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/446] 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/446] 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/446] 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/446] 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/446] 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/446] 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/446] 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/446] 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 52201ac9f7552bd06d67571ec0821f9e7615c0d4 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Fri, 14 Aug 2026 09:43:07 -0300 Subject: [PATCH 009/446] fix(session): recognize compression continuation across sub-second timestamp overlap _is_continuation_session() required float(child.started_at) >= float(parent.ended_at). A compression/cli_close handoff can persist the continuation row a few ms BEFORE the parent's ended_at lands (write-order race), so the strict comparison returned False despite every other piece of evidence (direct parent_session_id link, same source, end_reason=compression/cli_close, non-fork). The backend then labeled the continuation as relationship_type=child_session, causing duplicate same-title sidebar rows and spurious child-session entries (#6931). Allow a small tolerance (2s) for handoff-consistent timing; beyond it, an exact title match on the parent/child rows still confirms the same handoff. Fork, cross-source, and non-compression/cli_close guards are preserved, and materially overlapping children remain separate child sessions. Adds API-level regression tests (sub-second overlap forms one lineage; material overlap stays a child session; same-title evidence beyond tolerance) plus focused unit coverage of the classifier. Closes #6931 --- api/agent_sessions.py | 26 +++- tests/test_session_lineage_metadata_api.py | 159 +++++++++++++++++++++ 2 files changed, 183 insertions(+), 2 deletions(-) diff --git a/api/agent_sessions.py b/api/agent_sessions.py index c0a5078f..927f7063 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -53,6 +53,15 @@ MESSAGING_SOURCES = { CLI_MIN_UNTITLED_MESSAGE_COUNT = 6 CLI_MIN_UNTITLED_USER_MESSAGE_COUNT = 2 +# Sub-second scheduling/write-order races during a compression/cli_close +# handoff can persist the continuation row with ``started_at`` a few +# milliseconds BEFORE the parent's ``ended_at`` lands (#6931). The tolerance +# below lets those rows still classify as the next segment; the fork / +# cross-source / end_reason guards in ``_is_continuation_session`` already +# rule out unrelated rows, so a small window cannot collapse genuinely +# concurrently started children. +CONTINUATION_STARTED_AT_TOLERANCE_SECONDS = 2.0 + SOURCE_LABELS = { 'acp': 'ACP', 'api_server': 'API', @@ -312,7 +321,8 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: by ``hermes -c`` also records a new child session; for sidebar projection it should continue the same visible conversation rather than becoming a separate child-session row. Plain parent/child links that started before the - parent's ended boundary remain child sessions. + parent's ended boundary remain child sessions. A small scheduling/write-order + overlap between the handoff boundary timestamps is tolerated (#6931). Do not collapse lineage across raw sources. A WebUI session that continues from a Telegram/CLI/etc. parent must remain visible as its own surface-owned @@ -336,9 +346,21 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: # continuations when no boundary timestamp is available. return True try: - return float(child.get('started_at') or 0) >= float(ended_at) + child_started = float(child.get('started_at') or 0) + parent_ended = float(ended_at) except (TypeError, ValueError): return False + if child_started >= parent_ended - CONTINUATION_STARTED_AT_TOLERANCE_SECONDS: + return True + # Timestamps disagree beyond the tolerance. Independent evidence can still + # identify the same handoff: a compression/cli_close continuation carries + # the parent's conversation title verbatim (the #6931 rows had identical + # titles), while a genuinely new or forked session gets its own + # auto-numbered/user title. Requiring an exact title match keeps unrelated + # same-source children separate even when they overlap the parent. + parent_title = str(parent.get('title') or '').strip() + child_title = str(child.get('title') or '').strip() + return bool(parent_title and child_title and parent_title == child_title) def _continuation_root_id(rows_by_id: dict[str, dict], session_id: str | None) -> str | None: diff --git a/tests/test_session_lineage_metadata_api.py b/tests/test_session_lineage_metadata_api.py index 22635e57..f79848ce 100644 --- a/tests/test_session_lineage_metadata_api.py +++ b/tests/test_session_lineage_metadata_api.py @@ -693,3 +693,162 @@ def test_sessions_route_preserves_visible_child_lineage_when_archived_parent_fil ] finally: conn.close() + + +def test_compression_continuation_tolerates_sub_second_timestamp_overlap(_isolate): + """#6931: a compression continuation recorded a few ms BEFORE the parent's + ended_at (write-order race) is still one lineage in /api/sessions.""" + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_race_root", title="Shared conversation", updated_at=t0) + _save_webui_session("lineage_race_tip", title="Shared conversation", updated_at=t0 + 10) + _insert_state_row( + conn, + "lineage_race_root", + started_at=t0, + ended_at=t0 + 5, + end_reason="compression", + ) + # child.started_at lands 0.1s BEFORE parent.ended_at — the #6931 race. + _insert_state_row( + conn, + "lineage_race_tip", + parent="lineage_race_root", + started_at=t0 + 5 - 0.1, + ) + + rows = {row["session_id"]: row for row in all_sessions()} + + tip = rows["lineage_race_tip"] + assert tip.get("parent_session_id") == "lineage_race_root" + assert tip.get("_lineage_root_id") == "lineage_race_root" + assert tip.get("_lineage_tip_id") == "lineage_race_tip" + assert tip.get("_compression_segment_count") == 2 + assert tip.get("relationship_type") != "child_session" + finally: + conn.close() + + +def test_materially_overlapping_compression_child_stays_child_session(_isolate): + """#6931: a child that started long before the compression parent ended + (beyond tolerance, no matching title) must remain a separate child.""" + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_overlap_root", title="Root title", updated_at=t0) + _save_webui_session("lineage_overlap_child", title="Child title", updated_at=t0 + 10) + _insert_state_row( + conn, + "lineage_overlap_root", + started_at=t0, + ended_at=t0 + 60, + end_reason="compression", + ) + # child started 30s before the parent ended — a genuine concurrent child. + _insert_state_row( + conn, + "lineage_overlap_child", + parent="lineage_overlap_root", + started_at=t0 + 30, + ) + + rows = {row["session_id"]: row for row in all_sessions()} + + child = rows["lineage_overlap_child"] + assert child.get("relationship_type") == "child_session" + assert child.get("parent_session_id") == "lineage_overlap_root" + assert "_lineage_root_id" not in child + assert "_compression_segment_count" not in child + finally: + conn.close() + + +def test_same_title_compression_child_recognized_beyond_timestamp_tolerance(_isolate): + """#6931: a direct same-source child with the parent's exact title is still + a continuation even when the boundary timestamps disagree beyond the + tolerance — timestamp is not the only gate.""" + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_title_root", title="Shared conversation", updated_at=t0) + _save_webui_session("lineage_title_tip", title="Shared conversation", updated_at=t0 + 10) + _insert_state_row( + conn, + "lineage_title_root", + title="Shared conversation", + started_at=t0, + ended_at=t0 + 60, + end_reason="compression", + ) + # child started 30s before the parent ended but carries the exact title. + _insert_state_row( + conn, + "lineage_title_tip", + title="Shared conversation", + parent="lineage_title_root", + started_at=t0 + 30, + ) + + rows = {row["session_id"]: row for row in all_sessions()} + + tip = rows["lineage_title_tip"] + assert tip.get("_lineage_root_id") == "lineage_title_root" + assert tip.get("_lineage_tip_id") == "lineage_title_tip" + assert tip.get("_compression_segment_count") == 2 + assert tip.get("relationship_type") != "child_session" + finally: + conn.close() + + +def test_continuation_classification_timestamp_tolerance_and_guards(): + """#6931 focused unit coverage of _is_continuation_session: tolerance, + title evidence fallback, and preserved fork/cross-source/end_reason guards.""" + from api.agent_sessions import _is_continuation_session + + def make_parent(**over): + row = { + 'source': 'webui', + 'end_reason': 'compression', + 'ended_at': 1000.0, + 'title': 'Shared conversation', + } + row.update(over) + return row + + def make_child(**over): + row = { + 'source': 'webui', + 'started_at': 1000.05, + 'title': 'Shared conversation', + } + row.update(over) + return row + + # Normal non-overlapping ordering: continuation. + assert _is_continuation_session(make_parent(), make_child()) + # Sub-second overlap (observed -0.06..-0.07s in #6931): continuation. + assert _is_continuation_session(make_parent(), make_child(started_at=999.95)) + # Overlap inside the 2s tolerance: continuation. + assert _is_continuation_session(make_parent(), make_child(started_at=998.5)) + # Overlap beyond tolerance but exact title evidence: continuation. + assert _is_continuation_session(make_parent(), make_child(started_at=950.0)) + # Overlap beyond tolerance with a different title: separate child. + assert not _is_continuation_session( + make_parent(), make_child(started_at=950.0, title='Another conversation') + ) + # Fork guard holds regardless of timing. + assert not _is_continuation_session( + make_parent(), make_child(started_at=999.95, session_source='fork') + ) + # Cross-source guard holds regardless of timing. + assert not _is_continuation_session( + make_parent(), make_child(started_at=999.95, source='telegram') + ) + # Non-compression/cli_close parents never continue. + assert not _is_continuation_session( + make_parent(end_reason='user_stop'), make_child(started_at=999.95) + ) + # Missing/unparsable boundary timestamps degrade to False (no crash). + assert not _is_continuation_session(make_parent(ended_at='not-a-number'), make_child()) + assert not _is_continuation_session(make_parent(), make_child(started_at='not-a-number')) From c82c6a542a20ead7a72b085844f657f9004f57a1 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 17 Aug 2026 11:50:33 -0300 Subject: [PATCH 010/446] 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 6f0028cfa1ca565a69d9c5945b1b7bde347e0a56 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Fri, 14 Aug 2026 17:26:05 -0300 Subject: [PATCH 011/446] fix(session): exclude model_config branches before tolerance; drop title fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-gate #7021: two silent sidebar regressions on the #6931 continuation tolerance. 1. MUST-FIX 1 — branch marker in model_config. The session_source=='fork' guard only covers WebUI-created forks (/api/session/branch). Explicit Agent branches/delegates carry no session_source: they are marked in the model_config JSON column (_branched_from / _delegate_from). The caller queries did not select model_config at all, so an Agent branch starting inside the 2s tolerance window of its parent's compression passed the widened check and vanished from the sidebar. Fix: select model_config in the classifier's caller queries (get_cli_sessions, lineage report, lineage metadata) and reject a child whose _branched_from / _delegate_from points at the parent being classified, BEFORE the tolerance is applied. The marker must reference this parent: compression continuations inherit the rotated agent's model_config verbatim (mirrors hermes-agent _NON_CONTINUATION_CHILD_FILTER_SQL). 2. MUST-FIX 2 — unbounded title fallback removed. Titles are user-controlled and non-unique, so parent_title == child_title beyond the tolerance window collapsed independent same-title branches arbitrarily far apart. The fallback is gone entirely; only the bounded 2s early-side tolerance (CONTINUATION_STARTED_AT_TOLERANCE_SECONDS) remains, after the reliable non-continuation guards. Tests: same-title independent child outside tolerance stays visible (was collapsed); _branched_from/_delegate_from children inside the tolerance stay visible (reviewer's adversarial probe); unit coverage for marker parsing (JSON string, dict, unparsable) and marker-points-at-other-session preservation. All 4 new tests are RED on the previous head and GREEN here. Closes #6931 --- api/agent_sessions.py | 77 ++++++-- ...t_issue5455_listing_readonly_connection.py | 71 ++++++++ tests/test_session_lineage_metadata_api.py | 165 +++++++++++++++--- 3 files changed, 279 insertions(+), 34 deletions(-) diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 927f7063..76a47411 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -1,4 +1,5 @@ """Shared helpers for reading Hermes Agent sessions from state.db.""" +import json import logging import sqlite3 from contextlib import closing @@ -57,9 +58,9 @@ CLI_MIN_UNTITLED_USER_MESSAGE_COUNT = 2 # handoff can persist the continuation row with ``started_at`` a few # milliseconds BEFORE the parent's ``ended_at`` lands (#6931). The tolerance # below lets those rows still classify as the next segment; the fork / -# cross-source / end_reason guards in ``_is_continuation_session`` already -# rule out unrelated rows, so a small window cannot collapse genuinely -# concurrently started children. +# model_config branch-marker / cross-source / end_reason guards in +# ``_is_continuation_session`` already rule out unrelated rows, so a small +# window cannot collapse genuinely concurrently started children. CONTINUATION_STARTED_AT_TOLERANCE_SECONDS = 2.0 SOURCE_LABELS = { @@ -314,6 +315,30 @@ def is_cli_session_row_visible(row: dict) -> bool: return _count_user_turns(row) >= CLI_MIN_UNTITLED_USER_MESSAGE_COUNT +def _branch_markers(row: dict | None) -> tuple[str | None, str | None]: + """Return ``(_branched_from, _delegate_from)`` from a row's ``model_config``. + + Hermes Agent stamps explicit branches and delegate/subagent runs in the + ``model_config`` JSON column (``_branched_from`` / ``_delegate_from``); + ``session_source='fork'`` alone only covers WebUI-created forks. Missing, + empty, or unparsable ``model_config`` degrades to ``(None, None)``. + """ + if not row: + return None, None + raw = row.get('model_config') + if isinstance(raw, dict): + config = raw + elif isinstance(raw, str) and raw.strip(): + try: + parsed = json.loads(raw) + except (TypeError, ValueError): + parsed = None + config = parsed if isinstance(parsed, dict) else {} + else: + config = {} + return config.get('_branched_from'), config.get('_delegate_from') + + def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: """Return True when ``child`` is the next segment of the same conversation. @@ -328,11 +353,29 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: from a Telegram/CLI/etc. parent must remain visible as its own surface-owned conversation; otherwise the tip inherits the root's title/source metadata and can disappear under messaging/sidebar policies. + + Explicit branches/delegates never continue: a child whose ``model_config`` + ``_branched_from`` / ``_delegate_from`` points at this parent is a fork of + the conversation and must stay visible. Beyond the bounded tolerance window + there is no reliable continuation signal — titles are user-controlled and + non-unique, so they are never used to widen the window (#7021 re-gate). """ if not parent or not child: return False if str(child.get('session_source') or '').strip().lower() == 'fork': return False + # Real Agent branches/delegates are marked in model_config (not + # session_source, which only WebUI-created forks carry). The marker must + # reference THIS parent: compression continuations inherit the rotated + # agent's model_config verbatim, so presence alone (a delegate's + # continuation still carries the delegate's own ``_delegate_from``) would + # misclassify real continuations. + child_branched_from, child_delegate_from = _branch_markers(child) + parent_id = parent.get('id') + if parent_id and ( + child_branched_from == parent_id or child_delegate_from == parent_id + ): + return False parent_source = str(parent.get('source') or '').strip().lower() child_source = str(child.get('source') or '').strip().lower() if parent_source and child_source and parent_source != child_source: @@ -352,15 +395,10 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool: return False if child_started >= parent_ended - CONTINUATION_STARTED_AT_TOLERANCE_SECONDS: return True - # Timestamps disagree beyond the tolerance. Independent evidence can still - # identify the same handoff: a compression/cli_close continuation carries - # the parent's conversation title verbatim (the #6931 rows had identical - # titles), while a genuinely new or forked session gets its own - # auto-numbered/user title. Requiring an exact title match keeps unrelated - # same-source children separate even when they overlap the parent. - parent_title = str(parent.get('title') or '').strip() - child_title = str(child.get('title') or '').strip() - return bool(parent_title and child_title and parent_title == child_title) + # Beyond the bounded early-side window the child is a genuine concurrent + # session — an exact title match is not evidence (titles are user-controlled + # and non-unique, so it cannot extend the tolerance). + return False def _continuation_root_id(rows_by_id: dict[str, dict], session_id: str | None) -> str | None: @@ -594,6 +632,7 @@ def read_importable_agent_session_rows( parent_expr = _optional_col('parent_session_id', session_cols) session_source_expr = _optional_col('session_source', session_cols) + model_config_expr = _optional_col('model_config', session_cols) ended_expr = _optional_col('ended_at', session_cols) end_reason_expr = _optional_col('end_reason', session_cols) user_id_expr = _optional_col('user_id', session_cols) @@ -715,6 +754,7 @@ def read_importable_agent_session_rows( SELECT s.id, s.title, s.model, s.message_count, s.started_at, s.source, {session_source_expr}, + {model_config_expr}, {user_id_expr}, {chat_id_expr}, {chat_type_expr}, @@ -797,6 +837,11 @@ def read_importable_agent_session_rows( params, ) projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()]) + # model_config is selected only so the continuation classifier can + # read the real _branched_from/_delegate_from markers; it is internal + # agent state and must not leak into the /api/sessions response. + for row in projected: + row.pop('model_config', None) projected = [_with_normalized_source(row) for row in projected] projected = [row for row in projected if is_cli_session_row_visible(row)] if limit is None: @@ -899,6 +944,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops: source_expr = _optional_col('source', session_cols) session_source_expr = _optional_col('session_source', session_cols) + model_config_expr = _optional_col('model_config', session_cols) title_expr = _optional_col('title', session_cols) started_expr = _optional_col('started_at', session_cols, '0') ended_expr = _optional_col('ended_at', session_cols) @@ -913,6 +959,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops: SELECT s.id, {source_expr}, {session_source_expr}, + {model_config_expr}, {title_expr}, {started_expr}, {parent_expr}, @@ -959,6 +1006,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops: SELECT s.id, {source_expr}, {session_source_expr}, + {model_config_expr}, {title_expr}, {started_expr}, {parent_expr}, @@ -1036,6 +1084,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st return {} session_source_expr = _optional_col('session_source', session_cols) source_expr = _optional_col('source', session_cols) + model_config_expr = _optional_col('model_config', session_cols) message_count_expr = _optional_col('message_count', session_cols, '0') # Scoped fetch via PRIMARY KEY + idx_sessions_parent rather than a # full table scan. The sessions table grows unbounded over time @@ -1072,7 +1121,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st placeholders = ','.join('?' * len(chunk)) cur.execute( f""" - SELECT s.id, {source_expr}, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr} + SELECT s.id, {source_expr}, {session_source_expr}, {model_config_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr} FROM sessions s WHERE s.id IN ({placeholders}) """, @@ -1101,7 +1150,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st placeholders = ','.join('?' * len(chunk)) cur.execute( f""" - SELECT s.id, {source_expr}, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr} + SELECT s.id, {source_expr}, {session_source_expr}, {model_config_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason, {message_count_expr} FROM sessions s WHERE s.parent_session_id IN ({placeholders}) """, diff --git a/tests/test_issue5455_listing_readonly_connection.py b/tests/test_issue5455_listing_readonly_connection.py index fc2cb263..9502fae1 100644 --- a/tests/test_issue5455_listing_readonly_connection.py +++ b/tests/test_issue5455_listing_readonly_connection.py @@ -74,6 +74,77 @@ def test_listing_opens_read_only_and_returns_rows(tmp_path, monkeypatch): assert "mode=ro" in calls[0]["target"] +def test_listing_keeps_model_config_branch_visible_and_never_leaks_it(tmp_path, monkeypatch): + """#7021 re-gate: a CLI/agent branch whose _branched_from marker lives in + model_config must stay visible even when it starts inside the 2s + compression tolerance window, and model_config must not leak into the + returned rows.""" + db = tmp_path / "state.db" + conn = sqlite3.connect(str(db)) + conn.execute( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, title TEXT, model TEXT, message_count INTEGER, + started_at REAL, source TEXT, session_source TEXT, + parent_session_id TEXT, ended_at REAL, end_reason TEXT, + model_config TEXT + ) + """ + ) + conn.execute( + """ + INSERT INTO sessions + (id, title, model, message_count, started_at, source, session_source, + parent_session_id, ended_at, end_reason, model_config) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "cli-parent", "Parent conversation", "gpt", 0, 1000.0, "cli", "cli", + None, 1100.0, "compression", None, + ), + ) + # An explicit Agent branch starting 1.5s BEFORE the parent's compression + # ended_at. No session_source — the fork identity lives in model_config. + conn.execute( + """ + INSERT INTO sessions + (id, title, model, message_count, started_at, source, session_source, + parent_session_id, ended_at, end_reason, model_config) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "cli-branch", "Branch conversation", "gpt", 0, 1098.5, "cli", "cli", + "cli-parent", None, None, '{"_branched_from": "cli-parent"}', + ), + ) + conn.execute( + "CREATE TABLE messages (id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, timestamp REAL)" + ) + conn.executemany( + "INSERT INTO messages (session_id, role, timestamp) VALUES (?,?,?)", + [ + ("cli-parent", "user", 1001.0), + ("cli-parent", "assistant", 1002.0), + ("cli-branch", "user", 1099.0), + ("cli-branch", "assistant", 1099.5), + ], + ) + conn.commit() + conn.close() + _record_connects(monkeypatch) + + out = read_importable_agent_session_rows(db, exclude_sources=None) + out_ids = {r["id"] for r in out} + + # The branch survives the tolerance window: it is NOT collapsed into the + # parent lineage. + assert "cli-branch" in out_ids + assert "cli-parent" in out_ids + # model_config is internal agent state — never exposed on returned rows. + for row in out: + assert "model_config" not in row + + def test_listing_read_only_uri_encodes_special_path_chars(tmp_path, monkeypatch): db_dir = tmp_path / "state dir #1" db_dir.mkdir() diff --git a/tests/test_session_lineage_metadata_api.py b/tests/test_session_lineage_metadata_api.py index f79848ce..4d3c9c8c 100644 --- a/tests/test_session_lineage_metadata_api.py +++ b/tests/test_session_lineage_metadata_api.py @@ -1,5 +1,6 @@ """Regression tests for /api/sessions lineage metadata used by sidebar collapse.""" +import json import sqlite3 import time @@ -51,7 +52,8 @@ def _ensure_state_db(path): message_count INTEGER DEFAULT 0, parent_session_id TEXT, ended_at REAL, - end_reason TEXT + end_reason TEXT, + model_config TEXT ); """ ) @@ -79,14 +81,14 @@ def _insert_state_message(conn, sid, *, role, content, timestamp): conn.commit() -def _insert_state_row(conn, sid, *, title=None, parent=None, ended_at=None, end_reason=None, started_at=None, source='webui', session_source=None): +def _insert_state_row(conn, sid, *, title=None, parent=None, ended_at=None, end_reason=None, started_at=None, source='webui', session_source=None, model_config=None): conn.execute( """ INSERT INTO sessions - (id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) - VALUES (?, ?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?) + (id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason, model_config) + VALUES (?, ?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?, ?) """, - (sid, source, session_source, title or sid, started_at or time.time(), parent, ended_at, end_reason), + (sid, source, session_source, title or sid, started_at or time.time(), parent, ended_at, end_reason, model_config), ) conn.commit() @@ -764,10 +766,11 @@ def test_materially_overlapping_compression_child_stays_child_session(_isolate): conn.close() -def test_same_title_compression_child_recognized_beyond_timestamp_tolerance(_isolate): - """#6931: a direct same-source child with the parent's exact title is still - a continuation even when the boundary timestamps disagree beyond the - tolerance — timestamp is not the only gate.""" +def test_same_title_independent_child_outside_tolerance_stays_visible(_isolate): + """#7021 re-gate: a same-title independent child started well outside the + tolerance window must NOT collapse on the title match — titles are + user-controlled and non-unique, so the only continuation evidence is the + bounded early-side timestamp window.""" conn = _ensure_state_db(_isolate) t0 = time.time() - 100 try: @@ -781,33 +784,110 @@ def test_same_title_compression_child_recognized_beyond_timestamp_tolerance(_iso ended_at=t0 + 60, end_reason="compression", ) - # child started 30s before the parent ended but carries the exact title. + # Same title, but started 50s before the parent ended — far outside any + # handoff race window. Must remain a visible child session. _insert_state_row( conn, "lineage_title_tip", title="Shared conversation", parent="lineage_title_root", - started_at=t0 + 30, + started_at=t0 + 10, ) rows = {row["session_id"]: row for row in all_sessions()} tip = rows["lineage_title_tip"] - assert tip.get("_lineage_root_id") == "lineage_title_root" - assert tip.get("_lineage_tip_id") == "lineage_title_tip" - assert tip.get("_compression_segment_count") == 2 - assert tip.get("relationship_type") != "child_session" + assert tip.get("relationship_type") == "child_session" + assert tip.get("parent_session_id") == "lineage_title_root" + assert "_lineage_root_id" not in tip + assert "_compression_segment_count" not in tip + finally: + conn.close() + + +def test_model_config_branched_from_child_stays_visible_within_tolerance(_isolate): + """#7021 re-gate: an explicit Agent branch is marked in + model_config._branched_from (not session_source). Even when it starts + inside the 2s tolerance window of the parent's compression, the real + marker must keep it visible instead of collapsing it into the lineage.""" + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_branch_root", title="Shared conversation", updated_at=t0) + _save_webui_session("lineage_branch_tip", title="Shared conversation", updated_at=t0 + 10) + _insert_state_row( + conn, + "lineage_branch_root", + started_at=t0, + ended_at=t0 + 5, + end_reason="compression", + ) + # The reviewer's adversarial probe: an Agent branch starting 1.5s + # BEFORE the parent's compression ended_at. No session_source — the + # fork identity lives in model_config._branched_from. + _insert_state_row( + conn, + "lineage_branch_tip", + parent="lineage_branch_root", + started_at=t0 + 5 - 1.5, + model_config=json.dumps({"_branched_from": "lineage_branch_root"}), + ) + + rows = {row["session_id"]: row for row in all_sessions()} + + tip = rows["lineage_branch_tip"] + assert tip.get("relationship_type") == "child_session" + assert tip.get("parent_session_id") == "lineage_branch_root" + assert "_lineage_root_id" not in tip + assert "_compression_segment_count" not in tip + finally: + conn.close() + + +def test_model_config_delegate_from_child_stays_visible_within_tolerance(_isolate): + """#7021 re-gate: delegate/subagent runs are marked in + model_config._delegate_from. A delegate child starting inside the + tolerance window of the parent's compression must stay visible.""" + conn = _ensure_state_db(_isolate) + t0 = time.time() - 100 + try: + _save_webui_session("lineage_delegate_root", title="Shared conversation", updated_at=t0) + _save_webui_session("lineage_delegate_tip", title="Shared conversation", updated_at=t0 + 10) + _insert_state_row( + conn, + "lineage_delegate_root", + started_at=t0, + ended_at=t0 + 5, + end_reason="compression", + ) + _insert_state_row( + conn, + "lineage_delegate_tip", + parent="lineage_delegate_root", + started_at=t0 + 5 - 1.0, + model_config=json.dumps({"_delegate_from": "lineage_delegate_root"}), + ) + + rows = {row["session_id"]: row for row in all_sessions()} + + tip = rows["lineage_delegate_tip"] + assert tip.get("relationship_type") == "child_session" + assert tip.get("parent_session_id") == "lineage_delegate_root" + assert "_lineage_root_id" not in tip + assert "_compression_segment_count" not in tip finally: conn.close() def test_continuation_classification_timestamp_tolerance_and_guards(): - """#6931 focused unit coverage of _is_continuation_session: tolerance, - title evidence fallback, and preserved fork/cross-source/end_reason guards.""" + """#6931/#7021 focused unit coverage of _is_continuation_session: bounded + tolerance, model_config branch markers, and preserved fork/cross-source/ + end_reason guards.""" from api.agent_sessions import _is_continuation_session def make_parent(**over): row = { + 'id': 'parent-1', 'source': 'webui', 'end_reason': 'compression', 'ended_at': 1000.0, @@ -818,6 +898,7 @@ def test_continuation_classification_timestamp_tolerance_and_guards(): def make_child(**over): row = { + 'id': 'child-1', 'source': 'webui', 'started_at': 1000.05, 'title': 'Shared conversation', @@ -831,12 +912,56 @@ def test_continuation_classification_timestamp_tolerance_and_guards(): assert _is_continuation_session(make_parent(), make_child(started_at=999.95)) # Overlap inside the 2s tolerance: continuation. assert _is_continuation_session(make_parent(), make_child(started_at=998.5)) - # Overlap beyond tolerance but exact title evidence: continuation. - assert _is_continuation_session(make_parent(), make_child(started_at=950.0)) - # Overlap beyond tolerance with a different title: separate child. + # Overlap beyond tolerance: separate child, even with an exact title match + # (titles are user-controlled and non-unique — no title fallback). + assert not _is_continuation_session(make_parent(), make_child(started_at=950.0)) assert not _is_continuation_session( make_parent(), make_child(started_at=950.0, title='Another conversation') ) + # model_config._branched_from pointing at the parent: never a + # continuation, regardless of timing. + assert not _is_continuation_session( + make_parent(), + make_child( + started_at=999.95, + model_config=json.dumps({'_branched_from': 'parent-1'}), + ), + ) + assert not _is_continuation_session( + make_parent(), + make_child( + started_at=998.5, + model_config={'_branched_from': 'parent-1'}, + ), + ) + # model_config._delegate_from pointing at the parent: never a + # continuation, regardless of timing. + assert not _is_continuation_session( + make_parent(), + make_child( + started_at=999.95, + model_config=json.dumps({'_delegate_from': 'parent-1'}), + ), + ) + # A marker pointing at a DIFFERENT session does not disqualify: compression + # continuations inherit the rotated agent's model_config verbatim, so a + # delegate's continuation still carries the delegate's own marker. + assert _is_continuation_session( + make_parent(), + make_child( + started_at=999.95, + model_config=json.dumps({'_delegate_from': 'some-other-session'}), + ), + ) + # Unparsable model_config degrades to no markers (no crash, no match). + assert _is_continuation_session( + make_parent(), + make_child(started_at=999.95, model_config='not-json'), + ) + assert not _is_continuation_session( + make_parent(), + make_child(started_at=950.0, model_config='not-json'), + ) # Fork guard holds regardless of timing. assert not _is_continuation_session( make_parent(), make_child(started_at=999.95, session_source='fork') From cef2521f2ab4aefc03d7e8402ee7f10c791dd722 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 17 Aug 2026 11:54:37 -0300 Subject: [PATCH 012/446] 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 2e12075400dbff9d84ea515f1637d2ecd900ccae Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 17 Aug 2026 10:47:29 -0300 Subject: [PATCH 013/446] fix(session): apply branch-marker guard to transcript-stitcher lineage query (#7021 r2) The open/import transcript stitcher (get_state_db_session_messages with stitch_continuations=True) runs its own lineage SELECT that omitted session_source/model_config, so the shared _is_continuation_session guard added for the listing path never fired there: an explicit branch/delegate child (model_config._branched_from/_delegate_from) starting within the 2s tolerance window got its rows stitched into the parent transcript on open. Select both identity columns when the schema has them (NULL AS fallback for older state.db schemas), so the same guard applies in the stitch path. Tests (tests/test_session_lineage_metadata_api.py): - branched/delegate children inside the tolerance stay out of the parent transcript on open/import (fail before this fix, pass after); - old-schema rows without the identity columns still stitch genuine compression continuations (no crash, no behavior change). --- api/models.py | 25 ++- tests/test_session_lineage_metadata_api.py | 171 +++++++++++++++++++++ 2 files changed, 192 insertions(+), 4 deletions(-) diff --git a/api/models.py b/api/models.py index 2ad051b2..957eb519 100644 --- a/api/models.py +++ b/api/models.py @@ -8339,9 +8339,26 @@ def get_state_db_session_messages( cur.execute("PRAGMA table_info(sessions)") session_cols = {str(row['name']) for row in cur.fetchall()} if {'parent_session_id', 'end_reason', 'started_at', 'source'}.issubset(session_cols): + # session_source/model_config carry the fork/delegate boundary + # identity consumed by _is_continuation_session (#6931/#7021). + # Select them when present so the branch-marker guard applies + # to the stitch path too; older schemas degrade to NULL. + identity_cols = [] + if 'session_source' in session_cols: + identity_cols.append('session_source') + else: + identity_cols.append('NULL AS session_source') + if 'model_config' in session_cols: + identity_cols.append('model_config') + else: + identity_cols.append('NULL AS model_config') + lineage_select = ( + "id, source, started_at, parent_session_id, ended_at, end_reason" + + ", " + ", ".join(identity_cols) + ) cur.execute( - """ - SELECT id, source, started_at, parent_session_id, ended_at, end_reason + f""" + SELECT {lineage_select} FROM sessions WHERE id = ? """, @@ -8359,8 +8376,8 @@ def get_state_db_session_messages( if not parent_id or parent_id in seen: break cur.execute( - """ - SELECT id, source, started_at, parent_session_id, ended_at, end_reason + f""" + SELECT {lineage_select} FROM sessions WHERE id = ? """, diff --git a/tests/test_session_lineage_metadata_api.py b/tests/test_session_lineage_metadata_api.py index 4d3c9c8c..6e35bc1b 100644 --- a/tests/test_session_lineage_metadata_api.py +++ b/tests/test_session_lineage_metadata_api.py @@ -64,6 +64,7 @@ def _ensure_messages_table(conn): conn.execute( """ CREATE TABLE messages ( + id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, content TEXT, @@ -977,3 +978,173 @@ def test_continuation_classification_timestamp_tolerance_and_guards(): # Missing/unparsable boundary timestamps degrade to False (no crash). assert not _is_continuation_session(make_parent(ended_at='not-a-number'), make_child()) assert not _is_continuation_session(make_parent(), make_child(started_at='not-a-number')) + + +def test_state_db_stitch_keeps_branched_child_out_of_parent_transcript(_isolate): + """#7021 r2: the open/import transcript stitcher (get_state_db_session_messages + with stitch_continuations=True) must apply the same model_config branch-marker + guard as the listing classifier. A child whose model_config._branched_from / + _delegate_from points at the parent must NOT have its messages stitched into + the parent transcript even when it starts inside the tolerance window. + """ + conn = _ensure_state_db(_isolate) + _ensure_messages_table(conn) + t0 = time.time() - 100 + try: + # Compression parent whose child starts INSIDE the 2s tolerance window. + _insert_state_row( + conn, + "stitch_branch_parent", + started_at=t0, + ended_at=t0 + 5, + end_reason="compression", + ) + _insert_state_message( + conn, "stitch_branch_parent", role="user", content="parent turn", timestamp=t0 + 1 + ) + # Explicit Agent branch: fork identity lives in model_config, not + # session_source. Starting 1.5s before the parent's ended_at — inside + # the tolerance — it must still stay out of the parent's transcript. + _insert_state_row( + conn, + "stitch_branch_child", + parent="stitch_branch_parent", + started_at=t0 + 5 - 1.5, + model_config=json.dumps({"_branched_from": "stitch_branch_parent"}), + ) + _insert_state_message( + conn, "stitch_branch_child", role="user", content="branch turn", timestamp=t0 + 6 + ) + + msgs = models.get_state_db_session_messages( + "stitch_branch_child", stitch_continuations=True + ) + contents = [m["content"] for m in msgs] + assert "branch turn" in contents + assert "parent turn" not in contents + + # Control: a genuine compression continuation (no branch marker) starting + # inside the same window IS stitched into the parent transcript. + _insert_state_row( + conn, + "stitch_continuation_child", + parent="stitch_branch_parent", + started_at=t0 + 5 - 0.5, + ) + _insert_state_message( + conn, + "stitch_continuation_child", + role="user", + content="continuation turn", + timestamp=t0 + 7, + ) + msgs = models.get_state_db_session_messages( + "stitch_continuation_child", stitch_continuations=True + ) + contents = [m["content"] for m in msgs] + assert "continuation turn" in contents + assert "parent turn" in contents + finally: + conn.close() + + +def test_state_db_stitch_keeps_delegate_child_out_of_parent_transcript(_isolate): + """#7021 r2: same guard for model_config._delegate_from (subagent runs) in + the open/import transcript stitcher.""" + conn = _ensure_state_db(_isolate) + _ensure_messages_table(conn) + t0 = time.time() - 100 + try: + _insert_state_row( + conn, + "stitch_delegate_parent", + started_at=t0, + ended_at=t0 + 5, + end_reason="compression", + ) + _insert_state_message( + conn, "stitch_delegate_parent", role="user", content="parent turn", timestamp=t0 + 1 + ) + _insert_state_row( + conn, + "stitch_delegate_child", + parent="stitch_delegate_parent", + started_at=t0 + 5 - 1.0, + model_config=json.dumps({"_delegate_from": "stitch_delegate_parent"}), + ) + _insert_state_message( + conn, "stitch_delegate_child", role="user", content="delegate turn", timestamp=t0 + 6 + ) + + msgs = models.get_state_db_session_messages( + "stitch_delegate_child", stitch_continuations=True + ) + contents = [m["content"] for m in msgs] + assert "delegate turn" in contents + assert "parent turn" not in contents + finally: + conn.close() + + +def test_state_db_stitch_old_schema_without_identity_columns_still_stitches(_isolate): + """#7021 r2: older state.db schemas lacking session_source/model_config degrade + to NULL and keep the pre-fix behavior — a genuine compression continuation + within the tolerance is still stitched, without crashing.""" + conn = sqlite3.connect(str(_isolate)) + conn.executescript( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + title TEXT, + started_at REAL NOT NULL, + message_count INTEGER DEFAULT 0, + parent_session_id TEXT, + ended_at REAL, + end_reason TEXT + ); + """ + ) + _ensure_messages_table(conn) + t0 = time.time() - 100 + try: + conn.execute( + """ + INSERT INTO sessions + (id, source, title, started_at, message_count, parent_session_id, ended_at, end_reason) + VALUES (?, ?, ?, ?, 2, ?, ?, ?) + """, + ( + "stitch_old_parent", + "webui", + "stitch_old_parent", + t0, + None, + t0 + 5, + "compression", + ), + ) + conn.execute( + """ + INSERT INTO sessions + (id, source, title, started_at, message_count, parent_session_id, ended_at, end_reason) + VALUES (?, ?, ?, ?, 2, ?, NULL, NULL) + """, + ("stitch_old_child", "webui", "stitch_old_child", t0 + 4.5, "stitch_old_parent"), + ) + conn.commit() + _insert_state_message( + conn, "stitch_old_parent", role="user", content="parent turn", timestamp=t0 + 1 + ) + _insert_state_message( + conn, "stitch_old_child", role="user", content="old child turn", timestamp=t0 + 6 + ) + + msgs = models.get_state_db_session_messages( + "stitch_old_child", stitch_continuations=True + ) + contents = [m["content"] for m in msgs] + assert "old child turn" in contents + assert "parent turn" in contents + finally: + conn.close() From 7db2695a5e509cbee3d0ec02b388ff9d75436334 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 24 Aug 2026 09:54:05 -0300 Subject: [PATCH 014/446] 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 6f85d6e7807466758c9eda8902bc786a44a2d77e Mon Sep 17 00:00:00 2001 From: Brent Atchison Date: Fri, 4 Sep 2026 11:12:56 -0500 Subject: [PATCH 015/446] perf(sessions): skip redundant tool replay after scene restore --- .github/workflows/conversation-lifecycle.yml | 6 + TESTING.md | 29 +++- static/sessions.js | 6 + tests/browser_reconnect_scene_redraw.py | 171 +++++++++++++++++++ tests/test_reconnect_scene_redraw.py | 82 +++++++++ tests/test_static_asset_resolver.py | 9 +- 6 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 tests/browser_reconnect_scene_redraw.py create mode 100644 tests/test_reconnect_scene_redraw.py diff --git a/.github/workflows/conversation-lifecycle.yml b/.github/workflows/conversation-lifecycle.yml index e952d34f..a1898cdd 100644 --- a/.github/workflows/conversation-lifecycle.yml +++ b/.github/workflows/conversation-lifecycle.yml @@ -13,6 +13,7 @@ on: - 'server.py' - 'tests/browser_conversation_lifecycle.py' - 'tests/browser_historical_transcript_hydration.py' + - 'tests/browser_reconnect_scene_redraw.py' - 'requirements*.txt' - 'pyproject.toml' - '.github/workflows/conversation-lifecycle.yml' @@ -24,6 +25,7 @@ on: - 'server.py' - 'tests/browser_conversation_lifecycle.py' - 'tests/browser_historical_transcript_hydration.py' + - 'tests/browser_reconnect_scene_redraw.py' - 'requirements*.txt' - 'pyproject.toml' - '.github/workflows/conversation-lifecycle.yml' @@ -45,6 +47,9 @@ jobs: scenario: terminal-error - name: historical-transcript-hydration script: tests/browser_historical_transcript_hydration.py + - name: reconnect-scene-redraw + script: tests/browser_reconnect_scene_redraw.py + browsers: chromium steps: - uses: actions/checkout@v4 @@ -69,6 +74,7 @@ jobs: LIFECYCLE_ARTIFACT_DIR: lifecycle-artifacts HISTORICAL_HYDRATION_ARTIFACT_DIR: lifecycle-artifacts LIFECYCLE_SCENARIO: ${{ matrix.scenario }} + BROWSERS: ${{ matrix.browsers }} run: python ${{ matrix.script }} - name: Run settlement frame proof diff --git a/TESTING.md b/TESTING.md index 6b2f2862..d8fd0ff5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -137,11 +137,32 @@ HISTORICAL_HYDRATION_TEST_BITE=break-tool-link \ ``` The dedicated `Conversation lifecycle (informational)` workflow runs the current -proof rows (`normal`, `terminal-error`, and `historical-transcript-hydration`) and -stays non-blocking while the public +proof rows (`normal`, `terminal-error`, `historical-transcript-hydration`, and the +Chromium `reconnect-scene-redraw` matrix) and stays non-blocking while the public matrix expands to additional behavior rows. The maintainer's private QA harness -remains broader; later public slices will add session switching, reconnect/replay, -cancellation, compression, and recovery. +remains broader; later public slices will add cancellation, compression, and +recovery coverage. + +### Active-session reconnect redraw gate + +`tests/browser_reconnect_scene_redraw.py` is an opt-in deterministic browser +gate for reconnecting to a running session with a large Anchor activity scene. +It uses an isolated temporary server/home and fixture SSE events—no agent, +provider credentials, or production state. By default it checks Chromium and +WebKit, desktop and 390px viewports, and all three activity display modes: + +```bash +pip install playwright +python -m playwright install chromium webkit +python tests/browser_reconnect_scene_redraw.py +``` + +`TOOL_COUNT`, `BROWSERS`, and `MODES` narrow the matrix. `MEASURE_BASELINE=1` +records an unoptimized baseline without enforcing the redraw ceiling; +`METRICS_FILE` saves JSON metrics and `SCREENSHOT_DIR` saves generated-fixture +screenshots. This gate tests page reconstruction, journal-cursor resume, and +subsequent fixture SSE updates. It does not test a real provider/network or PWA +service-worker cache behavior. ### Streaming reader intent diff --git a/static/sessions.js b/static/sessions.js index b7a59359..60daf168 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2165,6 +2165,12 @@ async function loadSession(sid){ S.activeStreamId=activeStreamId; const liveToolReplayId=(tc)=>String(tc&&(tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||'')||'').trim(); const replayPersistedLiveToolCards=(opts)=>{ + // The journal-backed Anchor scene is authoritative through its resume + // cursor; newer rows arrive through the reattached SSE stream. Replaying + // the older INFLIGHT tool cache after a successful scene restore would + // redraw all N rows N times. Keep the #3707 replay only for legacy HTML + // restoration or a failed/unavailable scene render. + if(restoredAnchorScene) return; const liveToolCalls=Array.isArray(S.toolCalls) ? S.toolCalls : (Array.isArray(INFLIGHT[sid]&&INFLIGHT[sid].toolCalls)?INFLIGHT[sid].toolCalls:[]); diff --git a/tests/browser_reconnect_scene_redraw.py b/tests/browser_reconnect_scene_redraw.py new file mode 100644 index 00000000..8bc5118e --- /dev/null +++ b/tests/browser_reconnect_scene_redraw.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Real page/loadSession/renderers with deterministic session and SSE fixtures. + +No agent, provider, production state, or credentials. Tests DOM reconstruction, +not a real provider/network reconnect. +""" +import json +import os +import tempfile +import time +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + +from playwright.sync_api import sync_playwright + +from browser_conversation_lifecycle import _start_webui_server, _terminate_process + +ROOT = Path(os.environ.get('WEBUI_TEST_ROOT', Path(__file__).resolve().parent.parent)) + + +def fixture(count): + tools = [dict(name='terminal', tid=f'tool-{i}', args={'command': f'printf result-{i}'}, + preview=f'result-{i}', snippet=f'result-{i}', done=True) for i in range(count)] + rows = [dict(row_id=f'tool:{t["tid"]}', local_id=t['tid'], role='tool', kind='tool_call', + source_event_type='tool_complete', status='completed', order_index=i, + tool=t, payload=t) for i, t in enumerate(tools)] + return dict(stream_id='run-fixture', last_seq=1000, last_event_id='run-fixture:1000', + messages=[], tool_calls=tools, last_assistant_text='', last_reasoning_text='', + anchor_activity_scene=dict(version='activity_scene_v1', + identity=dict(session_id='fixture', stream_id='run-fixture', run_id='run-fixture'), + activity_rows=rows)) + + +INIT = """ +// A controlling service worker bypasses Playwright's page route fixtures in +// WebKit. This test targets reconnect rendering, not PWA cache behavior. +if(window===window.top && 'serviceWorker' in navigator){ + navigator.serviceWorker.register=()=>Promise.reject(new Error('Disabled in reconnect harness')); +} +window.fixtureSources=[]; +class FixtureEventSource { + static OPEN=1; static CONNECTING=0; static CLOSED=2; + constructor(url){this.url=String(url);this.readyState=1;this.listeners={};window.fixtureSources.push(this);} + addEventListener(name,fn){(this.listeners[name]||=[]).push(fn);} + removeEventListener(){} + close(){this.readyState=2;} + emit(name,data,id){for(const fn of this.listeners[name]||[])fn({data:JSON.stringify(data),lastEventId:id||''});} +} +window.EventSource=FixtureEventSource; +""" + + +def session_route(session, sid, workspace): + def handle(route): + query = parse_qs(urlsplit(route.request.url).query) + requested = query.get('session_id', [''])[0] + data = session if requested == sid else dict( + session_id='idle-fixture', messages=[], message_count=0, + tool_calls=[], workspace=workspace) + route.fulfill(json={'session': data}) + return handle + + +def main(): + tool_count = int(os.environ.get('TOOL_COUNT', '470')) + snapshot = fixture(tool_count) + stream_id = snapshot['stream_id'] + sid = 'fixture' + with tempfile.TemporaryDirectory(prefix='webui-reconnect-') as temp: + state = Path(temp) + env = {k:os.environ[k] for k in ('PATH','SYSTEMROOT','TMPDIR') if k in os.environ} + env.update(HOME=temp, HERMES_HOME=temp, HERMES_BASE_HOME=temp, + HERMES_WEBUI_STATE_DIR=str(state/'webui'), HERMES_CONFIG_PATH=str(state/'config.yaml'), + HERMES_WEBUI_HOST='127.0.0.1', HERMES_WEBUI_SKIP_ONBOARDING='1', + HERMES_WEBUI_AGENT_DIR=str(state/'no-agent')) + proc, log, _, base = _start_webui_server(ROOT, env, state) + results = [] + try: + with sync_playwright() as pw: + for engine in os.environ.get('BROWSERS','chromium,webkit').split(','): + browser = getattr(pw,engine).launch(headless=True) + try: + for mode in os.environ.get('MODES','compact_worklog,transparent_stream,hide_all_activity').split(','): + for width in [1280,390]: + context = browser.new_context(viewport={'width':width,'height':844},bypass_csp=True) + context.add_init_script(INIT) + page = context.new_page() + + errors=[] + page.on('pageerror',lambda e, errors=errors:errors.append(str(e))) + session = dict(session_id=sid,title='Reconnect performance fixture',model='', + workspace=temp,messages=[],message_count=0,tool_calls=[], + active_stream_id=stream_id,pending_user_message='Inspect the fixture', + pending_started_at=1,runtime_journal_snapshot=snapshot) + page.route('**/api/session?*',session_route(session,sid,temp)) + page.route('**/api/chat/stream/status?*',lambda r:r.fulfill(json={'active':True})) + page.goto(base,wait_until='load') + # WebKit's wait_for_function uses eval, blocked by + # the app CSP even with bypass_csp. Inspector + # evaluation still works; wait on actual boot state. + deadline=time.monotonic()+30 + while not page.evaluate("typeof loadSession==='function' && S._bootReady === true"): + assert time.monotonic(){ + window._chatActivityDisplayMode=mode;window._showThinking=true; + window._simplifiedToolCalling=true; + window.redrawCount=0; + const render=window._renderLiveAnchorActivitySceneForStream; + window._renderLiveAnchorActivitySceneForStream=function(...args){window.redrawCount++;return render(...args);}; + }""",mode) + measured=page.evaluate("""async sid=>{ + const t=performance.now();await loadSession(sid); + const loadMs=performance.now()-t; + const deadline=performance.now()+5000; + while(!fixtureSources.some(s=>s.url.includes('api/chat/stream?'))&&performance.now()setTimeout(r,10)); + } + return {ms:loadMs,redraws:window.redrawCount, + tools:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length, + busy:S.busy, + sources:fixtureSources.filter(s=>s.url.includes('api/chat/stream?')).map(s=>s.url)}; + }""",sid) + result=dict(engine=engine,mode=mode,width=width, + **{k:v for k,v in measured.items() if k!='sources'}) + print(json.dumps(result),flush=True) + results.append(result) + if not os.environ.get('MEASURE_BASELINE'): + assert measured['redraws']<=2,result + expected=0 if mode=='hide_all_activity' else tool_count + assert measured['tools']==expected,result + assert measured['busy'],result + assert measured['sources'], (result, errors) + cursor=parse_qs(urlsplit(measured['sources'][-1]).query) + assert cursor['after_seq']==[str(snapshot['last_seq'])], (result,errors) + if mode!='hide_all_activity': + # Subsequent real SSE handler updates still paint the existing owner. + updated=page.evaluate("""({sid,stream})=>{ + const source=fixtureSources.findLast(s=>s.url.includes('api/chat/stream?')&&s.readyState===1); + if(!source)throw new Error('missing chat stream'); + source.emit('tool',{name:'terminal',tid:'after-reconnect',args:{command:'printf later'},preview:'later'},stream+':1001'); + source.emit('tool_complete',{name:'terminal',tid:'after-reconnect',preview:'LATER RESULT',duration:1},stream+':1002'); + return {rows:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length, + result:document.querySelector('#liveAssistantTurn').textContent.includes('LATER RESULT')}; + }""",{'sid':sid,'stream':stream_id}) + assert updated=={'rows':expected+1,'result':True},updated + switched=page.evaluate("""async sid=>{ + await loadSession('idle-fixture'); + await loadSession(sid); + return {rows:document.querySelectorAll('#liveAssistantTurn [data-anchor-row-role="tool"]').length, + result:document.querySelector('#liveAssistantTurn').textContent.includes('LATER RESULT')}; + }""",sid) + assert switched==updated,switched + artifact=os.environ.get('SCREENSHOT_DIR') + if artifact: + Path(artifact).mkdir(parents=True,exist_ok=True) + page.screenshot(path=str(Path(artifact)/f'{engine}-{mode}-{width}.png')) + assert not errors,errors + context.close() + finally: + browser.close() + output=os.environ.get('METRICS_FILE') + if output: + Path(output).write_text(json.dumps(results,indent=2)) + finally: + _terminate_process(proc) + log.close() + + +if __name__=='__main__': + main() diff --git a/tests/test_reconnect_scene_redraw.py b/tests/test_reconnect_scene_redraw.py new file mode 100644 index 00000000..1c1f03e3 --- /dev/null +++ b/tests/test_reconnect_scene_redraw.py @@ -0,0 +1,82 @@ +"""Execute the production reconnect replay seam, not a source-string assertion. + +Browser coverage in browser_reconnect_scene_redraw.py exercises loadSession and +real DOM rendering; these cheap Node checks pin fallback and redraw counts. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +NODE = shutil.which('node') +pytestmark = pytest.mark.skipif(NODE is None, reason='Node.js is required for the reconnect replay probe') + + +def source_slice(text, start_marker, end_marker, path): + start = text.find(start_marker) + assert start >= 0, f'{path}: missing start marker {start_marker!r}' + end = text.find(end_marker, start) + assert end >= 0, f'{path}: missing end marker {end_marker!r}' + return text[start:end] + + +def replay_probe(restored_scene, count, skip_unkeyed=True, has_rows=True): + sessions = (ROOT / 'static/sessions.js').read_text() + ui = (ROOT / 'static/ui.js').read_text() + replay = source_slice( + sessions, 'const liveToolReplayId=(tc)=>', ' let didReconnect=false;', + 'static/sessions.js', + ) + append = source_slice( + ui, 'function appendLiveToolCard(', '\nfunction _findLatestLiveAssistantByBurst', + 'static/ui.js', + ) + script = r""" +const vm=require('node:vm'); +const opts=JSON.parse(process.argv[1]); +const tools=Array.from({length:opts.count},(_,i)=>({name:'terminal',tid:'tool-'+i,done:true})); +tools.push({name:'read_file',done:true}); +let redraws=0, appended=[]; +const sandbox={ + S:{session:{session_id:'session'},activeStreamId:'run',toolCalls:tools}, + sid:'session',activeStreamId:'run',INFLIGHT:{},restoredAnchorScene:opts.restored_scene, + document:{getElementById:()=>({querySelector:()=>opts.has_rows?{}:null})}, + isFinalAnswerOnlyMode:()=>false, + isLiveAnchorActivitySceneOwner:()=>true, + _renderLiveAnchorActivitySceneForStream:()=>{redraws++;return true;}, +}; +vm.createContext(sandbox); +vm.runInContext(APPEND,sandbox); +// The actual append function proves the amplification when a scene owns DOM. +// For legacy fallback, observe arguments at the append boundary instead. +if(!opts.restored_scene) sandbox.appendLiveToolCard=(tc)=>appended.push(tc.tid||'unkeyed'); +vm.runInContext(REPLAY+'\nreplayPersistedLiveToolCards('+JSON.stringify({skipUnkeyedRestoredDuplicates:opts.skip_unkeyed})+');',sandbox); +console.log(JSON.stringify({redraws,appended})); +""".replace('APPEND', json.dumps(append)).replace('REPLAY', json.dumps(replay)) + result = subprocess.run( + [NODE or 'node', '-e', script, json.dumps({ + 'restored_scene': restored_scene, 'count': count, + 'skip_unkeyed': skip_unkeyed, 'has_rows': has_rows, + })], capture_output=True, text=True, check=True, + ) + return json.loads(result.stdout) + + +@pytest.mark.parametrize('count', [0, 1, 470]) +def test_restored_complete_scene_is_not_redrawn_per_saved_tool(count): + result = replay_probe(True, count) + assert result['redraws'] == 0, result + + +def test_non_anchor_restore_still_replays_keyed_tools(): + result = replay_probe(False, 3) + assert result['appended'] == ['tool-0', 'tool-1', 'tool-2'] + + +@pytest.mark.parametrize('skip_unkeyed,has_rows', [(False, True), (True, False)]) +def test_failed_scene_or_empty_html_keeps_unkeyed_fallback(skip_unkeyed, has_rows): + result = replay_probe(False, 3, skip_unkeyed, has_rows) + assert result['appended'] == ['tool-0', 'tool-1', 'tool-2', 'unkeyed'] diff --git a/tests/test_static_asset_resolver.py b/tests/test_static_asset_resolver.py index 455a6dc7..c2e51c17 100644 --- a/tests/test_static_asset_resolver.py +++ b/tests/test_static_asset_resolver.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib import json from pathlib import Path from types import SimpleNamespace @@ -7,7 +8,7 @@ from urllib.parse import quote import api.config as api_config import api.routes as routes -from api.updates import WEBUI_VERSION + ROOT = Path(__file__).resolve().parent.parent @@ -81,8 +82,12 @@ def test_service_worker_and_favicon_follow_selected_static_root(tmp_path, monkey monkeypatch.setattr(api_config, "get_static_root", lambda: static_root) sw_handler = _get("/sw.js") + # The route resolves api.updates dynamically on each request. Other update + # tests deliberately evict and re-import that module, so a collection-time + # alias can become stale even though the checkout and runtime module agree. + webui_version = importlib.import_module("api.updates").WEBUI_VERSION expected = sw_path.read_text(encoding="utf-8").replace( - "__WEBUI_VERSION__", quote(WEBUI_VERSION, safe="") + "__WEBUI_VERSION__", quote(webui_version, safe="") ).encode("utf-8") assert sw_handler.status == 200 assert sw_handler.header("Service-Worker-Allowed") == "/" From 23ddcf6cb1b75e1d0a8c92954cbc7b63a714c054 Mon Sep 17 00:00:00 2001 From: Brent Atchison Date: Sat, 5 Sep 2026 10:33:45 -0500 Subject: [PATCH 016/446] ci: enforce reconnect browser regression coverage --- .github/workflows/conversation-lifecycle.yml | 3 ++- TESTING.md | 14 ++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/conversation-lifecycle.yml b/.github/workflows/conversation-lifecycle.yml index a1898cdd..6f49c3c8 100644 --- a/.github/workflows/conversation-lifecycle.yml +++ b/.github/workflows/conversation-lifecycle.yml @@ -34,7 +34,8 @@ jobs: live-to-final: runs-on: ubuntu-latest timeout-minutes: 10 - continue-on-error: true + # Keep existing proof rows informational; reconnect regressions fail CI. + continue-on-error: ${{ matrix.name != 'reconnect-scene-redraw' }} strategy: fail-fast: false matrix: diff --git a/TESTING.md b/TESTING.md index d8fd0ff5..61d77776 100644 --- a/TESTING.md +++ b/TESTING.md @@ -136,12 +136,14 @@ HISTORICAL_HYDRATION_TEST_BITE=break-tool-link \ python tests/browser_historical_transcript_hydration.py ``` -The dedicated `Conversation lifecycle (informational)` workflow runs the current -proof rows (`normal`, `terminal-error`, `historical-transcript-hydration`, and the -Chromium `reconnect-scene-redraw` matrix) and stays non-blocking while the public -matrix expands to additional behavior rows. The maintainer's private QA harness -remains broader; later public slices will add cancellation, compression, and -recovery coverage. +The dedicated `Conversation lifecycle (informational)` workflow keeps the existing +proof rows (`normal`, `terminal-error`, and `historical-transcript-hydration`) +non-blocking while the public matrix expands. The Chromium +`reconnect-scene-redraw` row does **not** allow failures: it exercises the real +`loadSession` reconnect path and fails the workflow on a regression. Required +merge checks remain a maintainer-controlled repository setting. +The maintainer's private QA harness remains broader; later public slices will +add cancellation, compression, and recovery coverage. ### Active-session reconnect redraw gate From cd516704199e2f152c5c3ed98c264df1a1519e62 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 26 Aug 2026 10:53:17 -0300 Subject: [PATCH 017/446] fix(profiles): guard agent.skill_utils import in skills-stats fallback (#7305) --- api/profiles.py | 29 +++++++++- tests/test_issue7305_profiles_fallback.py | 64 +++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue7305_profiles_fallback.py diff --git a/api/profiles.py b/api/profiles.py index f5e8432a..ad95993b 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -1862,7 +1862,34 @@ def _compute_profile_skills_stats(profile_dir: Path) -> tuple[int, int]: except Exception: pass - from agent.skill_utils import iter_skill_index_files, parse_frontmatter, skill_matches_platform + try: + from agent.skill_utils import iter_skill_index_files, parse_frontmatter, skill_matches_platform + except ImportError: + # agent source not mounted (two-container Docker, + # HERMES_WEBUI_CHAT_BACKEND=gateway): the fallback profile path must + # still work — never 500 GET /api/profiles (#7305). Best-effort local + # scan: no platform filtering, minimal frontmatter parse. + def iter_skill_index_files(root, filename="SKILL.md"): + for dirpath, _dirnames, filenames in os.walk(root, followlinks=True): + if filename in filenames: + yield Path(dirpath) / filename + + def parse_frontmatter(content): + data = {} + try: + if content.startswith("---"): + _head, _sep, rest = content.partition("\n---") + fm, _, _body = rest.partition("\n---") + for line in fm.splitlines(): + if ":" in line: + k, v = line.split(":", 1) + data[k.strip()] = v.strip().strip('"\'') + except Exception: + pass + return data, None + + def skill_matches_platform(_frontmatter): + return True seen_names = set() enabled_count = 0 diff --git a/tests/test_issue7305_profiles_fallback.py b/tests/test_issue7305_profiles_fallback.py new file mode 100644 index 00000000..c89be6e1 --- /dev/null +++ b/tests/test_issue7305_profiles_fallback.py @@ -0,0 +1,64 @@ +"""#7305: GET /api/profiles must never 500 when agent.skill_utils is not +importable (two-container Docker without the hermes-agent source mounted).""" + +from __future__ import annotations + +import builtins +import importlib +from pathlib import Path + +import pytest + +import api.profiles as profiles + + +def _profile_dir(tmp_path, skill_name="my-skill"): + d = tmp_path / "home" + skills = d / "skills" + (skills / skill_name).mkdir(parents=True) + (skills / skill_name / "SKILL.md").write_text( + "---\nname: " + skill_name + "\ndescription: test skill\n---\nbody\n", + encoding="utf-8", + ) + return d + + +def test_skills_stats_works_without_agent_skill_utils(tmp_path): + """The fallback local scan must serve the count when the agent module is + absent — no ImportError escapes into GET /api/profiles.""" + d = _profile_dir(tmp_path) + real_import = builtins.__import__ + + def no_agent(name, *args, **kwargs): + if name == "agent.skill_utils" or name.startswith("agent."): + raise ImportError("agent not mounted (simulated Docker)") + return real_import(name, *args, **kwargs) + + importlib.reload(profiles) # ensure the module-level agent imports are re-evaluated + try: + # Patch the import used by _get_profile_skills_stats' compute path. + import api.profiles as p2 + orig = p2.__dict__.get("iter_skill_index_files") + # The fallback is installed inside the function; simulate the missing + # module by patching builtins for the module's import machinery. + with pytest.MonkeyPatch.context() as mp: + mp.setattr(builtins, "__import__", no_agent) + enabled, compatible = profiles._get_profile_skills_stats(d) + finally: + pass + assert compatible >= 1, "must count at least the local SKILL.md without agent.skill_utils" + assert enabled >= 1 + + +def test_default_profile_dict_never_raises_without_agent(tmp_path): + """_default_profile_dict() (the fallback row for GET /api/profiles) must + return a dict even with no agent module available.""" + d = _profile_dir(tmp_path) + orig = profiles._DEFAULT_HERMES_HOME + try: + profiles._DEFAULT_HERMES_HOME = d + row = profiles._default_profile_dict() + finally: + profiles._DEFAULT_HERMES_HOME = orig + assert row["name"] == "default" + assert row["skill_count"] >= 1 From d2e470b218bcad28f050e7f3d8392580758887d3 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 26 Aug 2026 10:59:01 -0300 Subject: [PATCH 018/446] test(profiles): fix ruff F401/F841 in #7305 regression --- tests/test_issue7305_profiles_fallback.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/test_issue7305_profiles_fallback.py b/tests/test_issue7305_profiles_fallback.py index c89be6e1..c79d403f 100644 --- a/tests/test_issue7305_profiles_fallback.py +++ b/tests/test_issue7305_profiles_fallback.py @@ -5,7 +5,6 @@ from __future__ import annotations import builtins import importlib -from pathlib import Path import pytest @@ -37,10 +36,6 @@ def test_skills_stats_works_without_agent_skill_utils(tmp_path): importlib.reload(profiles) # ensure the module-level agent imports are re-evaluated try: # Patch the import used by _get_profile_skills_stats' compute path. - import api.profiles as p2 - orig = p2.__dict__.get("iter_skill_index_files") - # The fallback is installed inside the function; simulate the missing - # module by patching builtins for the module's import machinery. with pytest.MonkeyPatch.context() as mp: mp.setattr(builtins, "__import__", no_agent) enabled, compatible = profiles._get_profile_skills_stats(d) From 86c2252dc80c3f77dffb86ba3874290d0d6b66f4 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Fri, 11 Sep 2026 18:08:19 -0300 Subject: [PATCH 019/446] fix(profiles): report skill stats as unknown when agent source is unmounted (#7305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the #7305 regression fix. - Drop the local shadow of ``agent.skill_utils`` (an ``os.walk`` + frontmatter parser + always-True platform filter). A local re-implementation cannot preserve the index walk's directory exclusions, frontmatter-name identity or platform filtering, so it reported inaccurate counts: a SKILL.md declared for another platform was counted as enabled, and support / dependency / VCS / inactive org-mirror files were counted as skills. - The Agent-absent branch now reports the stats as unknown — a stable ``(0, 0)``. GET /api/profiles still cannot 500, and the picker never shows a count it cannot verify (the UI omits the skills line when the total is 0). - Tests now pin that contract: force ``ImportError`` for ``agent.skill_utils`` (and ``hermes_cli``) and assert the unknown-stats result, the fallback row from ``_default_profile_dict()``, and the isolated-mode ``/api/profiles`` path end to end. The fixture reproduces the shapes a partial local scan gets wrong — a directory name differing from the declared ``name``, two directories declaring the same name, a declared name disabled for the webui platform, a platform-incompatible skill, and the paths the index walk prunes (support dirs of a skill package, dependency/VCS/metadata dirs, an inactive org mirror) — and a positive control runs the same tree through the real Agent-backed path so the fixture cannot degrade into a tree with nothing to count. --- api/profiles.py | 32 +-- tests/test_issue7305_profiles_fallback.py | 232 ++++++++++++++++++---- 2 files changed, 201 insertions(+), 63 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index ad95993b..221a2ebc 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -1866,30 +1866,14 @@ def _compute_profile_skills_stats(profile_dir: Path) -> tuple[int, int]: from agent.skill_utils import iter_skill_index_files, parse_frontmatter, skill_matches_platform except ImportError: # agent source not mounted (two-container Docker, - # HERMES_WEBUI_CHAT_BACKEND=gateway): the fallback profile path must - # still work — never 500 GET /api/profiles (#7305). Best-effort local - # scan: no platform filtering, minimal frontmatter parse. - def iter_skill_index_files(root, filename="SKILL.md"): - for dirpath, _dirnames, filenames in os.walk(root, followlinks=True): - if filename in filenames: - yield Path(dirpath) / filename - - def parse_frontmatter(content): - data = {} - try: - if content.startswith("---"): - _head, _sep, rest = content.partition("\n---") - fm, _, _body = rest.partition("\n---") - for line in fm.splitlines(): - if ":" in line: - k, v = line.split(":", 1) - data[k.strip()] = v.strip().strip('"\'') - except Exception: - pass - return data, None - - def skill_matches_platform(_frontmatter): - return True + # HERMES_WEBUI_CHAT_BACKEND=gateway): this must never 500 GET + # /api/profiles (#7305). Report the skill stats as unknown — a stable + # (0, 0) — instead of keeping a partial shadow of agent.skill_utils + # here: a local re-implementation cannot preserve the index walk's + # exclusions, frontmatter-name identity or platform filtering, so any + # count it produced would be inaccurate. The UI omits the skills line + # when the total is 0, so the profile picker stays fully usable. + return (0, 0) seen_names = set() enabled_count = 0 diff --git a/tests/test_issue7305_profiles_fallback.py b/tests/test_issue7305_profiles_fallback.py index c79d403f..8407de62 100644 --- a/tests/test_issue7305_profiles_fallback.py +++ b/tests/test_issue7305_profiles_fallback.py @@ -1,59 +1,213 @@ -"""#7305: GET /api/profiles must never 500 when agent.skill_utils is not -importable (two-container Docker without the hermes-agent source mounted).""" +"""#7305: GET /api/profiles must never 500 when the hermes-agent source is not +importable (two-container Docker / HERMES_WEBUI_CHAT_BACKEND=gateway). + +Contract pinned here: when ``agent.skill_utils`` is missing, the WebUI reports +the skill stats as *unknown* — a stable ``(0, 0)`` — instead of counting skills +with a local partial re-implementation of the index walk. The profile picker +still renders, and it never claims a count it cannot verify (the UI omits the +skills line when the total is 0). + +The fixture tree deliberately reproduces the shapes that a partial local scan +gets wrong: a SKILL.md whose declared ``name`` differs from its directory, two +directories declaring the same name, a declared name disabled in the profile +config, a platform-incompatible skill, and the paths the real index walk prunes +(support dirs of a skill package, dependency/VCS/metadata dirs, an inactive +org-mirror). ``test_agent_backed_path_counts_these_shapes_accurately`` is the +positive control: it runs the same tree through the real agent-backed path, so +the fixture cannot silently degrade into "a tree that never had a count to +lose". +""" from __future__ import annotations import builtins -import importlib +import pathlib import pytest import api.profiles as profiles +# How a fixture entry relates to the reported counts. +COUNTED = "counted" # yields a logical skill; contributes to compatible/enabled +WALK_PRUNED = "walk-pruned" # never yielded by agent.skill_utils' index walk +PLATFORM_FILTERED = "platform-filtered" # walked, but incompatible with this host -def _profile_dir(tmp_path, skill_name="my-skill"): - d = tmp_path / "home" - skills = d / "skills" - (skills / skill_name).mkdir(parents=True) - (skills / skill_name / "SKILL.md").write_text( - "---\nname: " + skill_name + "\ndescription: test skill\n---\nbody\n", - encoding="utf-8", - ) - return d +# path -> (declared frontmatter name, platform restriction or None, outcome) +SKILLS_TREE = { + # Declared name differs from the directory name; that declared name is + # disabled for the webui platform in config.yaml below. + "alpha-dir/SKILL.md": ("my-skill", None, COUNTED), + # A second directory declaring the same name — one logical skill. + "beta-dir/SKILL.md": ("my-skill", None, COUNTED), + # Platform-incompatible: must not be offered (or counted) on this host. + "gamma-dir/SKILL.md": ("gamma-skill", "some-other-platform", PLATFORM_FILTERED), + # A plain, enabled skill. + "delta-dir/SKILL.md": ("delta-skill", None, COUNTED), + # Support dir of a skill package: disclosure-only, not a standalone skill. + "delta-dir/references/notes/SKILL.md": ("support-note", None, WALK_PRUNED), + # Dependency / virtualenv / VCS / metadata dirs are excluded wholesale, and + # an org mirror without its .active_org marker is token-gated shut. + "node_modules/dep/SKILL.md": ("vendored-skill", None, WALK_PRUNED), + ".venv/lib/pkg/SKILL.md": ("venv-skill", None, WALK_PRUNED), + ".git/hooks/SKILL.md": ("hook-skill", None, WALK_PRUNED), + "_org/other-org/org-skill/SKILL.md": ("org-skill", None, WALK_PRUNED), +} + +# The one declared name the profile config disables for the webui platform. +DISABLED_NAMES = {"my-skill"} + +CONFIG_YAML = ( + "profile:\n" + " name: default\n" + "skills:\n" + " platform_disabled:\n" + " webui:\n" + " - my-skill\n" +) + +CONFIG_WITHOUT_DISABLE = "profile:\n name: default\n" -def test_skills_stats_works_without_agent_skill_utils(tmp_path): - """The fallback local scan must serve the count when the agent module is - absent — no ImportError escapes into GET /api/profiles.""" - d = _profile_dir(tmp_path) +def _expected_walked() -> set[str]: + return { + rel + for rel, (_, _, outcome) in SKILLS_TREE.items() + if outcome != WALK_PRUNED + } + + +def _expected_names(outcome: str) -> set[str]: + return {name for name, _, entry_outcome in SKILLS_TREE.values() if entry_outcome == outcome} + + +@pytest.fixture() +def agent_absent(monkeypatch): + """Simulate the agent-less deployment: importing anything under ``agent.`` + raises ``ImportError``, even when the source tree is present locally.""" real_import = builtins.__import__ - def no_agent(name, *args, **kwargs): - if name == "agent.skill_utils" or name.startswith("agent."): - raise ImportError("agent not mounted (simulated Docker)") + def _no_agent(name, *args, **kwargs): + if name == "agent" or str(name).startswith("agent."): + raise ImportError(f"{name} not mounted (simulated two-container Docker)") return real_import(name, *args, **kwargs) - importlib.reload(profiles) # ensure the module-level agent imports are re-evaluated - try: - # Patch the import used by _get_profile_skills_stats' compute path. - with pytest.MonkeyPatch.context() as mp: - mp.setattr(builtins, "__import__", no_agent) - enabled, compatible = profiles._get_profile_skills_stats(d) - finally: - pass - assert compatible >= 1, "must count at least the local SKILL.md without agent.skill_utils" - assert enabled >= 1 + monkeypatch.setattr(builtins, "__import__", _no_agent) -def test_default_profile_dict_never_raises_without_agent(tmp_path): +@pytest.fixture() +def hermes_cli_absent(monkeypatch): + """Simulate the same deployment's other half: no ``hermes_cli`` either.""" + real_import = builtins.__import__ + + def _no_cli(name, *args, **kwargs): + if name == "hermes_cli" or str(name).startswith("hermes_cli."): + raise ImportError(f"{name} not mounted (simulated two-container Docker)") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _no_cli) + + +def _write_profile(tmp_path: pathlib.Path, dir_name: str = "home") -> pathlib.Path: + """Materialise SKILLS_TREE + config.yaml as a profile home.""" + home = tmp_path / dir_name + skills = home / "skills" + for rel, (name, platform, _outcome) in SKILLS_TREE.items(): + skill_md = skills / rel + skill_md.parent.mkdir(parents=True, exist_ok=True) + lines = ["---", f"name: {name}", "description: issue 7305 fixture"] + if platform: + lines += ["platforms:", f" - {platform}"] + lines += ["---", "body", ""] + skill_md.write_text("\n".join(lines), encoding="utf-8") + (home / "config.yaml").write_text(CONFIG_YAML, encoding="utf-8") + return home + + +def test_skills_stats_are_unknown_without_agent_skill_utils(tmp_path, agent_absent): + """Without agent.skill_utils the stats are (0, 0) — never a guessed count, + no matter which fixtures a partial local scan would have counted.""" + home = _write_profile(tmp_path) + + assert profiles._get_profile_skills_stats(home) == (0, 0) + + +def test_default_profile_dict_never_raises_without_agent(tmp_path, agent_absent, monkeypatch): """_default_profile_dict() (the fallback row for GET /api/profiles) must - return a dict even with no agent module available.""" - d = _profile_dir(tmp_path) - orig = profiles._DEFAULT_HERMES_HOME - try: - profiles._DEFAULT_HERMES_HOME = d - row = profiles._default_profile_dict() - finally: - profiles._DEFAULT_HERMES_HOME = orig + return a usable row with unknown skill stats instead of raising.""" + home = _write_profile(tmp_path) + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", home) + + row = profiles._default_profile_dict() + assert row["name"] == "default" - assert row["skill_count"] >= 1 + assert row["path"] == str(home) + assert row["is_default"] is True + assert row["skill_count"] == 0 + assert row["enabled_skills"] == 0 + assert row["total_skills"] == 0 + + +def test_list_profiles_api_isolated_mode_without_agent_or_cli( + tmp_path, agent_absent, hermes_cli_absent, monkeypatch +): + """The #7305 path end to end: isolated profile mode with neither + agent.skill_utils nor hermes_cli importable must still return the + default-only row instead of propagating ImportError out of the route.""" + home = _write_profile(tmp_path, dir_name="default") + monkeypatch.setattr(profiles, "_is_isolated_profile_mode", lambda: True) + monkeypatch.setattr(profiles, "_INITIAL_HERMES_HOME", str(home)) + + rows = profiles.list_profiles_api() + + assert [r["name"] for r in rows] == ["default"] + assert rows[0]["is_active"] is True + assert rows[0]["skill_count"] == 0 + assert rows[0]["enabled_skills"] == 0 + assert rows[0]["total_skills"] == 0 + + +def test_agent_backed_path_counts_these_shapes_accurately(tmp_path): + """Positive control (agent source present): the fixture above is a real + semantic trap, not a tree with nothing to count. + + - the index walk yields exactly the non-pruned SKILL.md files, so the + support/dependency/VCS/org-mirror files a naive local ``os.walk`` would + pick up are invisible to the count; + - the platform-incompatible and duplicate-declared-name files do not add to + the compatible total, which therefore stays below the walk's file count; + - identity comes from frontmatter ``name``: the disabled ``my-skill`` (a + declared name, not a directory) is compatible but not enabled; + - dropping that config disable flips it to enabled without changing the + compatible total. + """ + pytest.importorskip("agent.skill_utils") + from agent.skill_utils import iter_skill_index_files + + home = _write_profile(tmp_path) + skills = home / "skills" + + walked = { + p.relative_to(skills).as_posix() for p in iter_skill_index_files(skills, "SKILL.md") + } + on_disk = {p.relative_to(skills).as_posix() for p in skills.rglob("SKILL.md")} + + expected_walked = _expected_walked() + expected_compatible = _expected_names(COUNTED) + expected_enabled = expected_compatible - DISABLED_NAMES + + # The fixture must contain paths a naive scan would over-count... + assert on_disk > walked, "fixture must include SKILL.md files the index walk prunes" + # ...and the walk must still see exactly the paths it is documented to keep. + assert walked == expected_walked + + enabled, compatible = profiles._compute_profile_skills_stats(home) + + assert compatible == len(expected_compatible) + assert compatible < len(walked) + assert enabled == len(expected_enabled) + + (home / "config.yaml").write_text(CONFIG_WITHOUT_DISABLE, encoding="utf-8") + enabled_all, compatible_all = profiles._compute_profile_skills_stats(home) + + assert compatible_all == compatible + assert enabled_all == len(expected_compatible) From aeab2fb99f7bbf484f174663681c28421d9e1364 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Fri, 11 Sep 2026 18:27:46 -0300 Subject: [PATCH 020/446] test(profiles): derive skill-count expectations from the agent walk output (#7305) The positive control asserted on_disk > walked, i.e. it froze the agent package's index-walk exclusion list. In an environment whose agent.skill_utils yields every SKILL.md (no pruning) both sets are equal and the assertion failed, which is what broke CI on the previous head. Expectations are now derived from the paths the walk actually yielded, so the test keeps asserting the property that belongs to this code - the compatible total stays strictly below the walked file count via platform filtering and frontmatter-name deduplication - without freezing another package's exclusion policy. --- tests/test_issue7305_profiles_fallback.py | 49 +++++++++++++++-------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/tests/test_issue7305_profiles_fallback.py b/tests/test_issue7305_profiles_fallback.py index 8407de62..3a3331e3 100644 --- a/tests/test_issue7305_profiles_fallback.py +++ b/tests/test_issue7305_profiles_fallback.py @@ -76,10 +76,6 @@ def _expected_walked() -> set[str]: } -def _expected_names(outcome: str) -> set[str]: - return {name for name, _, entry_outcome in SKILLS_TREE.values() if entry_outcome == outcome} - - @pytest.fixture() def agent_absent(monkeypatch): """Simulate the agent-less deployment: importing anything under ``agent.`` @@ -170,11 +166,16 @@ def test_agent_backed_path_counts_these_shapes_accurately(tmp_path): """Positive control (agent source present): the fixture above is a real semantic trap, not a tree with nothing to count. - - the index walk yields exactly the non-pruned SKILL.md files, so the - support/dependency/VCS/org-mirror files a naive local ``os.walk`` would - pick up are invisible to the count; + The fixture's expectations are derived from the paths the agent's index + walk actually yields, because that walk (and its exclusion list) belongs to + the agent package and differs between deployments. What this asserts: + + - the compatible total stays strictly below the walked file count — the + support/dependency/VCS/org-mirror files a naive local ``os.walk`` picks + up are either pruned by the walk or, when the walk keeps them, still not + enough to reach the walk's file count; - the platform-incompatible and duplicate-declared-name files do not add to - the compatible total, which therefore stays below the walk's file count; + the compatible total; - identity comes from frontmatter ``name``: the disabled ``my-skill`` (a declared name, not a directory) is compatible but not enabled; - dropping that config disable flips it to enabled without changing the @@ -191,19 +192,35 @@ def test_agent_backed_path_counts_these_shapes_accurately(tmp_path): } on_disk = {p.relative_to(skills).as_posix() for p in skills.rglob("SKILL.md")} - expected_walked = _expected_walked() - expected_compatible = _expected_names(COUNTED) + # The index walk itself belongs to the agent package and its exclusion list + # is version-dependent, so this test does not freeze it: every expectation + # below is derived from the paths the walk actually yielded. (Removing the + # local mirror of that walk is the point of #7305 — the fallback reported + # (0, 0) instead of guessing.) + assert _expected_walked() <= walked, "the walk must keep the non-pruned skills" + assert walked <= on_disk + + expected_compatible = { + name + for rel, (name, platform, _outcome) in SKILLS_TREE.items() + if rel in walked and platform is None + } expected_enabled = expected_compatible - DISABLED_NAMES - # The fixture must contain paths a naive scan would over-count... - assert on_disk > walked, "fixture must include SKILL.md files the index walk prunes" - # ...and the walk must still see exactly the paths it is documented to keep. - assert walked == expected_walked + # The fixture is a real semantic trap: the compatible total must stay below + # the walked file count. If the walk pruned the support/dependency/VCS/org + # files the count drops because those files are gone; if it did not, the + # count still drops because the platform-incompatible file is filtered out + # and the two directories declaring `my-skill` collapse to one logical + # skill. + assert len(expected_compatible) < len(walked), ( + "fixture must make the compatible total fall below the walked file count" + ) enabled, compatible = profiles._compute_profile_skills_stats(home) - assert compatible == len(expected_compatible) - assert compatible < len(walked) + assert compatible == len(expected_compatible), (compatible, expected_compatible) + assert compatible < len(on_disk) assert enabled == len(expected_enabled) (home / "config.yaml").write_text(CONFIG_WITHOUT_DISABLE, encoding="utf-8") From e4d9b799e4e13f99af7577614442a7343387e9b2 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 12 Sep 2026 14:12:52 -0700 Subject: [PATCH 021/446] Release: manual title regeneration for queued-user transcripts (#7545) (#7547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(api): scan to first complete exchange for title regeneration (#7543) _first_exchange_snippets() aborted at a second consecutive user message before collecting assistant text, so transcripts opening with queued user rows (channel-backed imports) hit the missing_exchange rejection and the deterministic fallback got persisted with HTTP 200 — invisible in the UI and unrecoverable by retrying. Keep scanning until the first complete user+assistant pair instead, and fall back to the last complete exchange when the opening user message yields no user text (privacy scrubbing) instead of failing with empty_user_message. Tests: tests/test_issue7543_title_first_exchange.py (8 cases, incl. the issue-pinned transcript shape and walker-mocked discriminator tests for both fixes). * test(api): drop local-machine notes and sys.path hack from 7543 test module - Docstring described the change as a local-only uncommitted hotfix with machine-specific paths — wrong for an upstream PR and unsafe to publish. - Drop the defensive REPO_ROOT sys.path insert; the repo conftest already puts the package root on sys.path (matches the neighboring test modules). * fix(api): drop unreachable latest-exchange fallback from title regeneration Greptile flagged the mocked-walker fallback test (P2) as fabricating internal state, and an exhaustive enumeration of realistic transcript shapes (5460 combinations, lengths 1-6) confirms it: both walkers share the same text extraction and the forward walker scans every message, so 'first walker empty while latest walker yields a pair' is unreachable from real transcripts. The fallback branch was dead code. Remove it together with its two walker-mocked tests; the fixed first- exchange walker alone resolves the reported issue shape. Replace the mocked tests with an observable E2E error-path test through the real walkers, and make the aux mock guard-faithful (empty assistant_text -> missing_exchange per the generate_title_raw_via_aux contract) so the fail-before proof actually exercises the endpoint contract. * fix(#7543): scope first-exchange scan-past to the manual-regen path only The full suite caught that broadening the SHARED _first_exchange_snippets also activated the automatic in-stream background-title path for consecutive-user-row transcripts, moving stream teardown from the synchronous stream_end branch to the background-title-thread branch (test_issue3929 emits_done regressed: stream_end was no longer in the synchronous queue). #7543 is specifically about the MANUAL 'Regenerate title' endpoint, so gate the scan-past behavior behind an opt-in scan_past_consecutive_users flag (default False = master behavior) and pass True only from generate_session_title_for_session. Auto in-stream path is now byte-identical to master; added a default-behavior regression guard test. * docs(changelog): stamp #7545 (manual title regen for queued-user transcripts) --------- Co-authored-by: Raylands Co-authored-by: nesquena-hermes Co-authored-by: n --- CHANGELOG.md | 2 + api/streaming.py | 28 +++- tests/test_issue7543_title_first_exchange.py | 165 +++++++++++++++++++ 3 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 tests/test_issue7543_title_first_exchange.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ecc5de..f3ba6229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Manual "Regenerate title" now works for conversations that open with several queued user messages.** For a session whose transcript begins with consecutive user turns (no assistant reply before the second user message), the first-exchange walker stopped at the second user row with no assistant text, so "Regenerate title" skipped the LLM call, silently persisted the local fallback (a truncated first message), and returned `200` with that same wrong title on every retry — leaving the session permanently stuck. Manual regeneration now scans past the opening user rows to the first complete user+assistant pair so it reaches the aux model. The automatic in-stream title path is deliberately unchanged. Thanks @Raylands. (#7545, closes #7543) + - **Custom providers that Hermes auto-discovered now show their full live `/v1/models` catalog again instead of just the saved models.** When a provider entry carries `models_discovered: true` alongside a `models:` mapping of per-model metadata (vision, context length), that mapping is discovery metadata — not a hand-curated allowlist — but the WebUI treated it as one and suppressed the live catalog, collapsing the model picker to only the already-saved IDs. Such a provider now defers to its live `/v1/models` catalog (matching the Hermes Agent's own `_models_config_is_allowlist` semantics), while an explicit `discover_models: false` opt-out (including the Agent-compatible string forms `false`/`no`/`0`) still pins the configured list, and a transient empty live probe falls back to the configured models rather than dropping the provider from the picker. Thanks @evgenyponomarev. (#7409, closes #7404) - **Ctrl-C and `ctl.sh`-launched daemons now shut down gracefully instead of leaving the server running.** The WebUI installed a SIGTERM handler that drains in-flight work through `serve_forever()`'s `finally`, but SIGINT fell through to Python's default, so a daemon started via `./ctl.sh start` could only be stopped with `kill ` and the Settings "Stop server" button did not stop it. SIGINT now shares the same idempotent graceful-shutdown handler as SIGTERM. Thanks @aniruddhaadak80. (#7315, closes #7078) diff --git a/api/streaming.py b/api/streaming.py index 4386fe75..f41ab71f 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -4011,11 +4011,21 @@ def _looks_like_current_user_turn(msg, msg_text) -> bool: return any(" ".join(str(candidate or '').split()) == needle for candidate in candidates) -def _first_exchange_snippets(messages): +def _first_exchange_snippets(messages, *, scan_past_consecutive_users: bool = False): """Return (first_user_text, first_assistant_text) snippets for title generation. Prefer the first substantive assistant answer in the opening exchange, skipping empty placeholders and assistant tool-call preambles. + + ``scan_past_consecutive_users`` (opt-in) keeps scanning past consecutive + opening user rows (queued first turns) until the first COMPLETE user+assistant + pair — needed by the manual "Regenerate title" path (#7543), which otherwise + aborted at the second user row with an empty assistant snippet and silently + persisted the local fallback. It defaults False so the automatic in-stream + background-title path keeps its exact prior behavior (a transcript with no + assistant text before the second user row yields an empty assistant snippet, + which _background_title_generation_inputs treats as "not yet eligible" — the + stream teardown then emits stream_end on its synchronous path unchanged). """ user_text = '' asst_text = '' @@ -4025,11 +4035,14 @@ def _first_exchange_snippets(messages): role = m.get('role') if role == 'user': candidate = _strip_thinking_markup(_title_exchange_input_text(m.get('content'))) - if not user_text and candidate: + if candidate and not user_text: user_text = candidate - continue - if user_text and candidate: + elif user_text and candidate and not scan_past_consecutive_users: + # Legacy/default behavior: a second populated user row before any + # assistant text ends the opening exchange (asst_text stays empty). break + # When scan_past_consecutive_users is set, keep going past consecutive + # user rows (#7543) until the first user+assistant pair. elif role == 'assistant' and user_text: candidate = _message_text(m.get('content')) # Skip tool-call preambles *only* when content is empty or looks @@ -5098,7 +5111,12 @@ def generate_session_title_for_session(session, *, prefer_latest: bool = False, if prefer_latest: user_text, assistant_text = _latest_exchange_snippets(messages) else: - user_text, assistant_text = _first_exchange_snippets(messages) + # Manual "Regenerate title" (#7543): scan past consecutive opening user + # rows to the first complete user+assistant pair, so a transcript that + # opens with queued user turns still reaches the aux LLM instead of + # silently persisting the local fallback. The automatic in-stream path + # keeps the default (no scan-past) so its stream teardown is unchanged. + user_text, assistant_text = _first_exchange_snippets(messages, scan_past_consecutive_users=True) if not user_text: return None, 'empty_user_message', '' from api import profiles as profiles_api diff --git a/tests/test_issue7543_title_first_exchange.py b/tests/test_issue7543_title_first_exchange.py new file mode 100644 index 00000000..e3d226ca --- /dev/null +++ b/tests/test_issue7543_title_first_exchange.py @@ -0,0 +1,165 @@ +"""Regression coverage for nesquena/hermes-webui#7543. + +Bug: manual "Regenerate title" failed with missing_exchange for sessions +whose transcript opens with consecutive user rows (no assistant text before +the second user turn) — _first_exchange_snippets() aborted at the second +user message, the aux call was skipped, and the deterministic local fallback +was persisted (200 + identical wrong title on every retry). +""" + +from unittest.mock import MagicMock + +import api.profiles as profiles_api +import api.streaming as streaming + + +class _ProfileEnv: + def __enter__(self): + return None + + def __exit__(self, exc_type, exc, tb): + return False + + +def _capturing_llm_result(captured, title="LLM Title", status="llm_aux"): + # Guard-faithful stand-in for the aux route: mirrors the documented + # generate_title_raw_via_aux contract — empty assistant_text is rejected + # with missing_exchange (the provider edge), never the selection logic. + def _fake(user_text, assistant_text, **kwargs): + captured["user_text"] = user_text + captured["assistant_text"] = assistant_text + if not user_text or not assistant_text: + return None, "missing_exchange", "" + return title, status, "" + + return _fake + + +def _run_generation(monkeypatch, messages, captured, prefer_latest=False): + monkeypatch.setattr(profiles_api, "profile_env_for_background_worker", lambda *a, **k: _ProfileEnv()) + monkeypatch.setattr(streaming, "_aux_title_generation_enabled", lambda: True) + monkeypatch.setattr(streaming, "_generate_llm_session_title_via_aux", _capturing_llm_result(captured)) + session = MagicMock() + session.messages = messages + session.session_id = "issue7543" + return streaming.generate_session_title_for_session(session, prefer_latest=prefer_latest) + + +# --- Fix A: _first_exchange_snippets scans past consecutive user rows (opt-in) --- + + +def test_first_exchange_snippets_scan_past_consecutive_user_rows(): + # Channel-backed import/projection shape: opening run of user rows, + # first assistant answer only much later (#7543 repro shape). + # scan_past_consecutive_users=True is the manual-regen behavior. + messages = [ + {"role": "user", "content": "Opening question about certificates"}, + {"role": "user", "content": "Follow-up that used to trigger the break"}, + {"role": "user", "content": "Another queued user turn"}, + {"role": "assistant", "content": "## Real first answer with substance"}, + ] + user_text, asst_text = streaming._first_exchange_snippets( + messages, scan_past_consecutive_users=True + ) + assert user_text == "Opening question about certificates" + assert asst_text == "## Real first answer with substance" + + +def test_first_exchange_snippets_default_stops_at_second_user_row(): + # The DEFAULT (automatic in-stream background-title path) must keep master's + # exact behavior: a second populated user row before any assistant text ends + # the opening exchange with an empty assistant snippet. This is load-bearing — + # _background_title_generation_inputs treats an empty assistant snippet as + # "not yet eligible", which keeps the stream teardown on its synchronous + # stream_end path (regression guard for test_issue3929 emits_done). + messages = [ + {"role": "user", "content": "Opening question about certificates"}, + {"role": "user", "content": "Follow-up that used to trigger the break"}, + {"role": "user", "content": "Another queued user turn"}, + {"role": "assistant", "content": "## Real first answer with substance"}, + ] + user_text, asst_text = streaming._first_exchange_snippets(messages) + assert user_text == "Opening question about certificates" + assert asst_text == "" + + +def test_first_exchange_snippets_normal_pair_unchanged(): + # Classic [user, assistant] opening must keep its exact behavior in BOTH modes. + messages = [ + {"role": "user", "content": "Please fix the stale sidebar title controls"}, + {"role": "assistant", "content": "I will add a regenerate-title action."}, + {"role": "user", "content": "Second question"}, + ] + for scan in (False, True): + user_text, asst_text = streaming._first_exchange_snippets( + messages, scan_past_consecutive_users=scan + ) + assert user_text == "Please fix the stale sidebar title controls" + assert asst_text == "I will add a regenerate-title action." + + +def test_first_exchange_snippets_without_any_assistant_text_still_empty(): + # No assistant text anywhere -> still unusable for the LLM path in BOTH modes; + # the missing_exchange rejection in generate_title_raw_via_aux stays intact. + messages = [ + {"role": "user", "content": "Question one"}, + {"role": "user", "content": "Question two"}, + ] + for scan in (False, True): + user_text, asst_text = streaming._first_exchange_snippets( + messages, scan_past_consecutive_users=scan + ) + assert user_text == "Question one" + assert asst_text == "" + + +def test_issue7543_real_transcript_shape_reaches_llm_path(monkeypatch): + captured = {} + messages = [ + {"role": "user", "content": "Wie kann ich auf einem windows server 2025 ein zertifikat erstellen"} , + {"role": "user", "content": "Wächst das Log so nicht in eine unendliche Schleife"}, + {"role": "assistant", "content": "## Zertifikat für Windows Admin Center mit AD-CS"}, + ] + title, status, _raw = _run_generation(monkeypatch, messages, captured) + assert status == "llm_aux" + assert title == "LLM Title" + assert captured["user_text"].startswith("Wie kann ich auf einem windows server 2025") + assert captured["assistant_text"].startswith("## Zertifikat") + + +def test_regenerate_helper_errors_with_real_walkers_when_no_user_text_exists(monkeypatch): + """Observable error path through the real walkers: no user text anywhere + (orphan assistant rows only) -> empty_user_message, aux never called.""" + captured = {} + messages = [{"role": "assistant", "content": "orphan answer without any user turn"}] + title, status, _raw = _run_generation(monkeypatch, messages, captured) + assert title is None + assert status == "empty_user_message" + assert "user_text" not in captured # aux path never reached + + +def test_regenerate_helper_prefer_latest_path_unchanged(monkeypatch): + captured = {} + messages = [ + {"role": "user", "content": "First question"}, + {"role": "assistant", "content": "First answer"}, + {"role": "user", "content": "Latest question"}, + {"role": "assistant", "content": "Latest answer"}, + ] + title, status, _raw = _run_generation(monkeypatch, messages, captured, prefer_latest=True) + assert status == "llm_aux" + assert captured["user_text"] == "Latest question" + assert captured["assistant_text"] == "Latest answer" + + +def test_regenerate_helper_prefer_latest_with_empty_last_user_message_errors(monkeypatch): + captured = {} + messages = [ + {"role": "user", "content": "First question"}, + {"role": "assistant", "content": "First answer"}, + {"role": "user", "content": " "}, + ] + title, status, _raw = _run_generation(monkeypatch, messages, captured, prefer_latest=True) + assert title is None + assert status == "empty_user_message" + assert "user_text" not in captured From e2ce040f1f026c53c46a696599dfa27f93563ef4 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 12 Sep 2026 20:07:49 -0700 Subject: [PATCH 022/446] Release: actionable ids for raw id-less pending approvals (#7510) (#7551) * fix(approvals): mint actionable ids for raw id-less local pending entries A raw id-less approval entry in _pending (agent-side _pending_result drops {command, pattern_key, pattern_keys, description} verbatim when no gateway notifier is registered for the session) is unactionable by contract: the frontend owner-capture requires an approval_id, so the approval card can neither be approved nor dismissed - the 1.5s poll re-serves it forever. reconcile_gateway_pending_mirror_locked already mints gwlocal: ids for id-less _gateway_queues producers; extend the same contract to raw non-mirror _pending entries with a stable gwlocal-mirrorless: id minted once onto the stored entry dict, so the id persists across polls and frontend dismiss markers stick. The exact-id respond path needs no further change: once the entry carries an id, _resolve_approval_legacy pops it (also clearing the stale stream-pointer 409 guard), stale explicit ids still fail closed (#527), and the yolo Skip-all release drains it. * docs(changelog): stamp #7510 (actionable ids for raw id-less pending approvals) --------- Co-authored-by: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com> Co-authored-by: n Co-authored-by: nesquena-hermes --- CHANGELOG.md | 2 + api/route_approvals.py | 17 ++ tests/test_issue_raw_pending_approval_id.py | 249 ++++++++++++++++++++ 3 files changed, 268 insertions(+) create mode 100644 tests/test_issue_raw_pending_approval_id.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ba6229..8ef31e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **A timed-out command-approval card can be dismissed again instead of getting stuck on screen forever.** When the agent raised a local approval with no gateway notifier registered, the raw pending entry was stored without an `approval_id`; the frontend needs that id to own the card, so a card that timed out (or otherwise went unanswered) could be neither approved nor dismissed and the poll re-served it indefinitely. Reconciliation now mints a stable `gwlocal-mirrorless:` id on the raw local entry itself — the same tokenization contract already used for gateway producers — so the id survives repeated polls and the client's dismiss marker sticks. Gateway/run-bearing entries are untouched, and the synthetic prefix can't be mistaken for gateway authority (routing keys on exact ids, `run_id`, and mirror tokens, never the prefix). Thanks @CharlesMcquade. (#7510) + - **Manual "Regenerate title" now works for conversations that open with several queued user messages.** For a session whose transcript begins with consecutive user turns (no assistant reply before the second user message), the first-exchange walker stopped at the second user row with no assistant text, so "Regenerate title" skipped the LLM call, silently persisted the local fallback (a truncated first message), and returned `200` with that same wrong title on every retry — leaving the session permanently stuck. Manual regeneration now scans past the opening user rows to the first complete user+assistant pair so it reaches the aux model. The automatic in-stream title path is deliberately unchanged. Thanks @Raylands. (#7545, closes #7543) - **Custom providers that Hermes auto-discovered now show their full live `/v1/models` catalog again instead of just the saved models.** When a provider entry carries `models_discovered: true` alongside a `models:` mapping of per-model metadata (vision, context length), that mapping is discovery metadata — not a hand-curated allowlist — but the WebUI treated it as one and suppressed the live catalog, collapsing the model picker to only the already-saved IDs. Such a provider now defers to its live `/v1/models` catalog (matching the Hermes Agent's own `_models_config_is_allowlist` semantics), while an explicit `discover_models: false` opt-out (including the Agent-compatible string forms `false`/`no`/`0`) still pins the configured list, and a transient empty live probe falls back to the configured models rather than dropping the provider from the picker. Thanks @evgenyponomarev. (#7409, closes #7404) diff --git a/api/route_approvals.py b/api/route_approvals.py index 84e43346..eb2761a0 100644 --- a/api/route_approvals.py +++ b/api/route_approvals.py @@ -265,6 +265,23 @@ def reconcile_gateway_pending_mirror_locked(session_key: str) -> tuple[dict | No live_local_tokens.add(live_entry_token) if not str(live_data.get("approval_id") or "").strip(): live_data["approval_id"] = f"gwlocal:{live_entry_token}" + + # A raw id-less LOCAL entry in `_pending` (agent-side _pending_result drops + # `{command, pattern_key, pattern_keys, description}` verbatim when no + # gateway notifier is registered) is unactionable by contract: the frontend + # owner-capture requires an approval_id, so its card can neither be + # approved nor dismissed — the poll re-serves it forever. Mint a stable id + # on the entry itself (the same contract the producer-tokenization loop + # above applies to _gateway_queues producers). Minting on the stored dict + # keeps the id stable across polls, so frontend dismiss markers persist. + for entry in queue_list: + if not isinstance(entry, dict) or _is_gateway_mirror_entry(entry): + continue + if str(entry.get("approval_id") or "").strip(): + continue + if str(entry.get("run_id") or "").strip(): + continue + entry["approval_id"] = f"gwlocal-mirrorless:{uuid.uuid4().hex}" live_token = ( _gateway_mirror_entry_token(live_head_entry) if live_head_entry and live_head_data diff --git a/tests/test_issue_raw_pending_approval_id.py b/tests/test_issue_raw_pending_approval_id.py new file mode 100644 index 00000000..77777d56 --- /dev/null +++ b/tests/test_issue_raw_pending_approval_id.py @@ -0,0 +1,249 @@ +"""Raw id-less local approval entries in ``_pending`` must be actionable. + +Regression for the Sep 2026 live incident. When a guarded tool needs approval +and no gateway notifier is registered for the session (``unregister_gateway_ +notify`` fires at turn end; a queued wakeup/continuation then hits a guard), +the agent-side fallback ``tools/approval.py::_pending_result`` writes a raw +dict — ``{command, description, pattern_key, pattern_keys}`` and NOTHING else, +no ``approval_id``, no ``request_id`` — directly into the shared +``tools.approval._pending[session_key]`` dict that the WebUI imports. + +Before the fix: +- ``GET /api/approval/pending`` served that raw dict verbatim (reconcile passes + non-mirror entries through untouched) with NO ``approval_id``, so the + frontend owner-capture (``_captureApprovalResponseOwner``) bailed and every + button on the approval card was a no-op; +- the frontend dismiss (X) only hid the card locally and the 1.5s poll + re-rendered it forever — the card could neither be approved nor dismissed; +- there is no waiter thread behind this shape (``_pending_result`` returns the + STOP message immediately), so draining the entry is purely UI hygiene — but + leaving it forever means the session shows a permanent phantom card. + +After the fix (mint at the reconcile chokepoint): +- id-less, run-less, non-mirror ``_pending`` entries get a stable minted + ``approval_id`` (persisted in the stored entry dict, so it is stable across + polls and dismissals persist); +- an exact-id response pops the entry and reports ``ok``; +- a stale/different explicit id still fails closed (#527 guard preserved); +- Skip-all (yolo release) drains the entry instead of dead-ending. + +The sibling shape — an orphaned ``_gateway_queues`` producer entry — is already +covered upstream (minting ``gwlocal:`` in the producer-tokenization loop, +commit 5826d76d); the tests in ``test_gateway_approval_legacy_path.py`` and this +file's history document it. This file covers the raw-``_pending`` shape, which +no existing test exercised. +""" + +from __future__ import annotations + +import json +import re +import uuid +from unittest.mock import patch + +import pytest + +from api import models +from api import route_approvals as ra +from api import routes + +try: + import tools.approval as ta + APPROVAL_AVAILABLE = True +except ImportError: + ta = None + APPROVAL_AVAILABLE = False + +pytestmark = pytest.mark.skipif( + not APPROVAL_AVAILABLE, + reason="tools.approval not available in this environment", +) + + +class _FakeHandler: + def __init__(self): + self.status = None + self._body = b"" + self.client_address = ("127.0.0.1", 0) + self.headers = {} + + class _W: + def __init__(self, outer): + self.outer = outer + + def write(self, b): + self.outer._body += b + + self.wfile = _W(self) + + def send_response(self, code): + self.status = code + + def send_header(self, k, v): + pass + + def end_headers(self): + pass + + def json(self): + return json.loads(self._body.decode("utf-8")) + + +def _register_session(sid: str, active_stream_id: str | None = None): + s = models.Session(session_id=sid, title="approval-raw-pending-orphan") + s.active_stream_id = active_stream_id + with models.LOCK: + models.SESSIONS[sid] = s + return s + + +def _seed_raw_pending(sid: str, command: str = "rm -rf /tmp/qa-probe-home", + key: str = "recursive delete") -> dict: + """Seed ``_pending[sid]`` with the exact ``_pending_result`` shape. + + Mirrors agent-side tools/approval.py::_pending_result when no gateway + notifier is registered: a raw dict with no approval_id, no request_id, + no run_id, written directly into the shared _pending dict. + """ + pending = { + "command": command, + "pattern_key": key, + "pattern_keys": [key], + "description": "recursive delete", + } + with ta._lock: + ta._pending[sid] = pending + ta._gateway_queues.pop(sid, None) + return pending + + +def _cleanup(sid: str): + with ta._lock: + ta._pending.pop(sid, None) + ta._gateway_queues.pop(sid, None) + with models.LOCK: + models.SESSIONS.pop(sid, None) + try: + ta.disable_session_yolo(sid) + except Exception: + pass + + +def _poll_pending(sid: str) -> dict: + h = _FakeHandler() + routes._handle_approval_pending(h, type("P", (), {"query": f"session_id={sid}"})()) + assert h.status == 200, f"pending poll failed: {h.status} {h.json()!r}" + return h.json() + + +def _respond(sid: str, body: dict): + h = _FakeHandler() + with patch("api.gateway_chat.webui_gateway_chat_enabled", return_value=True): + routes._handle_approval_respond(h, body) + return h + + +MINTED_ID_RE = re.compile(r"^gwlocal-mirrorless:[0-9a-f]{32}$") + + +def test_raw_pending_entry_surfaces_with_stable_actionable_id(): + """The id-less raw entry must be served with a minted id that is stable + across polls (dismiss persistence keys on sid+approval_id).""" + sid = f"raw-id-{uuid.uuid4().hex[:8]}" + entry = _seed_raw_pending(sid) + try: + data = _poll_pending(sid) + assert data["pending_count"] >= 1 + pending = data["pending"] + assert pending is not None + approval_id = pending.get("approval_id") + assert approval_id, ( + "pending payload served a raw id-less local entry with no " + "approval_id; the frontend cannot act on it (buttons no-op, " + "dismiss cannot stick)" + ) + assert MINTED_ID_RE.match(approval_id), approval_id + + # Stability: the second poll must return the SAME id — the mint must + # persist in the stored entry, not re-mint per poll. + data2 = _poll_pending(sid) + assert data2["pending"]["approval_id"] == approval_id + assert entry["approval_id"] == approval_id + finally: + _cleanup(sid) + + +def test_raw_pending_entry_respond_with_minted_id_pops_entry(): + """An exact-id response must pop the raw entry and report ok (no waiter + exists behind this shape — draining is the correct resolution).""" + sid = f"raw-resp-{uuid.uuid4().hex[:8]}" + entry = _seed_raw_pending(sid) + # The incident session had a LIVE run pointer; the stale stream-pointer + # guard must not 409 the click before the legacy resolver pops the entry. + _register_session(sid, active_stream_id="stream-raw-resp-live") + from api.gateway_chat import _STREAM_RUN_IDS + + _STREAM_RUN_IDS["stream-raw-resp-live"] = "run-live-1" + try: + approval_id = _poll_pending(sid)["pending"]["approval_id"] + assert approval_id + + h = _respond(sid, {"session_id": sid, "choice": "deny", + "approval_id": approval_id}) + body = h.json() + assert h.status == 200, f"respond failed: {h.status} {body!r}" + assert body.get("ok") is True, f"respond not accepted: {body!r}" + + # The entry must actually be gone — no more phantom card. + data = _poll_pending(sid) + assert data["pending"] is None + assert data["pending_count"] == 0 + finally: + _STREAM_RUN_IDS.pop("stream-raw-resp-live", None) + _cleanup(sid) + + +def test_raw_pending_entry_stale_id_fails_closed(): + """#527 guard preserved: a different explicit id must not pop the live + entry and must keep it surfaced with its stable id.""" + sid = f"raw-stale-{uuid.uuid4().hex[:8]}" + entry = _seed_raw_pending(sid) + _register_session(sid) + try: + approval_id = _poll_pending(sid)["pending"]["approval_id"] + assert approval_id + + h = _respond(sid, {"session_id": sid, "choice": "once", + "approval_id": "stale-different-id"}) + body = h.json() + assert body.get("ok") is not True, f"stale id must fail closed: {body!r}" + + # The live entry remains pending, unchanged, with its stable id. + data = _poll_pending(sid) + assert data["pending"] is not None + assert data["pending"]["approval_id"] == approval_id + assert entry["approval_id"] == approval_id + finally: + _cleanup(sid) + + +def test_raw_pending_entry_yolo_skip_all_drains(): + """Skip-all (yolo release) must drain the raw entry instead of leaving the + phantom card, and report the yolo state truthfully.""" + sid = f"raw-yolo-{uuid.uuid4().hex[:8]}" + entry = _seed_raw_pending(sid) + _register_session(sid) + try: + approval_id = _poll_pending(sid)["pending"]["approval_id"] + assert approval_id + + h = _respond(sid, {"session_id": sid, "choice": "once", + "approval_id": approval_id, "yolo": True}) + body = h.json() + assert h.status == 200, f"yolo respond failed: {h.status} {body!r}" + assert body.get("ok") is True, body + assert body.get("yolo_enabled") is True + assert _poll_pending(sid)["pending"] is None + finally: + # Restore: the yolo release enables session YOLO as a side effect. + _cleanup(sid) From e36f77389191fe9d81cd3a7416772e2f7b022e19 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 12 Sep 2026 20:16:48 -0700 Subject: [PATCH 023/446] test(#7510): clean up ruff F401/F841 in the raw-pending-approval test (#7552) Follow-up to #7551: the merged test file carried an unused `route_approvals as ra` import and two unused `entry =` assignments (advisory lint gate was red, though not a required check). Drop them; the two `entry` bindings that are asserted on are kept. Co-authored-by: nesquena-hermes --- tests/test_issue_raw_pending_approval_id.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_issue_raw_pending_approval_id.py b/tests/test_issue_raw_pending_approval_id.py index 77777d56..8a5b330c 100644 --- a/tests/test_issue_raw_pending_approval_id.py +++ b/tests/test_issue_raw_pending_approval_id.py @@ -44,7 +44,6 @@ from unittest.mock import patch import pytest from api import models -from api import route_approvals as ra from api import routes try: @@ -177,7 +176,7 @@ def test_raw_pending_entry_respond_with_minted_id_pops_entry(): """An exact-id response must pop the raw entry and report ok (no waiter exists behind this shape — draining is the correct resolution).""" sid = f"raw-resp-{uuid.uuid4().hex[:8]}" - entry = _seed_raw_pending(sid) + _seed_raw_pending(sid) # The incident session had a LIVE run pointer; the stale stream-pointer # guard must not 409 the click before the legacy resolver pops the entry. _register_session(sid, active_stream_id="stream-raw-resp-live") @@ -231,7 +230,7 @@ def test_raw_pending_entry_yolo_skip_all_drains(): """Skip-all (yolo release) must drain the raw entry instead of leaving the phantom card, and report the yolo state truthfully.""" sid = f"raw-yolo-{uuid.uuid4().hex[:8]}" - entry = _seed_raw_pending(sid) + _seed_raw_pending(sid) _register_session(sid) try: approval_id = _poll_pending(sid)["pending"]["approval_id"] From 94fd2da83008d24719eb3b52a691ce696d4f992c Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 13 Sep 2026 10:39:39 -0700 Subject: [PATCH 024/446] Release: semantic fingerprint for the Codex catalog dependency (#7556) (#7558) * fix(config): semantic fingerprint for the Codex catalog dependency (#7540) * fix(#7556): guard the recursive codex-cache strip against RecursionError Codex found a valid-but-deeply-nested models_cache.json crashes _codex_models_cache_fingerprint with RecursionError (the recursive strip ran outside the fallback try), 500ing /api/models. Move _strip_volatile_codex_cache_fields inside the existing encode try/except so a transform failure degrades to the stat-based fingerprint. +1 mutation-proven regression (forces the strip to raise; fails with RecursionError if the guard is reverted). * docs(changelog): stamp #7556 (semantic fingerprint for Codex catalog dependency) --------- Co-authored-by: webtecnica Co-authored-by: nesquena-hermes Co-authored-by: n --- CHANGELOG.md | 2 + api/config.py | 100 ++++- ...est_issue7540_codex_catalog_fingerprint.py | 386 ++++++++++++++++++ 3 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue7540_codex_catalog_fingerprint.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ef31e36..74fc11f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Opening a session no longer stalls just because Codex refreshed its model cache in the background.** The WebUI keys its 24-hour `/api/models` cache partly on Codex's `~/.codex/models_cache.json`, but that file was fingerprinted by `mtime` + size — and Codex rewrites it on its own refresh timer, bumping those stat fields even when the model catalog is byte-identical (only a volatile `fetched_at` timestamp changed). Every Codex refresh therefore invalidated the cache, and the next session open paid a full live rebuild whose serial provider probes stalled the open (#7540). The Codex cache is now fingerprinted by its *content* with the refresh-timestamp fields (`fetched_at`, `updated_at`) stripped, so a timestamp-only rewrite keeps the cache while any genuine catalog change (models, `etag`, `client_version`) still invalidates it. A malformed or pathologically deep cache file degrades safely to the old stat fingerprint rather than erroring. Thanks @webtecnica. (#7556, closes #7540) + - **A timed-out command-approval card can be dismissed again instead of getting stuck on screen forever.** When the agent raised a local approval with no gateway notifier registered, the raw pending entry was stored without an `approval_id`; the frontend needs that id to own the card, so a card that timed out (or otherwise went unanswered) could be neither approved nor dismissed and the poll re-served it indefinitely. Reconciliation now mints a stable `gwlocal-mirrorless:` id on the raw local entry itself — the same tokenization contract already used for gateway producers — so the id survives repeated polls and the client's dismiss marker sticks. Gateway/run-bearing entries are untouched, and the synthetic prefix can't be mistaken for gateway authority (routing keys on exact ids, `run_id`, and mirror tokens, never the prefix). Thanks @CharlesMcquade. (#7510) - **Manual "Regenerate title" now works for conversations that open with several queued user messages.** For a session whose transcript begins with consecutive user turns (no assistant reply before the second user message), the first-exchange walker stopped at the second user row with no assistant text, so "Regenerate title" skipped the LLM call, silently persisted the local fallback (a truncated first message), and returned `200` with that same wrong title on every retry — leaving the session permanently stuck. Manual regeneration now scans past the opening user rows to the first complete user+assistant pair so it reaches the aux model. The automatic in-stream title path is deliberately unchanged. Thanks @Raylands. (#7545, closes #7543) diff --git a/api/config.py b/api/config.py index 6578c8e1..686d2392 100644 --- a/api/config.py +++ b/api/config.py @@ -6182,6 +6182,97 @@ def _models_cache_file_fingerprint(path: Path) -> dict: return fingerprint +# Codex's ~/.codex/models_cache.json is rewritten on Codex's own refresh timer. +# Each rewrite bumps mtime_ns + size, but the payload usually only refreshes +# the volatile timestamp fields (`fetched_at`, plus `updated_at` when present) +# while the model catalog (client_version, etag, models[]) stays identical. +# Fingerprinting that file by stat (#2443's _models_cache_file_fingerprint) +# therefore invalidated the 24h /api/models cache on every Codex refresh, and +# the next session visit paid a full live rebuild whose serial provider probes +# starved the session-open path (#7540). +# +# This is a DENY-list, not an allow-list, on purpose — same safety direction as +# _AUTH_FINGERPRINT_VOLATILE_KEYS: every other field (client_version, etag, +# models, and any future model-affecting key) stays IN the fingerprint, so a +# genuine catalog change still invalidates the cache. +_CODEX_CACHE_FINGERPRINT_VOLATILE_KEYS = frozenset({ + # Whole-file refresh timestamp, rewritten by every Codex models refresh. + "fetched_at", + # Same-family save timestamp (mirrors the auth.json deny-list). + "updated_at", +}) + + +def _strip_volatile_codex_cache_fields(obj): + """Recursively drop refresh-timestamp-only keys from a Codex cache tree. + + Pure structural transform; never mutates the input. Any key NOT in the + deny-list is preserved verbatim so real catalog changes still show through + in the fingerprint. + """ + if isinstance(obj, dict): + return { + k: _strip_volatile_codex_cache_fields(v) + for k, v in obj.items() + if k not in _CODEX_CACHE_FINGERPRINT_VOLATILE_KEYS + } + if isinstance(obj, list): + return [_strip_volatile_codex_cache_fields(v) for v in obj] + return obj + + +def _codex_models_cache_fingerprint(path: Path) -> dict: + """Return a content fingerprint of Codex's models_cache.json. + + Unlike _models_cache_file_fingerprint() (mtime_ns + size), this hashes the + JSON content with the refresh-timestamp fields stripped, so a Codex + refresh that only bumps `fetched_at` does NOT invalidate the 24h + /api/models cache and therefore does not force the live rebuild that + stalled session opens (#7540). A change to anything that actually feeds the + Codex models we surface (client_version, etag, models[], an unknown future + field) still changes the hash and correctly busts the cache. + + Failure modes are deliberately conservative — a missing file is recorded, + and an unreadable/undecodable file falls back to the stat-based fingerprint + so behaviour is never *less* safe than the stat-only version. + """ + p = Path(path).expanduser() + fp: dict = {"path": str(p)} + try: + st = p.stat() + except OSError: + fp["missing"] = True + return fp + try: + raw = json.loads(p.read_text(encoding="utf-8")) + except Exception: + # Unreadable / corrupt / mid-write: keep the stat-based fingerprint. + # Strictly no less safe than the pre-fix behaviour (every write still + # invalidates) for this rare path only. + fp["mtime_ns"] = st.st_mtime_ns + fp["size"] = st.st_size + fp["semantic"] = "unparsed-fallback" + return fp + try: + # The recursive strip can raise (e.g. RecursionError on a pathologically + # deep JSON tree) — keep it inside the fallback try so any transform + # failure degrades to the stat fingerprint rather than 500ing /api/models. + stripped = _strip_volatile_codex_cache_fields(raw) + encoded = json.dumps( + stripped, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + default=str, + ).encode("utf-8") + fp["semantic_sha256"] = hashlib.sha256(encoded).hexdigest() + except Exception: + fp["mtime_ns"] = st.st_mtime_ns + fp["size"] = st.st_size + fp["semantic"] = "encode-fallback" + return fp + + def _models_cache_catalog_fingerprint() -> dict: """Return non-secret model-catalog identity metadata for cache invalidation. @@ -6191,6 +6282,13 @@ def _models_cache_catalog_fingerprint() -> dict: deterministic so a server restart after catalog changes does not keep serving an otherwise-valid persisted models_cache.json until the 24h TTL expires (#2443). + + The Codex axis uses a *content* fingerprint that excludes the refresh + timestamp fields (see _codex_models_cache_fingerprint): Codex rewrites + ~/.codex/models_cache.json on its own timer, bumping mtime_ns + size while + the model payload stays identical, so a stat-based fingerprint invalidated + the 24h cache on every Codex refresh and the next session visit paid a live + rebuild (#7540). """ catalog_payload = { "provider_models": _PROVIDER_MODELS, @@ -6211,7 +6309,7 @@ def _models_cache_catalog_fingerprint() -> dict: codex_home = Path(os.getenv("CODEX_HOME", "").strip() or (HOME / ".codex")).expanduser() return { "provider_catalog_sha256": provider_catalog_sha, - "codex_models_cache": _models_cache_file_fingerprint(codex_home / "models_cache.json"), + "codex_models_cache": _codex_models_cache_fingerprint(codex_home / "models_cache.json"), } diff --git a/tests/test_issue7540_codex_catalog_fingerprint.py b/tests/test_issue7540_codex_catalog_fingerprint.py new file mode 100644 index 00000000..6449948d --- /dev/null +++ b/tests/test_issue7540_codex_catalog_fingerprint.py @@ -0,0 +1,386 @@ +"""Regression tests for issue #7540 — Codex models_cache.json churn must not +bust a valid /api/models catalog cache and force a live rebuild on session open. + +Bug shape +--------- +``_models_cache_source_fingerprint()`` covers the small local catalogs the +catalog depends on, including Codex's ``~/.codex/models_cache.json``, and +fingerprinted that file by ``mtime_ns`` + ``size`` +(``_models_cache_file_fingerprint``, #2443). Codex rewrites that file on its +own refresh timer, and each rewrite bumps both stat fields even when the model +payload is byte-identical — only ``fetched_at`` / ``updated_at`` move. So: + + Codex refresh -> fingerprint differs + -> ``_get_fresh_memory_models_cache()`` rejects the warm 24h snapshot + -> the session visit (#4756) then finds ``_is_loadable_disk_cache()`` + rejecting the disk snapshot for the same reason + -> ``get_available_models(force_refresh=True)`` -> serial live provider + probes on the session-open path (the multi-second hang in the report). + +Fix (maintainer recommendation on #7540: semantic fingerprint) +-------------------------------------------------------------- +``_codex_models_cache_fingerprint()`` hashes the file's *content* with the +refresh-timestamp keys deny-listed, mirroring the auth.json fix +(``_auth_store_semantic_fingerprint``, RCA t_16551f61). Metadata churn is +invisible to the fingerprint, while anything that actually feeds the Codex +model list we merge still changes it. + +These tests are INVARIANTS, not change-detectors: + + * churn-immune — a ``fetched_at``-only rewrite keeps the + fingerprint identical, keeps a previously-valid + disk cache loadable, and keeps a session visit + served from memory (no live rebuild). + * real-change-invalidates — client_version / etag / models[] / an unknown + future field each flip the fingerprint AND reject + the previously-valid disk cache, so the fix cannot + over-stabilise into serving a stale catalog. +""" + +import copy +import json +import os +import time + +import pytest + +import api.config as config + + +_TS_1 = "2026-09-13T12:00:00+00:00" +_TS_2 = "2026-09-13T15:45:11+00:00" + + +class _LiveRebuildReached(BaseException): + """Sentinel raised if a session visit escalates to a live provider rebuild. + + Deliberately NOT an ``Exception``: the session-visit path catches broad + ``Exception`` around its ``force_refresh`` call and falls back to a stale + on-disk snapshot, which would mask the regression. + """ + + +def _codex_cache(fetched_at=_TS_1, *, client_version="0.51.0", + etag='"codex-etag-1"', models=None, extra_top=None): + """Build a realistic ~/.codex/models_cache.json payload.""" + if models is None: + models = [ + {"slug": "gpt-5.3-codex", "visibility": "list", "priority": 1}, + {"slug": "gpt-5.1-codex-mini", "visibility": "list", "priority": 2}, + ] + payload = { + "fetched_at": fetched_at, + "client_version": client_version, + "etag": etag, + "models": models, + } + if extra_top: + payload.update(extra_top) + return payload + + +def _write_codex(tmp_path, monkeypatch, payload) -> "os.PathLike": + """Write the Codex cache into an isolated CODEX_HOME and point config at it.""" + codex_home = tmp_path / "codex_home" + codex_home.mkdir(parents=True, exist_ok=True) + cache_path = codex_home / "models_cache.json" + cache_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + return cache_path + + +def _rewrite(path, payload): + """Rewrite the Codex cache and deterministically bump its mtime. + + ``os.utime`` is used instead of ``time.sleep`` so the mtime_ns change is + guaranteed on every filesystem/timestamp-resolution combination — the test + must prove the *old* stat fingerprint churned, not rely on clock luck. + """ + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + bumped = path.stat().st_mtime + 7.0 + os.utime(path, (bumped, bumped)) + return path + + +def _catalog_payload(): + return { + "active_provider": "codex", + "default_model": "gpt-5.3-codex", + "configured_model_badges": {"gpt-5.3-codex": "Codex"}, + "groups": [{"name": "Codex", "models": ["gpt-5.3-codex"]}], + } + + +# ── churn-immunity invariant ──────────────────────────────────────────────── + + +def test_codex_fetched_at_only_rewrite_keeps_fingerprint_identical(tmp_path, monkeypatch): + """Codex's own refresh moves ``fetched_at`` only — same models, same size.""" + p = _write_codex(tmp_path, monkeypatch, _codex_cache(fetched_at=_TS_1)) + fp_before = config._codex_models_cache_fingerprint(p) + st_before = p.stat() + + _rewrite(p, _codex_cache(fetched_at=_TS_2)) + fp_after = config._codex_models_cache_fingerprint(p) + st_after = p.stat() + + # Pre-condition: this really is the mtime/size churn from the report — the + # old stat-based fingerprint WOULD have changed. + assert (st_before.st_mtime_ns, st_before.st_size) != ( + st_after.st_mtime_ns, st_after.st_size), "test setup: rewrite must churn the stat fields" + # Invariant: the semantic fingerprint does not. + assert fp_before == fp_after, ( + "A Codex refresh that only moves fetched_at must NOT change the catalog " + "fingerprint (#7540) — otherwise the 24h cache is rejected and the next " + "session visit pays a live rebuild" + ) + assert "semantic_sha256" in fp_before + + +def test_unsorted_codex_payload_key_order_keeps_fingerprint_identical(tmp_path, monkeypatch): + """Key order is not semantic: a reordered but otherwise identical file must + not bust the cache.""" + p = _write_codex(tmp_path, monkeypatch, _codex_cache()) + fp_before = config._codex_models_cache_fingerprint(p) + + reordered = { + "models": _codex_cache()["models"], + "etag": _codex_cache()["etag"], + "client_version": _codex_cache()["client_version"], + "fetched_at": _codex_cache()["fetched_at"], + } + _rewrite(p, reordered) + + assert config._codex_models_cache_fingerprint(p) == fp_before + + +def test_codex_metadata_churn_does_not_reject_valid_disk_models_cache(tmp_path, monkeypatch): + """End-to-end: the disk snapshot a warm server wrote must still load after + Codex refreshed its own cache.""" + p = _write_codex(tmp_path, monkeypatch, _codex_cache(fetched_at=_TS_1)) + monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") + config._save_models_cache_to_disk(_catalog_payload()) + assert config._load_models_cache_from_disk() is not None + + _rewrite(p, _codex_cache(fetched_at=_TS_2)) + + assert config._load_models_cache_from_disk() is not None, ( + "Codex metadata churn must NOT reject a valid on-disk models cache " + "(#7540) — that rejection is what triggers the live rebuild" + ) + + +def test_session_visit_after_codex_refresh_needs_no_live_rebuild(tmp_path, monkeypatch): + """The user-visible symptom: open a conversation after Codex refreshed. + + The warm in-memory snapshot must still be served; a rebuild here is the + multi-second session-open hang in the report. + """ + _write_codex(tmp_path, monkeypatch, _codex_cache(fetched_at=_TS_1)) + monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") + payload = _catalog_payload() + config._save_models_cache_to_disk(payload) + + # Warm the memory cache the way a normal /api/models call leaves it. + monkeypatch.setattr(config, "_available_models_cache", copy.deepcopy(payload)) + monkeypatch.setattr(config, "_available_models_cache_ts", time.monotonic()) + monkeypatch.setattr( + config, + "_available_models_cache_source_fingerprint", + config._models_cache_source_fingerprint(), + ) + + # Codex's refresh timer fires between two session opens. + _rewrite(tmp_path / "codex_home" / "models_cache.json", + _codex_cache(fetched_at=_TS_2)) + + def _boom(**_kwargs): + # BaseException, not Exception: get_available_models_for_session_visit + # swallows exceptions from its force_refresh path and falls back to the + # on-disk stale snapshot, so an AssertionError sentinel would be eaten + # and the test would pass while quietly taking the slow path. + raise _LiveRebuildReached( + "session visit fell through to the live rebuild: Codex fetched_at " + "churn invalidated a still-valid catalog cache (#7540)" + ) + + monkeypatch.setattr(config, "get_available_models", _boom) + + result = config.get_available_models_for_session_visit() + + assert result is not None + assert result["default_model"] == "gpt-5.3-codex" + + +def test_warm_memory_cache_survives_codex_churn(tmp_path, monkeypatch): + """Isolates the invalidation point itself (the memory-cache rejection that + precedes the disk load and the force_refresh).""" + _write_codex(tmp_path, monkeypatch, _codex_cache(fetched_at=_TS_1)) + monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") + payload = _catalog_payload() + config._save_models_cache_to_disk(payload) + monkeypatch.setattr(config, "_available_models_cache", copy.deepcopy(payload)) + monkeypatch.setattr(config, "_available_models_cache_ts", time.monotonic()) + monkeypatch.setattr( + config, + "_available_models_cache_source_fingerprint", + config._models_cache_source_fingerprint(), + ) + + _rewrite(tmp_path / "codex_home" / "models_cache.json", + _codex_cache(fetched_at=_TS_2)) + + assert config._get_fresh_memory_models_cache(time.monotonic()) is not None, ( + "The warm in-memory catalog cache must survive a Codex fetched_at-only " + "refresh (#7540)" + ) + + +# ── real-change-invalidates invariant ─────────────────────────────────────── + + +def test_new_codex_model_changes_fingerprint(tmp_path, monkeypatch): + p = _write_codex(tmp_path, monkeypatch, _codex_cache()) + fp_before = config._codex_models_cache_fingerprint(p) + + models = _codex_cache()["models"] + [ + {"slug": "gpt-5.4-codex", "visibility": "list", "priority": 3}, + ] + _rewrite(p, _codex_cache(models=models)) + + assert config._codex_models_cache_fingerprint(p) != fp_before, ( + "A new Codex model changes the merged catalog and MUST bust the cache" + ) + + +def test_model_visibility_change_changes_fingerprint(tmp_path, monkeypatch): + """``visibility`` gates whether the slug is surfaced — a hide/show flip is a + real catalog change at unchanged file size.""" + p = _write_codex(tmp_path, monkeypatch, _codex_cache()) + fp_before = config._codex_models_cache_fingerprint(p) + + models = copy.deepcopy(_codex_cache()["models"]) + models[0]["visibility"] = "hidden" + _rewrite(p, _codex_cache(models=models)) + + assert config._codex_models_cache_fingerprint(p) != fp_before + + +@pytest.mark.parametrize( + "changed", + [ + {"client_version": "0.52.0"}, + {"etag": '"codex-etag-2"'}, + {"models": [{"slug": "gpt-5.3-codex", "priority": 9}]}, + ], +) +def test_codex_catalog_fields_stay_in_fingerprint(tmp_path, monkeypatch, changed): + """Everything that actually feeds the Codex model list stays covered.""" + p = _write_codex(tmp_path, monkeypatch, _codex_cache()) + fp_before = config._codex_models_cache_fingerprint(p) + + _rewrite(p, _codex_cache(**changed)) + + assert config._codex_models_cache_fingerprint(p) != fp_before + + +def test_unknown_codex_field_stays_in_fingerprint(tmp_path, monkeypatch): + """Deny-list (not allow-list) safety: an unknown field is NOT stripped, so a + hypothetical future model-gating field still busts the cache.""" + p = _write_codex(tmp_path, monkeypatch, + _codex_cache(extra_top={"some_future_model_gate": "v1"})) + fp_before = config._codex_models_cache_fingerprint(p) + + _rewrite(p, _codex_cache(extra_top={"some_future_model_gate": "v2"})) + + assert config._codex_models_cache_fingerprint(p) != fp_before, ( + "An unknown (non-deny-listed) field must remain in the fingerprint — the " + "deny-list must fail safe toward over-invalidation" + ) + + +def test_real_codex_change_still_rejects_previously_valid_disk_cache(tmp_path, monkeypatch): + """Anti-over-stabilisation mirror of the churn test: a disk snapshot that WAS + valid must be rejected once a genuine Codex model change lands.""" + p = _write_codex(tmp_path, monkeypatch, _codex_cache()) + monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") + config._save_models_cache_to_disk(_catalog_payload()) + assert config._load_models_cache_from_disk() is not None + + _rewrite(p, _codex_cache(client_version="0.52.0")) + + assert config._load_models_cache_from_disk() is None, ( + "A real Codex catalog change MUST reject the stale disk cache — the fix " + "must not over-stabilise into serving wrong data" + ) + + +# ── helper unit guards ────────────────────────────────────────────────────── + + +def test_strip_volatile_codex_fields_is_pure_and_recursive(): + src = { + "fetched_at": "ts", + "client_version": "0.51.0", + "models": [{"slug": "gpt-5.3-codex", "updated_at": "ts", "priority": 1}], + } + snapshot = copy.deepcopy(src) + out = config._strip_volatile_codex_cache_fields(src) + + assert src == snapshot # input untouched (pure) + assert out == { + "client_version": "0.51.0", + "models": [{"slug": "gpt-5.3-codex", "priority": 1}], + } + + +def test_unparseable_codex_cache_falls_back_to_stat_fingerprint(tmp_path, monkeypatch): + """Corrupt / mid-write file: fall back to the old stat fingerprint instead of + silently pinning a hash of garbage — behaviour is never less safe than the + stat-based version, and a later good file still invalidates.""" + p = _write_codex(tmp_path, monkeypatch, _codex_cache()) + p.write_text('{"models": [', encoding="utf-8") + + fp = config._codex_models_cache_fingerprint(p) + assert fp.get("semantic") == "unparsed-fallback" + assert "mtime_ns" in fp and "size" in fp + + _rewrite(p, _codex_cache(fetched_at=_TS_2)) + assert config._codex_models_cache_fingerprint(p) != fp + + +def test_missing_codex_cache_fingerprint_is_stable_and_marked(tmp_path, monkeypatch): + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "absent")) + fp = config._codex_models_cache_fingerprint(tmp_path / "absent" / "models_cache.json") + + assert fp.get("missing") is True + assert config._codex_models_cache_fingerprint( + tmp_path / "absent" / "models_cache.json") == fp + + +def test_deeply_nested_codex_cache_degrades_to_stat_fallback_without_crashing(tmp_path, monkeypatch): + """A strip failure (e.g. RecursionError on a deep tree) must not 500 /api/models. + + The recursive volatile-field strip runs inside the encode try/except, so if + it raises (RecursionError on a pathologically deep JSON tree, or any other + error) the fingerprint degrades to the stat-based fallback instead of the + exception escaping and turning /api/models into an HTTP 500. (#7540 gate + finding.) We force the strip to raise directly so the guard is exercised + deterministically regardless of the ambient recursion depth. + """ + p = _write_codex(tmp_path, monkeypatch, _codex_cache(fetched_at=_TS_1)) + + def _boom(_obj): + raise RecursionError("simulated deep-tree strip overflow") + + monkeypatch.setattr(config, "_strip_volatile_codex_cache_fields", _boom) + + # Must not raise; must degrade to the stat-based fingerprint. + fp = config._codex_models_cache_fingerprint(p) + fp2 = config._codex_models_cache_fingerprint(p) + + assert isinstance(fp, dict) + assert fp.get("semantic") == "encode-fallback" + assert "mtime_ns" in fp and "size" in fp + assert "semantic_sha256" not in fp # the content hash was NOT produced + assert fp2 == fp # stable across repeated calls on the unchanged file From 80610c7b6cfe2b2492c8d59bb68d2a79d4c8f505 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Sun, 13 Sep 2026 15:02:03 -0300 Subject: [PATCH 025/446] docs(architecture): document the /api/models cache invalidation contract The /api/models cache identity shipped in #7556 (release #7558) changed from a stat identity to content fingerprints with volatile-key deny-lists, and the #7556 review flagged that this runtime contract had no project-level documentation. Record the three source-fingerprint axes and their rationale, the one-directional deny-list invariant, the stat-identity fallbacks (including the deep-tree RecursionError path), the independent schema/version stamps, and the change protocol; index the document from docs/CONTRACTS.md. Docs only. --- docs/CONTRACTS.md | 7 ++ .../architecture/models-cache-invalidation.md | 95 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 docs/architecture/models-cache-invalidation.md diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index fcfc5d54..05ed701a 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -78,6 +78,13 @@ contributor guidance; it does not change runtime behavior or CI gates. proof gates. Prefer the RFC's **Authoritative emitted events** table (live `/api/chat/stream` wire names) over the aspirational semantic taxonomy when writing clients against current source. +- [`docs/architecture/models-cache-invalidation.md`](architecture/models-cache-invalidation.md): + current contract for the `/api/models` catalog cache identity: the + `config.yaml`, `auth.json`, and catalog source-fingerprint axes, the + one-directional volatile-key deny-lists (auth rotation and Codex's + `models_cache.json` refresh timestamps), the stat-identity fallbacks, and the + schema/version stamps. Start here before changing model-catalog caching, the + `/api/models` cache keys, or the Codex catalog dependency (#2443, #7540). When a change touches streaming, recovery, replay, compression, context reconstruction, cancellation, approval/clarify, session metadata, or run state, diff --git a/docs/architecture/models-cache-invalidation.md b/docs/architecture/models-cache-invalidation.md new file mode 100644 index 00000000..f50d14b0 --- /dev/null +++ b/docs/architecture/models-cache-invalidation.md @@ -0,0 +1,95 @@ +# Models catalog cache invalidation contract (`/api/models`) + +This document records the current identity and invalidation contract for the +`/api/models` catalog cache in `api/config.py`. It describes shipped behavior and +changes no runtime behavior. It was added after #7556 shipped in `exp-v0.52.303`, +which the #7556 review flagged as an undocumented runtime contract. + +## What is cached + +- **In memory:** `_available_models_cache` plus `_available_models_cache_ts`, + with `_AVAILABLE_MODELS_CACHE_TTL` set to 24 hours. +- **On disk:** one `models_cache.json` per profile under the WebUI state + directory (`_get_models_cache_path()`), stamped with `_schema_version` and + `_webui_version`. +- **Cold path:** `get_available_models(prefer_cache=...)`; the `prefer_cache` + branch never starts a live provider rebuild. +- **Hot path:** `_endpoint_advertised_model_ids()` reads only the published + in-memory snapshot through the lock-free `_models_cache_provenance` tuple and + validates it against the current source fingerprint before trusting it. + `_sync_models_cache_provenance()` must run at every site that publishes or + invalidates the snapshot, so the tuple can never tear. + +## The three source axes + +`_models_cache_source_fingerprint()` is the single chokepoint. A cache is served +only when every axis matches the value recorded in the cache — the disk reader +compares it in `_is_loadable_disk_cache()`, and the hot path compares it against +the fingerprint captured at publish time. + +| Axis | Identity | Why it is fingerprinted this way | +| --- | --- | --- | +| `config_yaml` | stat identity: `mtime_ns` + size (`_models_cache_file_fingerprint`) | The file is rewritten only on deliberate user edits, and any edit can change the provider/model set, so the cheap conservative identity wins. | +| `auth_json` | content hash with a volatile-key deny-list (`_auth_store_semantic_fingerprint`, `_AUTH_FINGERPRINT_VOLATILE_KEYS`) | The credential store is rewritten roughly every 14 minutes by credential-pool / OAuth refresh; none of those rotating fields feed `detected_providers` or the returned catalog, and stat identity made the 24h cache churn on every refresh (RCA `t_d127953d` / `t_16551f61`). | +| `catalog` | baked-in provider catalog sha256 (`_PROVIDER_MODELS` + `_PROVIDER_DISPLAY`) plus the Codex local catalog (`_codex_models_cache_fingerprint`, `_CODEX_CACHE_FINGERPRINT_VOLATILE_KEYS`) | A restart after a catalog change must not keep serving a persisted payload for up to 24h (#2443). Codex rewrites `~/.codex/models_cache.json` on its own timer, bumping `mtime_ns` and size while models, `etag`, and `client_version` stay identical, so the Codex axis hashes **content** with only the refresh timestamps (`fetched_at`, `updated_at`) removed (#7540, #7556). | + +## Invariant: deny-lists are one-directional + +- Both volatile-key sets are deny-lists, never allow-lists. They may remove only + fields that provably do not gate the provider/model set. +- Every other field — including fields that Codex or the auth store may add in + the future — stays in the fingerprint + (`test_unknown_codex_field_stays_in_fingerprint`). +- Consequence: excluding a volatile key can only make the fingerprint **more + stable**; it can never hide a genuine catalog or provider change. When in + doubt, keep the key in. + +## Invariant: fallbacks are never less safe than stat + +- Missing file → recorded as missing, and the fingerprint stays stable + (`test_missing_codex_cache_fingerprint_is_stable_and_marked`). +- Unreadable, corrupt, or mid-write JSON → stat identity, marked + `unparsed-fallback`. +- Transform failure — including `RecursionError` on a pathologically deep tree → + stat identity, marked `encode-fallback` + (`test_deeply_nested_codex_cache_degrades_to_stat_fallback_without_crashing`). + The fingerprint must never raise into `/api/models`; a real rewrite still + changes the stat identity, so the fallback is strictly no less safe than the + pre-#7556 behavior. + +## Version stamps (independent of the fingerprint) + +- `_schema_version` must equal `_MODELS_CACHE_SCHEMA_VERSION` (currently `3`). + Bump it when the cached payload shape changes incompatibly. +- `_webui_version` must equal the running version, which forces a rebuild after + every release so picker-shape fixes appear immediately instead of after the + TTL expires. When the runtime version cannot be resolved (early boot), that + check is skipped rather than wedging the boot. + +## Change protocol + +1. Add or change a source axis in `_models_cache_source_fingerprint()` only — + one chokepoint, so the disk reader and the published-snapshot reader always + agree. +2. When adding a volatile key, name the value it derives from and prove it + inert: one test that fails if the key is dropped from the deny-list, and one + that shows a genuine catalog change still invalidates the cache. +3. Keep the fingerprint cheap, deterministic, and safe to run synchronously — + it is recomputed on cache reads, including a per-turn hot path. +4. Do not add a second, parallel cache-identity mechanism. The stat-based + `_models_cache_file_fingerprint()` stays only as the conservative fallback for + the axes above. +5. This is a runtime contract: changes here update this document and are + described in the PR body. Release-note wording belongs in the PR body, not in + `CHANGELOG.md`, which release commits own. + +## Tests + +`tests/test_issue7540_codex_catalog_fingerprint.py` covers both invariant groups: +timestamp-only churn keeps the fingerprint identical (and a session visit after a +Codex refresh needs no live rebuild), while genuine changes — a new model, a +visibility change, any catalog field, any unknown field — still invalidate. + +## References + +Issues/PRs: #2443, #7540, #7556, #7558. RCAs: `t_d127953d`, `t_16551f61`. 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 026/446] 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 027/446] 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 028/446] 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 029/446] 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 030/446] 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 031/446] 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 032/446] 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 033/446] 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 034/446] 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 035/446] 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 036/446] 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 037/446] 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 038/446] 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 ef31e3ac430337ea451baf4c000f1fe5dea8f977 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 15 Sep 2026 05:31:29 +0300 Subject: [PATCH 039/446] fix(sessions): stop cross-client clobber of unread state Two WebUI clients on the same origin/profile share one localStorage but each cache the unread stores in module state and re-serialise that cache wholesale. With no storage listener for either key, a stale client overwrites the other's writes: a session's viewed count can roll backwards (re-lighting a cleared unread dot via message_count > viewed_count) and a cleared completion-unread marker can be resurrected. Fix the class: - max-merge viewed counts on save so a count is monotonic and additive across clients, never dropping another client's entries or lowering a value; - keep the in-cache viewed count monotonic on set; - delete a viewed count directly from the persisted map (removals must still leave disk, unlike the additive set path); - invalidate both unread caches on storage events so a concurrent write is re-read instead of overwritten. Adds tests/test_unread_cross_client_sync.py (node-driven, observable behavior): the viewed-count rollback fails before the fix and passes after; the clear path keeps pruning; the storage-event handler prevents marker resurrection. --- static/sessions.js | 52 +++++++- tests/test_unread_cross_client_sync.py | 176 +++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 tests/test_unread_cross_client_sync.py diff --git a/static/sessions.js b/static/sessions.js index 76c1fdc9..b0289aba 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -478,7 +478,27 @@ function _knownSessionProfileCount(profile) { function _saveSessionViewedCounts() { try { - localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(_getSessionViewedCounts())); + // Merge against the persisted map instead of blindly overwriting it. Another + // WebUI client on the same origin/profile may have written newer counts since + // this client's cache was last loaded; serialising only our (possibly stale) + // in-memory copy would drop those entries and could roll a session's viewed + // count backwards, resurrecting a cleared unread dot. Max-merging keeps the + // map monotonic and additive across clients. + const cache = _getSessionViewedCounts(); + let merged = {}; + try { + const parsed = JSON.parse(localStorage.getItem(SESSION_VIEWED_COUNTS_KEY) || '{}'); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) merged = parsed; + } catch (_){ + merged = {}; + } + for (const sid of Object.keys(cache)) { + const prev = Number(merged[sid]) || 0; + const next = Number(cache[sid]) || 0; + merged[sid] = Math.max(prev, next); + } + localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(merged)); + _sessionViewedCounts = merged; } catch (_){ // Ignore localStorage write failures. } @@ -488,7 +508,10 @@ function _setSessionViewedCount(sid, messageCount = 0) { if (!sid) return; const counts = _getSessionViewedCounts(); const next = Number.isFinite(messageCount) ? Number(messageCount) : 0; - counts[sid] = next; + // A viewed count records "seen at least up to N messages" — keep it monotonic. + // A transient lower count (e.g. a list snapshot mid-compaction) must not + // resurrect a cleared unread dot before the save below. + counts[sid] = Math.max(Number(counts[sid]) || 0, next); _saveSessionViewedCounts(); // If the viewed count is now current, any prior completion-unread marker is // stale — clear it so _hasUnreadForSession doesn't short-circuit (#3020). @@ -689,6 +712,20 @@ function _clearSessionViewedCount(sid) { const counts = _getSessionViewedCounts(); if (!Object.prototype.hasOwnProperty.call(counts, sid)) return; delete counts[sid]; + // A removal must actually leave disk, unlike the additive max-merge used on + // the set path (which never deletes). Delete just this key from the persisted + // map so entries a concurrent client added are preserved. + try { + const parsed = JSON.parse(localStorage.getItem(SESSION_VIEWED_COUNTS_KEY) || '{}'); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + delete parsed[sid]; + localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(parsed)); + _sessionViewedCounts = parsed; + return; + } + } catch (_){ + // Fall through to the cache-only write on read/parse failure. + } _saveSessionViewedCounts(); } @@ -9055,10 +9092,21 @@ async function _handleShowAllProfilesStorageEvent(e){ if(typeof renderSessionList==='function') await renderSessionList({deferWhileInteracting:false}); } +function _handleUnreadStorageEvent(e){ + if(!e || !e.key) return; + // A concurrent WebUI client on the same origin/profile changed one of the + // unread stores. Drop our cached copy so the next read re-syncs from + // localStorage instead of our stale in-memory map clobbering their write + // (which would resurrect a cleared completion-unread marker). + if(e.key === SESSION_VIEWED_COUNTS_KEY) _sessionViewedCounts = null; + else if(e.key === SESSION_COMPLETION_UNREAD_KEY) _sessionCompletionUnread = null; +} + if(typeof window!=='undefined'){ window.addEventListener('storage', (e) => { void _handleActiveSessionStorageEvent(e); void _handleShowAllProfilesStorageEvent(e); + void _handleUnreadStorageEvent(e); }); window.addEventListener('popstate', () => { const sid=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null; diff --git a/tests/test_unread_cross_client_sync.py b/tests/test_unread_cross_client_sync.py new file mode 100644 index 00000000..a81cfdf5 --- /dev/null +++ b/tests/test_unread_cross_client_sync.py @@ -0,0 +1,176 @@ +"""Regression: unread state must not be clobbered by a second WebUI client. + +Two WebUI windows/tabs on the same origin and browser profile share one +``localStorage``. Each client caches the unread stores +(``hermes-session-viewed-counts`` and ``hermes-session-completion-unread``) in +module state and serialises that cache wholesale on save. With no ``storage`` +listener for either key, a stale client overwrites the other client's writes: + +- a session's *viewed* count can be rolled backwards, so ``message_count > + viewed_count`` re-lights a dot the user just cleared; and +- a completion-unread marker cleared in one client is resurrected by the other. + +This fixes the class by (a) max-merging viewed counts on save so a count can +never go down or drop another client's entries, and (b) invalidating both +caches on ``storage`` events so a concurrent write is re-read instead of +overwritten. +""" +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _extract(name: str) -> str: + """Extract a top-level `function name(...) { ... }` definition by brace match.""" + marker = f"function {name}(" + start = SESSIONS_JS.index(marker) + brace = SESSIONS_JS.index("{", start) + depth = 0 + for i in range(brace, len(SESSIONS_JS)): + ch = SESSIONS_JS[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return SESSIONS_JS[start:i + 1] + raise AssertionError(f"could not brace-match {name}") + + +def _run_node(script: str) -> dict: + result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(result.stdout) + + +_VIEWED_FNS = [ + "_getSessionViewedCounts", + "_saveSessionViewedCounts", + "_setSessionViewedCount", + "_clearSessionViewedCount", +] +_UNREAD_FNS = [ + "_getSessionCompletionUnread", + "_saveSessionCompletionUnread", + "_markSessionCompletionUnread", + "_clearSessionCompletionUnread", + "_hasSessionCompletionUnread", +] + +_HEADER = """ +const _store = {}; +const localStorage = { + getItem: (k) => (k in _store ? _store[k] : null), + setItem: (k, v) => { _store[k] = String(v); }, +}; +const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts'; +const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread'; +""" + + +def _make_client_block(): + """A factory that defines one client's module state and returns its API.""" + fns = "\n".join(_extract(n) for n in _VIEWED_FNS + _UNREAD_FNS) + handler = "" + if "function _handleUnreadStorageEvent(" in SESSIONS_JS: + handler = _extract("_handleUnreadStorageEvent") + return f""" +function makeClient() {{ + let _sessionViewedCounts = null; + let _sessionCompletionUnread = null; + {fns} + {handler} + return {{ + setViewed: _setSessionViewedCount, + clearViewed: _clearSessionViewedCount, + getViewed: (sid) => {{ + const c = _getSessionViewedCounts(); + return c[sid] === undefined ? null : c[sid]; + }}, + markUnread: _markSessionCompletionUnread, + clearUnread: _clearSessionCompletionUnread, + hasUnread: _hasSessionCompletionUnread, + handleStorage: (typeof _handleUnreadStorageEvent === 'function') ? _handleUnreadStorageEvent : null, + }}; +}} +""" + + +def test_viewed_count_is_monotonic_across_clients(): + """A stale client must not roll a session's viewed count backwards. + + Fails before the fix: client B serialises its stale cache and overwrites + client A's newer value, so the cleared dot re-lights via the count rule. + """ + script = _HEADER + _make_client_block() + """ +const A = makeClient(); +const B = makeClient(); +A.setViewed('X', 417); +B.setViewed('X', 131); // stale client believes the session has 131 msgs +console.log(JSON.stringify({ + disk: _store['hermes-session-viewed-counts'] ? JSON.parse(_store['hermes-session-viewed-counts']).X : null, + bCache: B.getViewed('X'), +})); +""" + out = _run_node(script) + assert out["disk"] == 417, ( + "a stale client must not lower a session's viewed count (disk was " + f"{out['disk']}, expected 417)" + ) + assert out["bCache"] == 417, ( + "the stale client's cache must be re-synced to the merged count" + ) + + +def test_clear_viewed_count_still_removes_from_disk(): + """Guard: session deletion must still prune the viewed-count entry.""" + script = _HEADER + _make_client_block() + """ +const A = makeClient(); +A.setViewed('D', 5); +A.clearViewed('D'); +const parsed = JSON.parse(_store['hermes-session-viewed-counts'] || '{}'); +console.log(JSON.stringify({ diskStillHas: Object.prototype.hasOwnProperty.call(parsed, 'D') })); +""" + out = _run_node(script) + assert out["diskStillHas"] is False, "clearing a viewed count must remove it from disk" + + +def test_storage_event_invalidates_unread_caches(): + """A storage event for either unread key must drop the in-memory cache so a + concurrent client's write is re-read, not clobbered. + + Skipped before the fix (the handler does not exist yet); passes after. + """ + if "function _handleUnreadStorageEvent(" not in SESSIONS_JS: + import pytest + pytest.skip("_handleUnreadStorageEvent not present yet") + + script = _HEADER + _make_client_block() + """ +const A = makeClient(); +const B = makeClient(); +A.markUnread('Y', 3); // A records a completion +B.hasUnread('Y'); // B loads the marker into its cache +A.clearUnread('Y'); // A clears it (e.g. the user visited Y in A) +// B sees A's storage event and must forget its cached copy. +B.handleStorage({ key: 'hermes-session-completion-unread' }); +B.markUnread('Z', 7); // B writes for an unrelated session +const disk = JSON.parse(_store['hermes-session-completion-unread'] || '{}'); +console.log(JSON.stringify({ + yResurrected: Object.prototype.hasOwnProperty.call(disk, 'Y'), + zPresent: Object.prototype.hasOwnProperty.call(disk, 'Z'), +})); +""" + out = _run_node(script) + assert out["zPresent"] is True, "B's new marker must be persisted" + assert out["yResurrected"] is False, ( + "a marker cleared in one client must not be resurrected by another" + ) + + +def test_unread_storage_handler_is_wired(): + """The window storage listener must route to the unread-cache handler.""" + assert "void _handleUnreadStorageEvent(e);" in SESSIONS_JS, ( + "the storage listener must invoke _handleUnreadStorageEvent" + ) From 9ffdaba4d96227b359c4c59226824e637579b105 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 15 Sep 2026 13:11:36 +0300 Subject: [PATCH 040/446] fix(sessions): converge the unread stores across WebUI clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two WebUI clients on the same origin/profile share one localStorage, and each caches the unread stores in module state. Max-merging viewed counts on save and dropping the caches on a storage event was not enough: a save read the store, merged its cache and wrote the result back, so a write landing inside another client's read-modify-write window was still lost — and the surviving client only forgot its own cache, so it re-read the value that had already been lost. A viewed count could roll backwards and re-light a cleared dot. The completion markers behind the visible dot were worse: that save was a wholesale overwrite with no merge at all, so a stale client re-serialising its cache re-added a marker another client had cleared. Make both stores converge on the later fact rather than on the last writer: - viewed counts mean "seen at least up to N messages", so they merge with a max: monotonic per session and additive across clients. A value that exists only in our cache is written back only for a session the session list still shows (_allSessions / _sessionListSnapshotById), so a session deleted in another client, already pruned from the store by _clearSessionViewedCount, is not resurrected. - completion markers are add/remove and cannot be ordered by a max, so every clear records a tombstone (hermes-session-completion-unread-cleared, sid -> clearedAt) and a marker that does not postdate its clear loses. A genuine later completion still wins. The tombstone store never enters the marker map that consumers read, and is pruned by session existence and by a 7-day cap. Both clear paths record it: _clearSessionCompletionUnread (visit / delete) and _clearCronSessionCompletionUnreadForInactiveProfiles (profile switch). - both saves write only when the store does not already hold the merged result, and the storage listener runs that same merge instead of dropping our cache. A client that still holds an acknowledgement therefore re-asserts it after the other client's write, the loser sees a value it cannot beat and stops, and a repair converges instead of ping-ponging storage events. tests/test_unread_cross_client_sync.py drives the real functions against a two-client harness sharing one fake localStorage: an interleaved write inside a read-modify-write window, a deleted session's count, a stale client's marker write after a clear, tombstone ordering vs a later re-mark, tombstone pruning, and the registered storage listener dispatched with synthetic events. tests/_unread_store_helpers.py gives the neighbouring node harnesses the helpers those persistence paths call, so they keep driving the real code. --- static/sessions.js | 226 +++++++- tests/_unread_store_helpers.py | 77 +++ .../test_issue5273_active_done_viewed_sync.py | 8 + ...est_issue5960_cron_unread_profile_scope.py | 20 + tests/test_session_unread_dot_on_visit.py | 9 + tests/test_unread_cross_client_sync.py | 503 +++++++++++++++--- 6 files changed, 729 insertions(+), 114 deletions(-) create mode 100644 tests/_unread_store_helpers.py diff --git a/static/sessions.js b/static/sessions.js index b0289aba..4915ebfb 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -347,6 +347,11 @@ function _clearComposerDraft(sid, text, files) { const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts'; const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread'; +// Cleared completion-unread markers leave a tombstone (sid -> clearedAt epoch ms) +// so a stale client that still holds the marker cannot resurrect it. Ordering +// facts live here, never in the marker map consumers read. +const SESSION_COMPLETION_UNREAD_CLEARED_KEY = 'hermes-session-completion-unread-cleared'; +const SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS = 7 * 24 * 60 * 60 * 1000; const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming'; // Per-profile session-count cache (issue #4717 / #4662 Phase 1.5). Records how // many sessions each profile rendered last time, keyed by profile name, so a @@ -476,29 +481,95 @@ function _knownSessionProfileCount(profile) { return (typeof v === 'number' && Number.isFinite(v)) ? v : null; } +// Read a persisted sid -> value map. Missing or unreadable values read as an +// empty map, matching the getters above. +function _readStoredJsonMap(key) { + try { + const parsed = JSON.parse(localStorage.getItem(key) || '{}'); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch (_){ + return {}; + } +} + +// Does this session still exist as far as this client can tell? The sidebar list +// is the authority (deletion drops the row) and the visit snapshot covers the +// window before the first list render. +function _sessionExistsForUnreadState(sid) { + if (!sid) return false; + if (typeof _allSessions !== 'undefined' && Array.isArray(_allSessions) + && _allSessions.some((s) => s && s.session_id === sid)) { + return true; + } + return typeof _sessionListSnapshotById !== 'undefined' && _sessionListSnapshotById + && typeof _sessionListSnapshotById.has === 'function' + && _sessionListSnapshotById.has(sid); +} + +// Is there a session list to judge existence against at all? Without one, +// "unknown" must not be read as "deleted": a client that has not rendered a list +// yet would otherwise refuse to persist a fresh acknowledgement, and would prune +// every tombstone it holds. +function _sessionListLoaded() { + if (typeof _allSessions !== 'undefined' && Array.isArray(_allSessions) + && _allSessions.length > 0) { + return true; + } + return typeof _sessionListSnapshotById !== 'undefined' && _sessionListSnapshotById + && typeof _sessionListSnapshotById.size === 'number' + && _sessionListSnapshotById.size > 0; +} + +// A viewed count is "seen at least up to N messages": monotonic per session and +// additive across clients, so merging is a max that never rolls a count back and +// never drops another client's entry. A value that exists only in our cache is +// written back only for a session the list still shows (see +// _sessionExistsForUnreadState) so a session deleted in another client, and +// already pruned from disk by _clearSessionViewedCount, is not resurrected. It +// is always kept in the live view regardless: dropping it here would lose the +// acknowledgement for a session that still exists. +function _mergeSessionViewedCounts(disk, cache) { + const counts = {}; + for (const sid of Object.keys(disk || {})) counts[sid] = Number(disk[sid]) || 0; + let changed = false; + for (const sid of Object.keys(cache || {})) { + const ours = Number(cache[sid]) || 0; + if (Object.prototype.hasOwnProperty.call(counts, sid)) { + if (ours > counts[sid]) { + counts[sid] = ours; + changed = true; + } + continue; + } + if (!ours) continue; + if (_sessionListLoaded() && !_sessionExistsForUnreadState(sid)) continue; + counts[sid] = ours; + changed = true; + } + const live = {}; + for (const sid of Object.keys(counts)) live[sid] = counts[sid]; + for (const sid of Object.keys(cache || {})) { + const ours = Number(cache[sid]) || 0; + if (ours > (Object.prototype.hasOwnProperty.call(live, sid) ? live[sid] : 0)) { + live[sid] = ours; + } + } + return {counts, live, changed}; +} + function _saveSessionViewedCounts() { try { - // Merge against the persisted map instead of blindly overwriting it. Another - // WebUI client on the same origin/profile may have written newer counts since - // this client's cache was last loaded; serialising only our (possibly stale) - // in-memory copy would drop those entries and could roll a session's viewed - // count backwards, resurrecting a cleared unread dot. Max-merging keeps the - // map monotonic and additive across clients. - const cache = _getSessionViewedCounts(); - let merged = {}; - try { - const parsed = JSON.parse(localStorage.getItem(SESSION_VIEWED_COUNTS_KEY) || '{}'); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) merged = parsed; - } catch (_){ - merged = {}; + const merged = _mergeSessionViewedCounts( + _readStoredJsonMap(SESSION_VIEWED_COUNTS_KEY), + _getSessionViewedCounts() + ); + // Write only when we hold something the store does not (a strictly greater + // count, or an entry it is missing). Rewriting an unchanged map would let two + // clients ping-pong storage events forever. + if (merged.changed) { + localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(merged.counts)); } - for (const sid of Object.keys(cache)) { - const prev = Number(merged[sid]) || 0; - const next = Number(cache[sid]) || 0; - merged[sid] = Math.max(prev, next); - } - localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(merged)); - _sessionViewedCounts = merged; + _sessionViewedCounts = merged.live; } catch (_){ // Ignore localStorage write failures. } @@ -529,9 +600,60 @@ function _getSessionCompletionUnread() { return _sessionCompletionUnread; } +// Completion-unread markers are add/remove, so unlike viewed counts they cannot +// be max-merged: the later event has to win. Every clear records a tombstone +// (sid -> clearedAt) in SESSION_COMPLETION_UNREAD_CLEARED_KEY, and a marker only +// survives when it does not predate its sid's clear. +function _markerLosesToUnreadClear(marker, clearedAt) { + const cleared = Number(clearedAt) || 0; + if (!cleared) return false; + return (Number(marker && marker.completed_at) || 0) <= cleared; +} + +// Merge the persisted marker map with our cache under the tombstones. Markers +// the store holds that a clear already outranks are dropped from the result, so +// the caller can write the repaired map back and no reader sees them again. +function _mergeSessionCompletionUnread(disk, cache, cleared) { + const markers = {}; + let changed = false; + for (const sid of Object.keys(disk || {})) { + const stored = disk[sid]; + if (_markerLosesToUnreadClear(stored, (cleared || {})[sid])) { + changed = true; + continue; + } + markers[sid] = stored; + } + for (const sid of Object.keys(cache || {})) { + const ours = cache[sid]; + if (_markerLosesToUnreadClear(ours, (cleared || {})[sid])) continue; + if (Object.prototype.hasOwnProperty.call(markers, sid)) { + const theirs = Number((markers[sid] || {}).completed_at) || 0; + if ((Number(ours && ours.completed_at) || 0) > theirs) { + markers[sid] = ours; + changed = true; + } + continue; + } + markers[sid] = ours; + changed = true; + } + return {markers, changed}; +} + function _saveSessionCompletionUnread() { try { - localStorage.setItem(SESSION_COMPLETION_UNREAD_KEY, JSON.stringify(_getSessionCompletionUnread())); + const merged = _mergeSessionCompletionUnread( + _readStoredJsonMap(SESSION_COMPLETION_UNREAD_KEY), + _getSessionCompletionUnread(), + _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY) + ); + // Same convergence rule as the viewed counts: write only when the store does + // not already hold the merged result. + if (merged.changed) { + localStorage.setItem(SESSION_COMPLETION_UNREAD_KEY, JSON.stringify(merged.markers)); + } + _sessionCompletionUnread = merged.markers; } catch (_){ // Ignore localStorage write failures. } @@ -573,11 +695,51 @@ function _markSessionCompletionUnreadIfBackground(sid, messageCount = null, meta return true; } +// Record that the completion-unread marker(s) for `sidOrSids` were cleared at +// `clearedAt`. This is the ordering fact a merge needs: markers carry +// completed_at, so a marker that does not postdate the clear loses to it, while a +// completion that happened afterwards still wins. The tombstone store is bounded +// like the maps it guards: entries for sessions the list no longer shows are +// dropped, as are entries older than SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS. +function _writeSessionCompletionUnreadCleared(sidOrSids, clearedAt) { + const sids = Array.isArray(sidOrSids) ? sidOrSids : [sidOrSids]; + const cleared = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY); + for (const sid of sids) cleared[sid] = clearedAt; + const justCleared = new Set(sids); + const listLoaded = _sessionListLoaded(); + for (const key of Object.keys(cleared)) { + if (clearedAt - (Number(cleared[key]) || 0) > SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS) { + delete cleared[key]; + continue; + } + // The tombstones just written stay even when their sessions are already + // gone: session deletion is exactly the case where the marker must not come + // back from a client that still holds it. + if (listLoaded && !justCleared.has(key) && !_sessionExistsForUnreadState(key)) { + delete cleared[key]; + } + } + try { + localStorage.setItem(SESSION_COMPLETION_UNREAD_CLEARED_KEY, JSON.stringify(cleared)); + } catch (_){ + // Ignore localStorage write failures; the marker map write below still runs. + } +} + function _clearSessionCompletionUnread(sid) { if (!sid) return; const unread = _getSessionCompletionUnread(); - if (!Object.prototype.hasOwnProperty.call(unread, sid)) return; + if (!Object.prototype.hasOwnProperty.call(unread, sid)) { + // Another client may have recorded this marker since our cache was loaded, + // so the store decides whether there is anything to clear. + const stored = Object.prototype.hasOwnProperty.call( + _readStoredJsonMap(SESSION_COMPLETION_UNREAD_KEY), sid); + if (!stored) return; + } delete unread[sid]; + // Durable ordering fact, written before the marker map: a client that still + // holds this marker (completed_at not after the clear) loses to it. + _writeSessionCompletionUnreadCleared(sid, Date.now()); _saveSessionCompletionUnread(); } @@ -689,6 +851,7 @@ function _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile) { : 'default'; const unread = _getSessionCompletionUnread(); let changed = false; + const clearedSids = []; for (const sid of Object.keys(unread)) { const marker = unread[sid]; if (!marker || typeof marker !== 'object' || Array.isArray(marker)) continue; @@ -699,9 +862,14 @@ function _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile) { if (!resolved.profile) continue; if (_cronMarkerProfileMatchesActive(resolved.profile, active)) continue; delete unread[sid]; + clearedSids.push(sid); changed = true; } if (!changed) return false; + // Each cleared marker needs its durable ordering fact, or the merge in + // _saveSessionCompletionUnread() (and a stale client's later save) would + // restore it from the store. + _writeSessionCompletionUnreadCleared(clearedSids, Date.now()); _saveSessionCompletionUnread(); if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache(); return true; @@ -9095,11 +9263,15 @@ async function _handleShowAllProfilesStorageEvent(e){ function _handleUnreadStorageEvent(e){ if(!e || !e.key) return; // A concurrent WebUI client on the same origin/profile changed one of the - // unread stores. Drop our cached copy so the next read re-syncs from - // localStorage instead of our stale in-memory map clobbering their write - // (which would resurrect a cleared completion-unread marker). - if(e.key === SESSION_VIEWED_COUNTS_KEY) _sessionViewedCounts = null; - else if(e.key === SESSION_COMPLETION_UNREAD_KEY) _sessionCompletionUnread = null; + // unread stores. Both saves re-read the store, merge our cache into it (max for + // viewed counts, tombstone-ordered for completion markers), keep the merged map + // as the live cache, and write back only what the store is missing. So this + // client re-asserts the acknowledgement it still holds instead of dropping it + // and re-reading a value the other client's write already lost. + if(e.key === SESSION_VIEWED_COUNTS_KEY) _saveSessionViewedCounts(); + else if(e.key === SESSION_COMPLETION_UNREAD_KEY || e.key === SESSION_COMPLETION_UNREAD_CLEARED_KEY){ + _saveSessionCompletionUnread(); + } } if(typeof window!=='undefined'){ diff --git a/tests/_unread_store_helpers.py b/tests/_unread_store_helpers.py new file mode 100644 index 00000000..abe2e1ea --- /dev/null +++ b/tests/_unread_store_helpers.py @@ -0,0 +1,77 @@ +"""Node-harness scaffolding shared by the unread-store persistence tests. + +``static/sessions.js`` persists two unread stores (``hermes-session-viewed-counts`` +and ``hermes-session-completion-unread``) through a small set of pure helpers: +reading a stored map, asking whether a session still exists, and merging a cache +into a store under the monotonic / tombstone rules. The node harnesses in this +suite work by extracting individual functions from the module by name, so a +harness that drives a persistence path must also provide those helpers. + +Keeping the list here means every harness injects the same set from the same +source, and the store keys / tombstone cap always match the module. +""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + +_HELPER_NAMES = ( + "_readStoredJsonMap", + "_sessionExistsForUnreadState", + "_sessionListLoaded", + "_mergeSessionViewedCounts", + "_markerLosesToUnreadClear", + "_mergeSessionCompletionUnread", + "_writeSessionCompletionUnreadCleared", +) + +# Declared from the module source so a harness cannot drift from the values the +# module uses. Harnesses keep their own stub keys for the two older stores. +_CONST_NAMES = ( + "SESSION_COMPLETION_UNREAD_CLEARED_KEY", + "SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS", +) + +_CONST_FALLBACKS = { + "SESSION_COMPLETION_UNREAD_CLEARED_KEY": ( + "const SESSION_COMPLETION_UNREAD_CLEARED_KEY = " + "'hermes-session-completion-unread-cleared';" + ), + "SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS": ( + "const SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS = 7 * 24 * 60 * 60 * 1000;" + ), +} + + +def _function_source(name: str) -> str: + """Brace-match a top-level ``function name(...) { ... }`` definition.""" + marker = f"function {name}(" + if marker not in SESSIONS_JS: + return "" + start = SESSIONS_JS.index(marker) + brace = SESSIONS_JS.index("{", start) + depth = 0 + for i in range(brace, len(SESSIONS_JS)): + ch = SESSIONS_JS[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return SESSIONS_JS[start:i + 1] + raise AssertionError(f"could not brace-match {name}") + + +def _const_source(name: str) -> str: + for line in SESSIONS_JS.splitlines(): + stripped = line.strip() + if stripped.startswith(f"const {name} ="): + return stripped + return _CONST_FALLBACKS.get(name, "") + + +# Ready to interpolate into a node harness. +BLOCK = "\n".join( + [_const_source(name) for name in _CONST_NAMES] + + [_function_source(name) for name in _HELPER_NAMES if f"function {name}(" in SESSIONS_JS] +) diff --git a/tests/test_issue5273_active_done_viewed_sync.py b/tests/test_issue5273_active_done_viewed_sync.py index 26fada8c..da5d8594 100644 --- a/tests/test_issue5273_active_done_viewed_sync.py +++ b/tests/test_issue5273_active_done_viewed_sync.py @@ -1,6 +1,7 @@ import json import shutil import subprocess +import sys import textwrap from pathlib import Path @@ -11,6 +12,11 @@ ROOT = Path(__file__).resolve().parent.parent MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") +# Sibling harness scaffolding lives beside this file and is not on sys.path by +# default; import it the same way the other node-harness tests do. +sys.path.insert(0, str(ROOT / "tests")) +import _unread_store_helpers as unread_store_helpers # noqa: E402 + def _function_body(src: str, name: str) -> str: marker = f"function {name}" @@ -91,6 +97,8 @@ def _run_done_compaction_harness( _extract_function(SESSIONS_JS, "_hasSessionCompletionUnread"), _extract_function(SESSIONS_JS, "_hasUnreadForSession"), _extract_function(SESSIONS_JS, "_markSessionCompletedInList"), + # Merge/tombstone helpers the unread persistence paths call. + unread_store_helpers.BLOCK, ] ) has_pre_list_sync = _has_pre_list_view_sync() diff --git a/tests/test_issue5960_cron_unread_profile_scope.py b/tests/test_issue5960_cron_unread_profile_scope.py index f98b4d76..8c7b5c66 100644 --- a/tests/test_issue5960_cron_unread_profile_scope.py +++ b/tests/test_issue5960_cron_unread_profile_scope.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import shutil import subprocess +import sys from pathlib import Path import pytest @@ -16,6 +17,11 @@ SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8") NODE = shutil.which("node") +# Sibling harness scaffolding lives beside this file and is not on sys.path by +# default; import it the same way the other node-harness tests do. +sys.path.insert(0, str(ROOT / "tests")) +import _unread_store_helpers as unread_store_helpers # noqa: E402 + def _extract_function(source: str, name: str) -> str: start = source.index(f"function {name}(") @@ -152,6 +158,8 @@ def test_profile_switch_clears_persisted_old_profile_cron_markers_only(): save_unread = _extract_function(SESSIONS_JS, "_saveSessionCompletionUnread") clear_helpers = "\n".join( [ + # Merge/tombstone helpers the unread persistence paths call. + unread_store_helpers.BLOCK, _extract_function(SESSIONS_JS, "_isCronSessionForUnread"), _extract_function(SESSIONS_JS, "_sourceKeyForSession"), _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"), @@ -371,6 +379,8 @@ def test_legacy_untagged_cron_marker_cleared_via_sidebar_metadata(): """Re-gate #5975: untagged markers resolve from sidebar session source/profile.""" helpers = "\n".join( [ + # Merge/tombstone helpers the unread persistence paths call. + unread_store_helpers.BLOCK, _extract_function(SESSIONS_JS, "_isCronSessionForUnread"), _extract_function(SESSIONS_JS, "_sourceKeyForSession"), _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"), @@ -434,6 +444,8 @@ def test_root_alias_keeps_current_profile_cron_marker(): """Re-gate #5975: default/renamed-root must not erase current-root cron dots.""" helpers = "\n".join( [ + # Merge/tombstone helpers the unread persistence paths call. + unread_store_helpers.BLOCK, _extract_function(SESSIONS_JS, "_isCronSessionForUnread"), _extract_function(SESSIONS_JS, "_sourceKeyForSession"), _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"), @@ -495,6 +507,8 @@ def test_switch_to_literal_default_clears_other_profile_cron_markers(): """ helpers = "\n".join( [ + # Merge/tombstone helpers the unread persistence paths call. + unread_store_helpers.BLOCK, _extract_function(SESSIONS_JS, "_isCronSessionForUnread"), _extract_function(SESSIONS_JS, "_sourceKeyForSession"), _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"), @@ -575,6 +589,8 @@ def test_switch_to_literal_default_without_roster_fails_closed_on_unknown_names( gets exact-name semantics (cleared), and literal-'default' markers stay.""" helpers = "\n".join( [ + # Merge/tombstone helpers the unread persistence paths call. + unread_store_helpers.BLOCK, _extract_function(SESSIONS_JS, "_isCronSessionForUnread"), _extract_function(SESSIONS_JS, "_sourceKeyForSession"), _extract_function(SESSIONS_JS, "_cronCompletionUnreadMetaForSession"), @@ -636,6 +652,8 @@ def test_stale_pre_switch_session_list_does_not_recreate_cron_markers(): mark_poll = _extract_function(sessions_js, "_markPollingCompletionUnreadTransitions") helpers = "\n".join( [ + # Merge/tombstone helpers the unread persistence paths call. + unread_store_helpers.BLOCK, _extract_function(sessions_js, "_isCronSessionForUnread"), _extract_function(sessions_js, "_sourceKeyForSession"), _extract_function(sessions_js, "_cronCompletionUnreadMetaForSession"), @@ -765,6 +783,8 @@ def test_fresh_session_list_still_marks_when_unread_gen_matches(): mark_poll = _extract_function(sessions_js, "_markPollingCompletionUnreadTransitions") helpers = "\n".join( [ + # Merge/tombstone helpers the unread persistence paths call. + unread_store_helpers.BLOCK, _extract_function(sessions_js, "_isCronSessionForUnread"), _extract_function(sessions_js, "_sourceKeyForSession"), _extract_function(sessions_js, "_cronCompletionUnreadMetaForSession"), diff --git a/tests/test_session_unread_dot_on_visit.py b/tests/test_session_unread_dot_on_visit.py index df358d3f..6865922a 100644 --- a/tests/test_session_unread_dot_on_visit.py +++ b/tests/test_session_unread_dot_on_visit.py @@ -29,11 +29,17 @@ Two invariants flagged in review are protected here and MUST NOT regress: """ import json import subprocess +import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") +# Sibling harness scaffolding lives beside this file and is not on sys.path by +# default; import it the same way the other node-harness tests do. +sys.path.insert(0, str(ROOT / "tests")) +import _unread_store_helpers as unread_store_helpers # noqa: E402 + def _load_session_block() -> str: start = SESSIONS_JS.index("async function loadSession(sid") @@ -195,6 +201,7 @@ function _forgetObservedStreamingSession() {{}} {set_viewed} {sync} {ack} +{unread_store_helpers.BLOCK} // Seed a stale completion-unread marker for the open session. _getSessionCompletionUnread()['open'] = {{message_count: 5, completed_at: 1}}; _saveSessionCompletionUnread(); @@ -272,6 +279,7 @@ const _sessionListSourceById = new Map(); {effective} {sync} {ack} +{unread_store_helpers.BLOCK} function _isSessionActivelyViewedForList(sid) {{ if (!sid || !S.session || S.session.session_id !== sid) return false; if (_loadingSessionId && _loadingSessionId !== sid) return false; @@ -380,6 +388,7 @@ async function api(url) {{ _apiCalled = true; return _apiResult; }} {set_viewed} {actively_viewed} {ensure} +{unread_store_helpers.BLOCK} function _hasMarker() {{ return Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(), 'open'); diff --git a/tests/test_unread_cross_client_sync.py b/tests/test_unread_cross_client_sync.py index a81cfdf5..35ddc829 100644 --- a/tests/test_unread_cross_client_sync.py +++ b/tests/test_unread_cross_client_sync.py @@ -1,26 +1,44 @@ """Regression: unread state must not be clobbered by a second WebUI client. Two WebUI windows/tabs on the same origin and browser profile share one -``localStorage``. Each client caches the unread stores -(``hermes-session-viewed-counts`` and ``hermes-session-completion-unread``) in -module state and serialises that cache wholesale on save. With no ``storage`` -listener for either key, a stale client overwrites the other client's writes: +``localStorage``, and each caches the unread stores in module state. A stale +client that re-serialises its cache wholesale -- or that drops its cache and +re-reads a value another client's write already destroyed -- breaks two stores: -- a session's *viewed* count can be rolled backwards, so ``message_count > - viewed_count`` re-lights a dot the user just cleared; and -- a completion-unread marker cleared in one client is resurrected by the other. +- ``hermes-session-viewed-counts``: a viewed count means "seen at least up to N + messages", so it is monotonic per session and additive across clients. It must + never roll back, and after a concurrent write the client that holds the higher + count must re-assert it into the store (repair) instead of dropping its cache + and re-reading whatever the losing write left behind. -This fixes the class by (a) max-merging viewed counts on save so a count can -never go down or drop another client's entries, and (b) invalidating both -caches on ``storage`` events so a concurrent write is re-read instead of -overwritten. +- ``hermes-session-completion-unread``: markers are add/remove, so they cannot be + max-merged. Every clear records a tombstone in + ``hermes-session-completion-unread-cleared`` (sid -> clearedAt), and the later + event wins: a marker whose ``completed_at`` does not postdate the clear is + dropped, while a genuinely later re-mark survives. This is the store behind the + visible dot, and the reported field symptom was a cleared dot coming back. + +These tests drive the real functions extracted from ``static/sessions.js`` +against a two-client harness that shares one fake ``localStorage``. The harness +injects the merge/tombstone helpers when the module defines them, so the same +assertions run against the pre-fix code and report the observable failure. """ import json +import shutil import subprocess from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[1] SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + +VIEWED_KEY = "hermes-session-viewed-counts" +UNREAD_KEY = "hermes-session-completion-unread" +CLEARED_KEY = "hermes-session-completion-unread-cleared" def _extract(name: str) -> str: @@ -40,18 +58,47 @@ def _extract(name: str) -> str: raise AssertionError(f"could not brace-match {name}") +def _extract_optional(name: str) -> str: + """Extraction for functions a change may add (empty string when absent). + + The same assertions therefore run before and after the fix: on the pre-fix + code the harness simply has no merge/tombstone helper to inject. + """ + return _extract(name) if f"function {name}(" in SESSIONS_JS else "" + + +def _extract_storage_listener() -> str: + """The module-level ``window.addEventListener('storage', ...)`` statement.""" + marker = "window.addEventListener('storage'" + start = SESSIONS_JS.index(marker) + brace = SESSIONS_JS.index("{", start) + depth = 0 + for i in range(brace, len(SESSIONS_JS)): + ch = SESSIONS_JS[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return SESSIONS_JS[start:i + 3] # include the closing "});" + raise AssertionError("could not brace-match the storage listener") + + def _run_node(script: str) -> dict: - result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + result = subprocess.run( + [NODE, "-e", script], capture_output=True, text=True, timeout=60 + ) + assert result.returncode == 0, f"node harness failed:\n{result.stderr}" return json.loads(result.stdout) -_VIEWED_FNS = [ +# Functions the unread stores are made of. All of these exist before and after +# the fix, so a missing one is a real extraction failure. +_CLIENT_FNS = [ "_getSessionViewedCounts", "_saveSessionViewedCounts", "_setSessionViewedCount", "_clearSessionViewedCount", -] -_UNREAD_FNS = [ "_getSessionCompletionUnread", "_saveSessionCompletionUnread", "_markSessionCompletionUnread", @@ -59,118 +106,400 @@ _UNREAD_FNS = [ "_hasSessionCompletionUnread", ] -_HEADER = """ -const _store = {}; -const localStorage = { - getItem: (k) => (k in _store ? _store[k] : null), - setItem: (k, v) => { _store[k] = String(v); }, -}; -const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts'; -const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread'; +# Helpers that carry the merge/tombstone rules; injected when present. +_HELPER_FNS = [ + "_readStoredJsonMap", + "_sessionExistsForUnreadState", + "_sessionListLoaded", + "_mergeSessionViewedCounts", + "_markerLosesToUnreadClear", + "_mergeSessionCompletionUnread", + "_writeSessionCompletionUnreadCleared", +] + +_HEADER = f""" +// One fake localStorage shared by every client, counting writes so a test can +// assert a repair converges instead of ping-ponging writes between clients. +const _store = {{}}; +let storageWrites = 0; +let _onRead = null; +const localStorage = {{ + getItem: (k) => {{ + const value = Object.prototype.hasOwnProperty.call(_store, k) ? _store[k] : null; + // Test hook: runs after the value is captured, i.e. inside a read-modify-write. + if (_onRead) {{ const hook = _onRead; _onRead = null; hook(); }} + return value; + }}, + setItem: (k, v) => {{ storageWrites += 1; _store[k] = String(v); }}, + removeItem: (k) => {{ delete _store[k]; }}, +}}; +const SESSION_VIEWED_COUNTS_KEY = {VIEWED_KEY!r}; +const SESSION_COMPLETION_UNREAD_KEY = {UNREAD_KEY!r}; +const SESSION_COMPLETION_UNREAD_CLEARED_KEY = {CLEARED_KEY!r}; +// Mirrors the module constant. The age assertions use hours vs 8 days so they +// do not depend on the exact cap. +const SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS = 7 * 24 * 60 * 60 * 1000; +let _now = 1000; +Date.now = () => _now; +// The session list this client can see: the authority on "still exists". +let _allSessions = []; +const _sessionListSnapshotById = new Map(); +const listed = (...ids) => {{ _allSessions = ids.map((sid) => ({{session_id: sid}})); }}; +const readDisk = (key) => JSON.parse(_store[key] || '{{}}'); +const setDisk = (key, value) => {{ _store[key] = JSON.stringify(value); }}; +const _storageHandlers = []; +const window = {{ + addEventListener: (type, fn) => {{ + if (type === 'storage') _storageHandlers.push(fn); + }}, +}}; +// The neighbours the storage registration also routes to; only the unread +// handler is under test here. +async function _handleActiveSessionStorageEvent() {{}} +async function _handleShowAllProfilesStorageEvent() {{}} """ -def _make_client_block(): - """A factory that defines one client's module state and returns its API.""" - fns = "\n".join(_extract(n) for n in _VIEWED_FNS + _UNREAD_FNS) - handler = "" - if "function _handleUnreadStorageEvent(" in SESSIONS_JS: - handler = _extract("_handleUnreadStorageEvent") +def _client_factory(with_listener: bool = False) -> str: + """One client's module state plus the whole unread-store API.""" + fns = "\n".join(_extract(name) for name in _CLIENT_FNS) + helpers = "\n".join(_extract_optional(name) for name in _HELPER_FNS) + handler = _extract_optional("_handleUnreadStorageEvent") + listener = _extract_storage_listener() if with_listener else "" return f""" function makeClient() {{ let _sessionViewedCounts = null; let _sessionCompletionUnread = null; {fns} + {helpers} {handler} + {listener} return {{ + viewed: (sid) => {{ + const counts = _getSessionViewedCounts(); + return Object.prototype.hasOwnProperty.call(counts, sid) ? counts[sid] : null; + }}, setViewed: _setSessionViewedCount, clearViewed: _clearSessionViewedCount, - getViewed: (sid) => {{ - const c = _getSessionViewedCounts(); - return c[sid] === undefined ? null : c[sid]; - }}, markUnread: _markSessionCompletionUnread, clearUnread: _clearSessionCompletionUnread, hasUnread: _hasSessionCompletionUnread, - handleStorage: (typeof _handleUnreadStorageEvent === 'function') ? _handleUnreadStorageEvent : null, + handleStorage: (typeof _handleUnreadStorageEvent === 'function') + ? _handleUnreadStorageEvent + : () => {{}}, }}; }} """ -def test_viewed_count_is_monotonic_across_clients(): - """A stale client must not roll a session's viewed count backwards. +def _script(body: str, with_listener: bool = False) -> str: + return _HEADER + _client_factory(with_listener) + body - Fails before the fix: client B serialises its stale cache and overwrites - client A's newer value, so the cleared dot re-lights via the count rule. - """ - script = _HEADER + _make_client_block() + """ + +# ── Viewed counts: monotonic, repaired, never resurrected ──────────────────── + +def test_stale_client_cannot_roll_a_viewed_count_back(): + """Sequential rollback: a client that saw an older list snapshot must not + lower a count another client already acknowledged.""" + out = _run_node(_script(""" +listed('X'); const A = makeClient(); const B = makeClient(); A.setViewed('X', 417); -B.setViewed('X', 131); // stale client believes the session has 131 msgs +B.setViewed('X', 131); console.log(JSON.stringify({ - disk: _store['hermes-session-viewed-counts'] ? JSON.parse(_store['hermes-session-viewed-counts']).X : null, - bCache: B.getViewed('X'), + disk: readDisk(SESSION_VIEWED_COUNTS_KEY), + bView: B.viewed('X'), })); -""" - out = _run_node(script) - assert out["disk"] == 417, ( - "a stale client must not lower a session's viewed count (disk was " - f"{out['disk']}, expected 417)" - ) - assert out["bCache"] == 417, ( - "the stale client's cache must be re-synced to the merged count" +""")) + assert out["disk"] == {"X": 417}, ( + "a stale client must not lower a session's viewed count" ) + assert out["bView"] == 417, "the stale client must converge on the merged count" def test_clear_viewed_count_still_removes_from_disk(): - """Guard: session deletion must still prune the viewed-count entry.""" - script = _HEADER + _make_client_block() + """ + """Guard: session deletion must still prune the viewed count.""" + out = _run_node(_script(""" +listed('D'); const A = makeClient(); A.setViewed('D', 5); A.clearViewed('D'); -const parsed = JSON.parse(_store['hermes-session-viewed-counts'] || '{}'); -console.log(JSON.stringify({ diskStillHas: Object.prototype.hasOwnProperty.call(parsed, 'D') })); -""" - out = _run_node(script) - assert out["diskStillHas"] is False, "clearing a viewed count must remove it from disk" +console.log(JSON.stringify({ + diskHasD: Object.prototype.hasOwnProperty.call(readDisk(SESSION_VIEWED_COUNTS_KEY), 'D'), +})); +""")) + assert out["diskHasD"] is False, "clearing a viewed count must remove it from disk" -def test_storage_event_invalidates_unread_caches(): - """A storage event for either unread key must drop the in-memory cache so a - concurrent client's write is re-read, not clobbered. - - Skipped before the fix (the handler does not exist yet); passes after. - """ - if "function _handleUnreadStorageEvent(" not in SESSIONS_JS: - import pytest - pytest.skip("_handleUnreadStorageEvent not present yet") - - script = _HEADER + _make_client_block() + """ +def test_interleaved_acknowledgements_both_survive(): + """Both clients read one baseline, then each write lands inside the other's + read-modify-write window. Neither acknowledgement may be lost.""" + out = _run_node(_script(""" +listed('X', 'Y'); const A = makeClient(); const B = makeClient(); -A.markUnread('Y', 3); // A records a completion -B.hasUnread('Y'); // B loads the marker into its cache -A.clearUnread('Y'); // A clears it (e.g. the user visited Y in A) -// B sees A's storage event and must forget its cached copy. -B.handleStorage({ key: 'hermes-session-completion-unread' }); -B.markUnread('Z', 7); // B writes for an unrelated session -const disk = JSON.parse(_store['hermes-session-completion-unread'] || '{}'); +// Both clients read the same baseline before either write commits. +A.viewed('X'); +B.viewed('Y'); +// A's save reads the store; B's write for its own session lands in that window, +// so A's write (built from the store it read) drops B's entry. +_onRead = () => { B.setViewed('Y', 999); }; +A.setViewed('X', 417); +const clobbered = readDisk(SESSION_VIEWED_COUNTS_KEY); +// B still holds its own acknowledgement, and now processes A's storage event. +B.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY }); +const disk = readDisk(SESSION_VIEWED_COUNTS_KEY); +const writesAfterRepair = storageWrites; +B.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY }); console.log(JSON.stringify({ - yResurrected: Object.prototype.hasOwnProperty.call(disk, 'Y'), - zPresent: Object.prototype.hasOwnProperty.call(disk, 'Z'), + clobbered: clobbered, + disk: disk, + stable: storageWrites === writesAfterRepair, + aView: A.viewed('X'), + bView: B.viewed('Y'), })); -""" - out = _run_node(script) - assert out["zPresent"] is True, "B's new marker must be persisted" - assert out["yResurrected"] is False, ( - "a marker cleared in one client must not be resurrected by another" +""")) + assert out["clobbered"] == {"X": 417}, ( + "precondition: the interleaved write dropped B's entry" + ) + assert out["disk"] == {"X": 417, "Y": 999}, ( + "the client that still holds an acknowledgement must re-assert it after " + "the other client's storage event, not re-read the store it just lost" + ) + assert out["aView"] == 417 and out["bView"] == 999, ( + "both clients must converge on a view that contains both acknowledgements" + ) + assert out["stable"] is True, ( + "a repair must converge: a second event must not write again" ) -def test_unread_storage_handler_is_wired(): - """The window storage listener must route to the unread-cache handler.""" - assert "void _handleUnreadStorageEvent(e);" in SESSIONS_JS, ( - "the storage listener must invoke _handleUnreadStorageEvent" +def test_repair_does_not_resurrect_a_deleted_sessions_viewed_count(): + """A session deleted in one client is pruned from the store; a stale client + that still caches its count must not write it back.""" + out = _run_node(_script(""" +listed('S', 'T'); +const A = makeClient(); +const B = makeClient(); +A.setViewed('S', 5); +B.viewed('S'); +A.clearViewed('S'); +listed('T'); +B.setViewed('T', 1); +B.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY }); +console.log(JSON.stringify({ + disk: readDisk(SESSION_VIEWED_COUNTS_KEY), + tView: B.viewed('T'), +})); +""")) + assert out["disk"] == {"T": 1}, ( + "a deleted session's pruned count must not be resurrected by a stale " + f"client's save or repair (disk was {out['disk']})" + ) + assert out["tView"] == 1, "the live acknowledgement for the listed session must survive" + + +# ── Completion unread: the later event wins ────────────────────────────────── + +def test_stale_client_cannot_resurrect_a_cleared_completion_marker(): + """The field symptom: a marker cleared by opening the chat must not come + back when another client that still holds it saves later.""" + out = _run_node(_script(""" +listed('Y', 'Z'); +const A = makeClient(); +const B = makeClient(); +_now = 1000; +A.markUnread('Y', 3); +B.hasUnread('Y'); +_now = 2000; +A.clearUnread('Y'); +// B has not processed A's storage event yet and saves for another session. +B.markUnread('Z', 7); +const disk = readDisk(SESSION_COMPLETION_UNREAD_KEY); +console.log(JSON.stringify({ + y: Object.prototype.hasOwnProperty.call(disk, 'Y'), + z: Object.prototype.hasOwnProperty.call(disk, 'Z'), + yViewA: A.hasUnread('Y'), +})); +""")) + assert out["z"] is True, "precondition: the stale client's own marker is persisted" + assert out["y"] is False, ( + "a marker cleared in one client must not be resurrected by another " + "client that still holds it" + ) + assert out["yViewA"] is False, "the cleared marker must stay cleared for the clear's own client" + + +def test_a_genuine_later_re_mark_survives_the_earlier_clear(): + """Control: ordering must not over-clear. A completion that happened after + the clear is new information and must win.""" + out = _run_node(_script(""" +listed('Y'); +const A = makeClient(); +const B = makeClient(); +_now = 1000; +A.markUnread('Y', 3); +B.hasUnread('Y'); +_now = 2000; +A.clearUnread('Y'); +_now = 3000; +A.markUnread('Y', 9); +B.handleStorage({ key: SESSION_COMPLETION_UNREAD_KEY }); +const disk = readDisk(SESSION_COMPLETION_UNREAD_KEY); +console.log(JSON.stringify({ + marker: disk.Y || null, + aView: A.hasUnread('Y'), + bView: B.hasUnread('Y'), +})); +""")) + assert out["marker"] == {"message_count": 9, "completed_at": 3000}, ( + "a completion that postdates the clear must be kept" + ) + assert out["aView"] is True and out["bView"] is True, ( + "both clients must show the genuine re-mark" + ) + + +def test_repair_removes_a_marker_that_lost_to_a_clear(): + """A stale client's write can land after the clear. The repair must drop the + losing marker from the store, or the dot returns on every client.""" + out = _run_node(_script(""" +listed('Y', 'Z'); +const A = makeClient(); +const B = makeClient(); +_now = 1000; +A.markUnread('Y', 3); +B.hasUnread('Y'); +_now = 2000; +A.clearUnread('Y'); +// The stale write lands after the clear (saved before the event was processed). +setDisk(SESSION_COMPLETION_UNREAD_KEY, { + Y: { message_count: 3, completed_at: 1000 }, + Z: { message_count: 2, completed_at: 1500 }, +}); +A.handleStorage({ key: SESSION_COMPLETION_UNREAD_KEY }); +const disk = readDisk(SESSION_COMPLETION_UNREAD_KEY); +console.log(JSON.stringify({ + diskY: Object.prototype.hasOwnProperty.call(disk, 'Y'), + diskZ: Object.prototype.hasOwnProperty.call(disk, 'Z'), + yView: A.hasUnread('Y'), + zView: A.hasUnread('Z'), +})); +""")) + assert out["diskY"] is False, ( + "a repair must remove a marker that lost to a clear, not leave it for " + "the next reader" + ) + assert out["diskZ"] is True, "an unrelated marker must be left alone" + assert out["yView"] is False and out["zView"] is True, ( + "the repaired view must match the store" + ) + + +def test_tombstones_are_pruned_when_their_session_is_gone(): + """Tombstones are keyed by session id; one whose session no longer exists + must not accumulate.""" + out = _run_node(_script(""" +listed('Y', 'GONE'); +const A = makeClient(); +_now = 1000; +A.markUnread('GONE', 1); +A.clearUnread('GONE'); +listed('Y'); +_now = 2000; +A.markUnread('Y', 1); +A.clearUnread('Y'); +console.log(JSON.stringify({ tombs: readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY) })); +""")) + assert out["tombs"] == {"Y": 2000}, ( + "only the tombstone for a session that still exists may be kept" + ) + + +def test_tombstones_are_capped_by_age(): + """Quota guard: an old tombstone is dropped when a newer clear is recorded.""" + out = _run_node(_script(""" +listed('Y', 'Z'); +const A = makeClient(); +_now = 1000; +A.markUnread('Y', 1); +A.clearUnread('Y'); +const young = _now + 60 * 60 * 1000; +_now = young; +A.markUnread('Z', 1); +A.clearUnread('Z'); +const keptYoung = readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY); +_now = young + 8 * 24 * 60 * 60 * 1000; +A.markUnread('Y', 1); +A.clearUnread('Y'); +console.log(JSON.stringify({ + keptYoung: keptYoung, + afterAge: readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY), + young: young, +})); +""")) + assert out["keptYoung"] == {"Y": 1000, "Z": out["young"]}, ( + "a tombstone younger than the cap must survive a newer clear" + ) + assert out["afterAge"] == {"Y": out["young"] + 8 * 24 * 60 * 60 * 1000}, ( + "a tombstone older than the cap must be dropped" + ) + + +# ── The registration itself ────────────────────────────────────────────────── + +def test_registered_storage_listener_syncs_both_unread_keys_from_storage(): + """Behavioural wiring test: capture the handler the module registers on + ``storage``, dispatch synthetic events for both keys, and assert the client's + view comes from the updated store.""" + out = _run_node(_script(""" +listed('X', 'Q', 'R'); +const A = makeClient(); +const dispatch = (key) => { + const value = Object.prototype.hasOwnProperty.call(_store, key) ? _store[key] : null; + for (const handler of _storageHandlers) handler({ key: key, newValue: value }); +}; +A.setViewed('X', 511); +A.hasUnread('Q'); +// A stale client clobbers the store with its lower count. +setDisk(SESSION_VIEWED_COUNTS_KEY, { X: 131 }); +dispatch(SESSION_VIEWED_COUNTS_KEY); +const writesAfterRepair = storageWrites; +dispatch(SESSION_VIEWED_COUNTS_KEY); +const repairStable = storageWrites === writesAfterRepair; +// A marker another client just recorded, and a clear it recorded. +setDisk(SESSION_COMPLETION_UNREAD_KEY, { Q: { message_count: 2, completed_at: 5000 } }); +const qStale = A.hasUnread('Q'); +dispatch(SESSION_COMPLETION_UNREAD_KEY); +setDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY, { R: 9000 }); +setDisk(SESSION_COMPLETION_UNREAD_KEY, { + Q: { message_count: 2, completed_at: 5000 }, + R: { message_count: 3, completed_at: 8000 }, +}); +dispatch(SESSION_COMPLETION_UNREAD_CLEARED_KEY); +const disk = readDisk(SESSION_COMPLETION_UNREAD_KEY); +console.log(JSON.stringify({ + handlers: _storageHandlers.length, + xView: A.viewed('X'), + diskX: readDisk(SESSION_VIEWED_COUNTS_KEY).X, + stable: repairStable, + qStale: qStale, + qView: A.hasUnread('Q'), + rView: A.hasUnread('R'), + diskR: Object.prototype.hasOwnProperty.call(disk, 'R'), +})); +""", with_listener=True)) + assert out["handlers"] == 1, "the module must register exactly one storage listener" + assert out["xView"] == 511 and out["diskX"] == 511, ( + "a storage event must repair the store with the count this client still " + "holds instead of adopting the clobbering value" + ) + assert out["stable"] is True, "the repair must not write again for the same state" + assert out["qStale"] is False and out["qView"] is True, ( + "a storage event for the unread key must refresh the client's marker view" + ) + assert out["rView"] is False and out["diskR"] is False, ( + "the cleared-marker key must be routed to the handler and its tombstone " + "must win over the stale marker in the store" ) From 000b85a4504ce67aaee57d3b7bbe77dc6d7b4353 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 15 Sep 2026 15:18:36 +0300 Subject: [PATCH 041/446] fix(sessions): prune viewed counts only the store holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_clearSessionViewedCount` returned early when this client's cache did not hold the session id, so it never consulted the persisted map. A viewed count another client acknowledged after our cache was loaded therefore survived session deletion, leaving one entry behind per deleted session — the quota pressure this prune path exists to avoid — and a later repair merged that stale entry back into the store. Read the store on every deletion and drop the key when the store holds it. The deletion must not cost this client the acknowledgement it holds for another session, so adopt the store's entries while keeping any higher count already cached — the merge the repair path uses — instead of replacing the cache with the store map. The write stays conditional, so a deletion that changes nothing does not fire a storage event. Behavioural two-client coverage in tests/test_unread_cross_client_sync.py: `test_delete_prunes_a_viewed_count_only_the_store_holds` and `test_delete_keeps_the_higher_count_this_client_holds_for_others`. Both fail on the previous revision (the store keeps the deleted session's count, and the repair writes it back) and pass with this change. --- static/sessions.js | 19 ++++++--- tests/test_unread_cross_client_sync.py | 57 ++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 4915ebfb..96cc8179 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -878,23 +878,30 @@ function _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile) { function _clearSessionViewedCount(sid) { if (!sid) return; const counts = _getSessionViewedCounts(); - if (!Object.prototype.hasOwnProperty.call(counts, sid)) return; + const hadCachedEntry = Object.prototype.hasOwnProperty.call(counts, sid); delete counts[sid]; // A removal must actually leave disk, unlike the additive max-merge used on // the set path (which never deletes). Delete just this key from the persisted - // map so entries a concurrent client added are preserved. + // map so entries a concurrent client added are preserved. The store is + // consulted even when our cache never held the key: an entry another client + // added since our cache was loaded is exactly the one this path has to prune. try { const parsed = JSON.parse(localStorage.getItem(SESSION_VIEWED_COUNTS_KEY) || '{}'); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - delete parsed[sid]; - localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(parsed)); - _sessionViewedCounts = parsed; + if (Object.prototype.hasOwnProperty.call(parsed, sid)) { + delete parsed[sid]; + localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(parsed)); + } + // Adopt the store's entries but keep any higher count we already hold: we + // are the only client left that can repair a count another client + // lowered, so dropping ours here would lose the acknowledgement. + _sessionViewedCounts = _mergeSessionViewedCounts(parsed, counts).live; return; } } catch (_){ // Fall through to the cache-only write on read/parse failure. } - _saveSessionViewedCounts(); + if (hadCachedEntry) _saveSessionViewedCounts(); } function _hasSessionCompletionUnread(sid) { diff --git a/tests/test_unread_cross_client_sync.py b/tests/test_unread_cross_client_sync.py index 35ddc829..e3e21b68 100644 --- a/tests/test_unread_cross_client_sync.py +++ b/tests/test_unread_cross_client_sync.py @@ -232,6 +232,63 @@ console.log(JSON.stringify({ assert out["diskHasD"] is False, "clearing a viewed count must remove it from disk" +def test_delete_prunes_a_viewed_count_only_the_store_holds(): + """Deletion must consult the store, not just our cache. An entry another + client added after this client's cache was loaded is exactly what the prune + path exists to remove, and an early return on the cache miss leaves it + behind for a session the list no longer shows.""" + out = _run_node(_script(""" +listed('E'); +const A = makeClient(); +const B = makeClient(); +// B's cache loads while the store is still empty, so B never sees E. +B.viewed('E'); +// A acknowledges E, which B cannot know about from its own cache. +A.setViewed('E', 17); +const bCached = B.viewed('E'); +B.clearViewed('E'); +console.log(JSON.stringify({ + bCached: bCached, + disk: readDisk(SESSION_VIEWED_COUNTS_KEY), +})); +""")) + assert out["bCached"] is None, "precondition: B's cache must not hold E" + assert out["disk"] == {}, ( + "deleting a session must prune its viewed count from the store even " + "when this client's cache never held the key" + ) + + +def test_delete_keeps_the_higher_count_this_client_holds_for_others(): + """Pruning one session must not cost this client the acknowledgement it + holds for another. Adopting the store's view wholesale while pruning would + drop a count another client already lowered, and this client is the only + one that can still repair it.""" + out = _run_node(_script(""" +listed('X', 'E'); +const A = makeClient(); +A.setViewed('X', 417); +// Another client lowered X while recording its own acknowledgement for E. +setDisk(SESSION_VIEWED_COUNTS_KEY, { X: 131, E: 17 }); +A.clearViewed('E'); +const held = A.viewed('X'); +// A now processes the storage event that clobber arrived with. +A.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY }); +console.log(JSON.stringify({ + held: held, + disk: readDisk(SESSION_VIEWED_COUNTS_KEY), +})); +""")) + assert out["held"] == 417, ( + "pruning one session must not drop the higher count this client holds " + "for another" + ) + assert out["disk"] == {"X": 417}, ( + "the client must still hold the acknowledgement that repairs the " + "lowered count in the store" + ) + + def test_interleaved_acknowledgements_both_survive(): """Both clients read one baseline, then each write lands inside the other's read-modify-write window. Neither acknowledgement may be lost.""" From 927f1ca4f72190016d60c16b78b8a0995d2e1e08 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 15 Sep 2026 15:48:02 +0300 Subject: [PATCH 042/446] docs(rfc): document the client-side unread persistence contract The run state consistency contract is where sidebar metadata work routes, and its state-layer table already lists the sidebar layer, but nothing recorded the rules the two client-side unread stores actually follow: viewed counts converge by a max merge, completion markers are ordered by per-session tombstones with a bounded retention window, and the storage listener repairs the store instead of only dropping the local cache. Write those rules down where they belong, naming the three localStorage keys and the retention cap, and state the limitation explicitly: each save is a read-modify-write across separate localStorage operations with no lock between them, so the contract is convergence rather than atomicity, and two concurrent clears can still lose a tombstone. tests/test_webui_run_state_consistency_contract_rfc.py pins the documented keys and the documented retention window to the constants in static/sessions.js, so changing one without the other fails. It fails on the previous revision and passes with this change. --- .../webui-run-state-consistency-contract.md | 49 +++++++++++++- ...ebui_run_state_consistency_contract_rfc.py | 65 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tests/test_webui_run_state_consistency_contract_rfc.py diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index ca185de6..f7fd90b9 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -3,7 +3,7 @@ - **Status:** Proposed - **Author:** @franksong2702 - **Created:** 2026-05-16 -- **Updated:** 2026-08-22 +- **Updated:** 2026-09-15 - **Tracking issue:** [#2361](https://github.com/nesquena/hermes-webui/issues/2361) - **Related architecture:** [#1925](https://github.com/nesquena/hermes-webui/issues/1925), [`hermes-run-adapter-contract.md`](hermes-run-adapter-contract.md), [`stable-assistant-turn-anchors.md`](stable-assistant-turn-anchors.md) @@ -75,6 +75,7 @@ and 5; it does not mark every run-state boundary implemented. | Compression summary / handoff | Gives the agent recovery context after automatic compression | Must remain agent-facing recovery material unless explicitly rendered as history | Pollute the active turn or become implicit current user intent | | Live UI scene/cache | Preserves expanded rows, in-progress cards, local scroll, and transient grouping | May optimize presentation but must be rebuildable or degradable from transcript/replay | Become the only place where chronological ordering exists | | Sidebar/session metadata | Helps the user find active and recent sessions | Must reflect meaningful user or assistant activity | Treat background cleanup as a fresh user-facing update | +| Client-side unread stores (`localStorage`) | Backs the sidebar unread dot for every client on the origin | Converges across clients by merge and tombstone ordering; not atomic | Let one client's stale cache lower a count or resurrect a cleared marker | ## Core Invariants @@ -153,6 +154,48 @@ and 5; it does not mark every run-state boundary implemented. timestamp (falling back to run start), so a long-running turn cancelled moments ago is never mistaken for an orphan. +## Client-side unread persistence (sidebar layer) + +The sidebar unread dot is backed by two client-side stores in `static/sessions.js`. +Both live in `localStorage` under the origin, so every WebUI client on the same +origin/profile (a PWA window and a browser tab, for example) shares them while each +client also caches them in module state. They are projections of the sidebar layer +above; the rules below describe what stays coherent when more than one client +writes. + +| Store | Key | Semantics | +|---|---|---| +| Viewed counts | `hermes-session-viewed-counts` | `sid -> N`, meaning "seen at least up to N messages" | +| Completion markers | `hermes-session-completion-unread` | `sid -> {message_count, completed_at, ...}` behind the visible dot | + +- **Viewed counts merge by maximum.** The fact is monotonic per session and + additive across clients, so a save reads the store, merges its cache in with a + max, and writes only when the store does not already hold the merged result. A + count held only in a client's cache is written back only for a session the + sidebar list still shows, so a session deleted in another client is not + resurrected. +- **Completion markers are ordered by tombstones.** Markers are add/remove and + cannot be max-ordered, so each clear records `sid -> clearedAt` in + `hermes-session-completion-unread-cleared`, and a marker that does not postdate + its clear loses; a genuine later completion still wins. The tombstone map is + bounded by session existence and a 7-day retention cap, and is never part of the + marker map consumers read. +- **Cross-client repair, not cache invalidation.** The `storage` listener routes a + changed unread key back through that same merge instead of only dropping the + local cache. A client that still holds an acknowledgement re-asserts it after the + other client's write, the loser sees a value it cannot beat and stops, and repair + converges instead of ping-ponging storage events. +- **The contract is convergence, not atomicity.** Each save is a read-modify-write + across separate `localStorage` operations with no lock between them, so two + clients writing inside the same window can still lose one update; repair + re-asserts held acknowledgements on the next storage event. The case repair + cannot fix is two concurrent *clears*: the later whole-map write can discard the + other client's tombstone, after which a later marker save can re-add the stale + marker and the cleared dot returns. A hard cross-tab guarantee needs either a + lock around those writes (Web Locks, with a documented fallback for contexts + without it) or per-session versioned records. This contract deliberately claims + only the convergence described here. + ## Review Checklist Use this checklist for PRs that touch run state, streaming, replay, compression, @@ -175,6 +218,10 @@ context reconstruction, or session metadata: - If it introduces or changes a reclamation window, what proves an in-flight cancellation is not evicted early, and that a wedged one is eventually freed? - Can automatic compression or recovery text become visible active-turn content? +- Does this change write one of the client-side unread stores + (`hermes-session-viewed-counts`, `hermes-session-completion-unread`, + `hermes-session-completion-unread-cleared`), and does it keep the merge and + tombstone rules in the client-side unread persistence section? - What test or manual evidence proves the invariant? ## Existing Issue Map diff --git a/tests/test_webui_run_state_consistency_contract_rfc.py b/tests/test_webui_run_state_consistency_contract_rfc.py new file mode 100644 index 00000000..b8c38a10 --- /dev/null +++ b/tests/test_webui_run_state_consistency_contract_rfc.py @@ -0,0 +1,65 @@ +"""The run state consistency contract must document the client-side unread +persistence rules, and what it names has to match what `static/sessions.js` +implements. + +The contract is prose, so it cannot be verified behaviourally. What rots silently +is the identifier set: the store keys the sidebar layer depends on and the +retention window that bounds the tombstone map. These assertions pin the +documented names to the implementation constants, so changing either one without +the other fails here instead of drifting. +""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RFC = ROOT / "docs" / "rfcs" / "webui-run-state-consistency-contract.md" +SESSIONS_JS = ROOT / "static" / "sessions.js" + +# The stores the sidebar layer persists in localStorage, and the constant that +# bounds the tombstone map. +DOCUMENTED_CONSTANTS = [ + "SESSION_VIEWED_COUNTS_KEY", + "SESSION_COMPLETION_UNREAD_KEY", + "SESSION_COMPLETION_UNREAD_CLEARED_KEY", +] + + +def _sessions_js() -> str: + return SESSIONS_JS.read_text(encoding="utf-8") + + +def _string_constant(source: str, name: str) -> str: + match = re.search(rf"^const {name} = '([^']*)';", source, re.MULTILINE) + assert match, f"{name} must be defined as a string constant in static/sessions.js" + return match.group(1) + + +def test_contract_documents_the_client_side_unread_store_keys(): + text = RFC.read_text(encoding="utf-8") + source = _sessions_js() + + for name in DOCUMENTED_CONSTANTS: + key = _string_constant(source, name) + assert key in text, ( + f"the contract must name the store behind {name} ({key})" + ) + + +def test_documented_retention_window_matches_the_implemented_cap(): + source = _sessions_js() + match = re.search( + r"^const SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS = (\d+) \*", + source, + re.MULTILINE, + ) + assert match, ( + "SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS must be defined in days in " + "static/sessions.js" + ) + days = int(match.group(1)) + + text = RFC.read_text(encoding="utf-8") + assert f"{days}-day" in text, ( + f"the contract must state the {days}-day retention cap on the tombstone map" + ) From 9b81ac4d482ac05cb9e19f28e985c52835244902 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 15 Sep 2026 16:53:29 +0300 Subject: [PATCH 043/446] fix: make unread clears race-safe across clients --- .../webui-run-state-consistency-contract.md | 47 +++--- static/sessions.js | 156 +++++++++++++----- tests/_unread_store_helpers.py | 4 + ...est_issue5960_cron_unread_profile_scope.py | 14 ++ tests/test_session_unread_dot_on_visit.py | 9 + tests/test_unread_cross_client_sync.py | 124 +++++++++++++- 6 files changed, 285 insertions(+), 69 deletions(-) diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index f7fd90b9..d15a4a20 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -75,7 +75,7 @@ and 5; it does not mark every run-state boundary implemented. | Compression summary / handoff | Gives the agent recovery context after automatic compression | Must remain agent-facing recovery material unless explicitly rendered as history | Pollute the active turn or become implicit current user intent | | Live UI scene/cache | Preserves expanded rows, in-progress cards, local scroll, and transient grouping | May optimize presentation but must be rebuildable or degradable from transcript/replay | Become the only place where chronological ordering exists | | Sidebar/session metadata | Helps the user find active and recent sessions | Must reflect meaningful user or assistant activity | Treat background cleanup as a fresh user-facing update | -| Client-side unread stores (`localStorage`) | Backs the sidebar unread dot for every client on the origin | Converges across clients by merge and tombstone ordering; not atomic | Let one client's stale cache lower a count or resurrect a cleared marker | +| Client-side unread stores (`localStorage`) | Backs the sidebar unread dot for every client on the origin | Converges counts/markers across clients and stores clear ordering independently per session | Let one client's stale cache lower a count or resurrect a cleared marker | ## Core Invariants @@ -174,27 +174,32 @@ writes. count held only in a client's cache is written back only for a session the sidebar list still shows, so a session deleted in another client is not resurrected. -- **Completion markers are ordered by tombstones.** Markers are add/remove and - cannot be max-ordered, so each clear records `sid -> clearedAt` in - `hermes-session-completion-unread-cleared`, and a marker that does not postdate - its clear loses; a genuine later completion still wins. The tombstone map is - bounded by session existence and a 7-day retention cap, and is never part of the - marker map consumers read. +- **Completion markers are ordered by per-session tombstones.** Markers are + add/remove and cannot be max-ordered, so each clear records `clearedAt` under + `hermes-session-completion-unread-cleared:v1::`. Independent + immutable version keys mean clients clearing different sessions—or clearing the + same session in an interleaved operation—cannot replace newer ordering facts. A + marker that does not postdate its clear loses; a genuine later + completion still wins. During rolling upgrades, the previous unsuffixed + `hermes-session-completion-unread-cleared` map is both read and dual-written so + already-open clients on the prior revision observe new clears; it remains a + temporary compatibility store and is not pruned in this revision. Versioned + tombstones are bounded by session existence and a 7-day retention cap, and are + never part of the marker map consumers read. - **Cross-client repair, not cache invalidation.** The `storage` listener routes a - changed unread key back through that same merge instead of only dropping the - local cache. A client that still holds an acknowledgement re-asserts it after the - other client's write, the loser sees a value it cannot beat and stops, and repair - converges instead of ping-ponging storage events. -- **The contract is convergence, not atomicity.** Each save is a read-modify-write - across separate `localStorage` operations with no lock between them, so two - clients writing inside the same window can still lose one update; repair - re-asserts held acknowledgements on the next storage event. The case repair - cannot fix is two concurrent *clears*: the later whole-map write can discard the - other client's tombstone, after which a later marker save can re-add the stale - marker and the cleared dot returns. A hard cross-tab guarantee needs either a - lock around those writes (Web Locks, with a documented fallback for contexts - without it) or per-session versioned records. This contract deliberately claims - only the convergence described here. + changed unread key (including any per-session clear key) back through the same + merge instead of only dropping the local cache. A client that still holds an + acknowledgement re-asserts it after the other client's write, the loser sees a + value it cannot beat and stops, and repair converges instead of ping-ponging + storage events. +- **Whole-map facts converge; clear ordering does not share a map.** Viewed counts + and completion markers still use read-modify-write maps, so interleaved writes + can transiently lose an entry. Their monotonic cache merge and storage-event + repair re-assert held facts. Clear ordering is different: each session has its + own atomic `localStorage` write, so concurrent clears of different sessions + cannot clobber each other. A viewed-count advance records its clear even when a + competing client has prepared but not yet persisted the older completion + marker; repeated observations at the same count do not refresh the tombstone. ## Review Checklist diff --git a/static/sessions.js b/static/sessions.js index 96cc8179..ef5f1492 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -347,10 +347,11 @@ function _clearComposerDraft(sid, text, files) { const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts'; const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread'; -// Cleared completion-unread markers leave a tombstone (sid -> clearedAt epoch ms) -// so a stale client that still holds the marker cannot resurrect it. Ordering -// facts live here, never in the marker map consumers read. +// Clear ordering is stored per session so two clients clearing different +// sessions never replace one another's tombstones with whole-map writes. The +// unsuffixed key is the legacy map read during migration. const SESSION_COMPLETION_UNREAD_CLEARED_KEY = 'hermes-session-completion-unread-cleared'; +const SESSION_COMPLETION_UNREAD_CLEARED_PREFIX = `${SESSION_COMPLETION_UNREAD_CLEARED_KEY}:v1:`; const SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS = 7 * 24 * 60 * 60 * 1000; const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming'; // Per-profile session-count cache (issue #4717 / #4662 Phase 1.5). Records how @@ -579,14 +580,17 @@ function _setSessionViewedCount(sid, messageCount = 0) { if (!sid) return; const counts = _getSessionViewedCounts(); const next = Number.isFinite(messageCount) ? Number(messageCount) : 0; + const hadCount = Object.prototype.hasOwnProperty.call(counts, sid); + const previous = Number(counts[sid]) || 0; // A viewed count records "seen at least up to N messages" — keep it monotonic. // A transient lower count (e.g. a list snapshot mid-compaction) must not // resurrect a cleared unread dot before the save below. - counts[sid] = Math.max(Number(counts[sid]) || 0, next); + counts[sid] = Math.max(previous, next); _saveSessionViewedCounts(); - // If the viewed count is now current, any prior completion-unread marker is - // stale — clear it so _hasUnreadForSession doesn't short-circuit (#3020). - _clearSessionCompletionUnread(sid); + // Advancing the acknowledgement records a clear even if another client has + // only prepared (not persisted) its older completion marker. Repeated polling + // at the same count does not refresh the tombstone or emit storage events. + _clearSessionCompletionUnread(sid, !hadCount || next > previous); } function _getSessionCompletionUnread() { @@ -610,6 +614,42 @@ function _markerLosesToUnreadClear(marker, clearedAt) { return (Number(marker && marker.completed_at) || 0) <= cleared; } +function _sessionCompletionUnreadClearedKey(sid, clearedAt = null) { + const base = `${SESSION_COMPLETION_UNREAD_CLEARED_PREFIX}${encodeURIComponent(String(sid))}`; + return clearedAt == null ? base : `${base}:${Number(clearedAt) || 0}`; +} + +function _parseSessionCompletionUnreadClearedKey(key) { + if (!key || !key.startsWith(SESSION_COMPLETION_UNREAD_CLEARED_PREFIX)) return null; + const suffix = key.slice(SESSION_COMPLETION_UNREAD_CLEARED_PREFIX.length); + const separator = suffix.lastIndexOf(':'); + const encodedSid = separator > 0 ? suffix.slice(0, separator) : suffix; + const version = separator > 0 ? Number(suffix.slice(separator + 1)) || 0 : 0; + let sid = ''; + try { sid = decodeURIComponent(encodedSid); } catch (_) { return null; } + if (!sid) return null; + return { sid, version }; +} + +// Read the legacy whole-map key plus independent versioned clear records. A +// clear writes a new key rather than replacing an older operation, so readers +// can fold the maximum timestamp without a cross-context compare-and-set. +function _readSessionCompletionUnreadCleared() { + const cleared = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY); + try { + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i); + const parsed = _parseSessionCompletionUnreadClearedKey(key); + if (!parsed) continue; + const value = Math.max(parsed.version, Number(localStorage.getItem(key)) || 0); + if (value > (Number(cleared[parsed.sid]) || 0)) cleared[parsed.sid] = value; + } + } catch (_) { + // A storage implementation that cannot enumerate still retains legacy data. + } + return cleared; +} + // Merge the persisted marker map with our cache under the tombstones. Markers // the store holds that a clear already outranks are dropped from the result, so // the caller can write the repaired map back and no reader sees them again. @@ -646,7 +686,7 @@ function _saveSessionCompletionUnread() { const merged = _mergeSessionCompletionUnread( _readStoredJsonMap(SESSION_COMPLETION_UNREAD_KEY), _getSessionCompletionUnread(), - _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY) + _readSessionCompletionUnreadCleared() ); // Same convergence rule as the viewed counts: write only when the store does // not already hold the merged result. @@ -663,7 +703,15 @@ function _markSessionCompletionUnread(sid, messageCount = 0, meta = null) { if (!sid) return; const unread = _getSessionCompletionUnread(); const count = Number.isFinite(messageCount) ? Number(messageCount) : 0; - const entry = {message_count: count, completed_at: Date.now()}; + // If a clear and a genuine later completion share one millisecond, preserve + // event order rather than letting the clear's <= comparison erase the later + // marker. + const intendedAt = Date.now(); + const clearedAt = Number(_readSessionCompletionUnreadCleared()[sid]) || 0; + const completedAt = clearedAt && clearedAt <= intendedAt + ? Math.max(intendedAt, clearedAt + 1) + : intendedAt; + const entry = {message_count: count, completed_at: completedAt}; // Cron markers carry source+profile so profile switches can clear only that // cross-profile leak without wiping ordinary chat completion unread (#5960). if (meta && typeof meta === 'object' && !Array.isArray(meta)) { @@ -696,49 +744,76 @@ function _markSessionCompletionUnreadIfBackground(sid, messageCount = null, meta } // Record that the completion-unread marker(s) for `sidOrSids` were cleared at -// `clearedAt`. This is the ordering fact a merge needs: markers carry -// completed_at, so a marker that does not postdate the clear loses to it, while a -// completion that happened afterwards still wins. The tombstone store is bounded -// like the maps it guards: entries for sessions the list no longer shows are -// dropped, as are entries older than SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS. +// `clearedAt`. Each session owns a separate key: clears for different sessions +// are independent atomic writes and cannot discard one another. Markers carry a +// completed_at timestamp, so a marker that does not postdate the clear loses, +// while a completion that happened afterwards still wins. Old or deleted-session +// tombstones are pruned opportunistically to bound quota use. function _writeSessionCompletionUnreadCleared(sidOrSids, clearedAt) { - const sids = Array.isArray(sidOrSids) ? sidOrSids : [sidOrSids]; - const cleared = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY); - for (const sid of sids) cleared[sid] = clearedAt; - const justCleared = new Set(sids); - const listLoaded = _sessionListLoaded(); - for (const key of Object.keys(cleared)) { - if (clearedAt - (Number(cleared[key]) || 0) > SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS) { - delete cleared[key]; - continue; - } - // The tombstones just written stay even when their sessions are already - // gone: session deletion is exactly the case where the marker must not come - // back from a client that still holds it. - if (listLoaded && !justCleared.has(key) && !_sessionExistsForUnreadState(key)) { - delete cleared[key]; - } + const justCleared = new Set( + (Array.isArray(sidOrSids) ? sidOrSids : [sidOrSids]).map((sid) => String(sid)) + ); + const at = Number(clearedAt) || 0; + const legacyClears = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY); + for (const sid of justCleared) { + try { + localStorage.setItem(_sessionCompletionUnreadClearedKey(sid, at), String(at)); + } catch (_) {} + // Rolling compatibility: clients already open on the previous revision only + // understand the whole-map key. Publish new clears there too, while the + // immutable key remains authoritative for clients on this revision. + legacyClears[sid] = Math.max(Number(legacyClears[sid]) || 0, at); } + try { localStorage.setItem(SESSION_COMPLETION_UNREAD_CLEARED_KEY, JSON.stringify(legacyClears)); } catch (_) {} + + // Copy ordering facts from the old whole-map representation, but keep the + // legacy map until this PR no longer needs rolling compatibility. An + // already-open older client only understands that key; deleting it would let + // that client reassert stale markers. Keeping it also avoids a racy + // read/copy/remove migration losing a clear written by an older client. + for (const [sid, value] of Object.entries(legacyClears)) { + const version = Number(value) || 0; + if (!version) continue; + try { + localStorage.setItem( + _sessionCompletionUnreadClearedKey(sid, version), String(version)); + } catch (_) {} + } + + const cleared = _readSessionCompletionUnreadCleared(); + const listLoaded = _sessionListLoaded(); + let keys = []; try { - localStorage.setItem(SESSION_COMPLETION_UNREAD_CLEARED_KEY, JSON.stringify(cleared)); - } catch (_){ - // Ignore localStorage write failures; the marker map write below still runs. + keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i)); + } catch (_) {} + for (const key of keys) { + const parsed = _parseSessionCompletionUnreadClearedKey(key); + if (!parsed) continue; + try { + const version = Math.max(parsed.version, Number(localStorage.getItem(key)) || 0); + const expired = at - version > SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS; + const deleted = listLoaded && !justCleared.has(parsed.sid) + && !_sessionExistsForUnreadState(parsed.sid); + const superseded = version < (Number(cleared[parsed.sid]) || 0); + if (expired || deleted || superseded) localStorage.removeItem(key); + } catch (_) {} } } -function _clearSessionCompletionUnread(sid) { +function _clearSessionCompletionUnread(sid, recordIfMissing = true) { if (!sid) return; const unread = _getSessionCompletionUnread(); + let stored = false; if (!Object.prototype.hasOwnProperty.call(unread, sid)) { // Another client may have recorded this marker since our cache was loaded, // so the store decides whether there is anything to clear. - const stored = Object.prototype.hasOwnProperty.call( + stored = Object.prototype.hasOwnProperty.call( _readStoredJsonMap(SESSION_COMPLETION_UNREAD_KEY), sid); - if (!stored) return; + if (!stored && !recordIfMissing) return; } delete unread[sid]; - // Durable ordering fact, written before the marker map: a client that still - // holds this marker (completed_at not after the clear) loses to it. + // Write the ordering fact before the marker map. This is required even when + // the marker has been prepared by another client but has not reached storage. _writeSessionCompletionUnreadCleared(sid, Date.now()); _saveSessionCompletionUnread(); } @@ -9276,7 +9351,10 @@ function _handleUnreadStorageEvent(e){ // client re-asserts the acknowledgement it still holds instead of dropping it // and re-reading a value the other client's write already lost. if(e.key === SESSION_VIEWED_COUNTS_KEY) _saveSessionViewedCounts(); - else if(e.key === SESSION_COMPLETION_UNREAD_KEY || e.key === SESSION_COMPLETION_UNREAD_CLEARED_KEY){ + else if(e.key === SESSION_COMPLETION_UNREAD_KEY + || e.key === SESSION_COMPLETION_UNREAD_CLEARED_KEY + || (typeof e.key === 'string' + && e.key.startsWith(SESSION_COMPLETION_UNREAD_CLEARED_PREFIX))){ _saveSessionCompletionUnread(); } } diff --git a/tests/_unread_store_helpers.py b/tests/_unread_store_helpers.py index abe2e1ea..6837f69e 100644 --- a/tests/_unread_store_helpers.py +++ b/tests/_unread_store_helpers.py @@ -21,6 +21,9 @@ _HELPER_NAMES = ( "_sessionListLoaded", "_mergeSessionViewedCounts", "_markerLosesToUnreadClear", + "_sessionCompletionUnreadClearedKey", + "_parseSessionCompletionUnreadClearedKey", + "_readSessionCompletionUnreadCleared", "_mergeSessionCompletionUnread", "_writeSessionCompletionUnreadCleared", ) @@ -29,6 +32,7 @@ _HELPER_NAMES = ( # module uses. Harnesses keep their own stub keys for the two older stores. _CONST_NAMES = ( "SESSION_COMPLETION_UNREAD_CLEARED_KEY", + "SESSION_COMPLETION_UNREAD_CLEARED_PREFIX", "SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS", ) diff --git a/tests/test_issue5960_cron_unread_profile_scope.py b/tests/test_issue5960_cron_unread_profile_scope.py index 8c7b5c66..5dd51a94 100644 --- a/tests/test_issue5960_cron_unread_profile_scope.py +++ b/tests/test_issue5960_cron_unread_profile_scope.py @@ -181,6 +181,8 @@ global.localStorage={{ getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null, setItem:(key,value)=>{{ store[key]=String(value); }}, removeItem:(key)=>{{ delete store[key]; }}, + key:(i)=>Object.keys(store)[i]??null, + get length(){{ return Object.keys(store).length; }}, }}; let _sessionCompletionUnread=null; let _sessionViewedCounts={{}}; @@ -403,6 +405,8 @@ global.localStorage={{ getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null, setItem:(key,value)=>{{ store[key]=String(value); }}, removeItem:(key)=>{{ delete store[key]; }}, + key:(i)=>Object.keys(store)[i]??null, + get length(){{ return Object.keys(store).length; }}, }}; let _sessionCompletionUnread=null; let _sessionViewedCounts={{}}; @@ -468,6 +472,8 @@ global.localStorage={{ getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null, setItem:(key,value)=>{{ store[key]=String(value); }}, removeItem:(key)=>{{ delete store[key]; }}, + key:(i)=>Object.keys(store)[i]??null, + get length(){{ return Object.keys(store).length; }}, }}; let _sessionCompletionUnread=null; let _sessionViewedCounts={{}}; @@ -535,6 +541,8 @@ global.localStorage={{ getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null, setItem:(key,value)=>{{ store[key]=String(value); }}, removeItem:(key)=>{{ delete store[key]; }}, + key:(i)=>Object.keys(store)[i]??null, + get length(){{ return Object.keys(store).length; }}, }}; let _sessionCompletionUnread=null; let _sessionViewedCounts={{}}; @@ -614,6 +622,8 @@ global.localStorage={{ getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null, setItem:(key,value)=>{{ store[key]=String(value); }}, removeItem:(key)=>{{ delete store[key]; }}, + key:(i)=>Object.keys(store)[i]??null, + get length(){{ return Object.keys(store).length; }}, }}; let _sessionCompletionUnread=null; let _sessionViewedCounts={{}}; @@ -675,6 +685,8 @@ global.localStorage={{ getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null, setItem:(key,value)=>{{ store[key]=String(value); }}, removeItem:(key)=>{{ delete store[key]; }}, + key:(i)=>Object.keys(store)[i]??null, + get length(){{ return Object.keys(store).length; }}, }}; let _sessionCompletionUnread=null; let _sessionViewedCounts={{}}; @@ -803,6 +815,8 @@ global.localStorage={{ getItem:(key)=>Object.prototype.hasOwnProperty.call(store,key)?store[key]:null, setItem:(key,value)=>{{ store[key]=String(value); }}, removeItem:(key)=>{{ delete store[key]; }}, + key:(i)=>Object.keys(store)[i]??null, + get length(){{ return Object.keys(store).length; }}, }}; let _sessionCompletionUnread=null; let _sessionViewedCounts={{}}; diff --git a/tests/test_session_unread_dot_on_visit.py b/tests/test_session_unread_dot_on_visit.py index 6865922a..789062f9 100644 --- a/tests/test_session_unread_dot_on_visit.py +++ b/tests/test_session_unread_dot_on_visit.py @@ -182,6 +182,9 @@ const _store = {{}}; const localStorage = {{ getItem: (k) => (k in _store ? _store[k] : null), setItem: (k, v) => {{ _store[k] = String(v); }}, + removeItem: (k) => {{ delete _store[k]; }}, + key: (i) => Object.keys(_store)[i] ?? null, + get length() {{ return Object.keys(_store).length; }}, }}; const SESSION_VIEWED_COUNTS_KEY = 'v'; const SESSION_COMPLETION_UNREAD_KEY = 'u'; @@ -245,6 +248,9 @@ const _store = {{}}; const localStorage = {{ getItem: (k) => (k in _store ? _store[k] : null), setItem: (k, v) => {{ _store[k] = String(v); }}, + removeItem: (k) => {{ delete _store[k]; }}, + key: (i) => Object.keys(_store)[i] ?? null, + get length() {{ return Object.keys(_store).length; }}, }}; const SESSION_VIEWED_COUNTS_KEY = 'v'; const SESSION_COMPLETION_UNREAD_KEY = 'u'; @@ -343,6 +349,9 @@ const _store = {{}}; const localStorage = {{ getItem: (k) => (k in _store ? _store[k] : null), setItem: (k, v) => {{ _store[k] = String(v); }}, + removeItem: (k) => {{ delete _store[k]; }}, + key: (i) => Object.keys(_store)[i] ?? null, + get length() {{ return Object.keys(_store).length; }}, }}; const SESSION_VIEWED_COUNTS_KEY = 'v'; const SESSION_COMPLETION_UNREAD_KEY = 'u'; diff --git a/tests/test_unread_cross_client_sync.py b/tests/test_unread_cross_client_sync.py index e3e21b68..45077dd3 100644 --- a/tests/test_unread_cross_client_sync.py +++ b/tests/test_unread_cross_client_sync.py @@ -113,6 +113,9 @@ _HELPER_FNS = [ "_sessionListLoaded", "_mergeSessionViewedCounts", "_markerLosesToUnreadClear", + "_sessionCompletionUnreadClearedKey", + "_parseSessionCompletionUnreadClearedKey", + "_readSessionCompletionUnreadCleared", "_mergeSessionCompletionUnread", "_writeSessionCompletionUnreadCleared", ] @@ -132,10 +135,13 @@ const localStorage = {{ }}, setItem: (k, v) => {{ storageWrites += 1; _store[k] = String(v); }}, removeItem: (k) => {{ delete _store[k]; }}, + key: (i) => Object.keys(_store)[i] ?? null, + get length() {{ return Object.keys(_store).length; }}, }}; const SESSION_VIEWED_COUNTS_KEY = {VIEWED_KEY!r}; const SESSION_COMPLETION_UNREAD_KEY = {UNREAD_KEY!r}; const SESSION_COMPLETION_UNREAD_CLEARED_KEY = {CLEARED_KEY!r}; +const SESSION_COMPLETION_UNREAD_CLEARED_PREFIX = `${{SESSION_COMPLETION_UNREAD_CLEARED_KEY}}:v1:`; // Mirrors the module constant. The age assertions use hours vs 8 days so they // do not depend on the exact cap. const SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS = 7 * 24 * 60 * 60 * 1000; @@ -147,6 +153,10 @@ const _sessionListSnapshotById = new Map(); const listed = (...ids) => {{ _allSessions = ids.map((sid) => ({{session_id: sid}})); }}; const readDisk = (key) => JSON.parse(_store[key] || '{{}}'); const setDisk = (key, value) => {{ _store[key] = JSON.stringify(value); }}; +// Aggregate either the legacy whole map or independent per-session records. +const readAllClears = (client) => client && typeof client.clears === 'function' + ? client.clears() + : readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY); const _storageHandlers = []; const window = {{ addEventListener: (type, fn) => {{ @@ -183,7 +193,11 @@ function makeClient() {{ clearViewed: _clearSessionViewedCount, markUnread: _markSessionCompletionUnread, clearUnread: _clearSessionCompletionUnread, + writeClear: _writeSessionCompletionUnreadCleared, hasUnread: _hasSessionCompletionUnread, + clears: (typeof _readSessionCompletionUnreadCleared === 'function') + ? _readSessionCompletionUnreadCleared + : () => readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY), handleStorage: (typeof _handleUnreadStorageEvent === 'function') ? _handleUnreadStorageEvent : () => {{}}, @@ -454,6 +468,92 @@ console.log(JSON.stringify({ ) +def test_concurrent_clears_keep_both_ordering_facts(): + """Clearing different sessions in the same read/write window must not let + either whole-map write erase the other session's ordering fact.""" + out = _run_node(_script(""" +listed('X', 'Y'); +const A = makeClient(); +const B = makeClient(); +_now = 1000; +A.markUnread('X', 1); +B.markUnread('Y', 1); +A.hasUnread('Y'); +B.hasUnread('X'); +_now = 2000; +// A captures the old tombstone state; B's clear lands before A writes. +_onRead = () => { B.clearUnread('Y'); }; +A.clearUnread('X'); +const tombs = readAllClears(A); +console.log(JSON.stringify({ tombs })); +""")) + assert out["tombs"] == {"X": 2000, "Y": 2000}, ( + "concurrent clears for different sessions must retain both tombstones" + ) + + +def test_new_clear_is_also_published_for_rolling_clients(): + """An already-open client on the previous PR head must see a clear created + by the new implementation in the legacy whole-map key.""" + out = _run_node(_script(""" +const A = makeClient(); +A.writeClear('new', 2000); +console.log(JSON.stringify({ legacy: readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY), all: readAllClears(A) })); +""")) + assert out["legacy"] == {"new": 2000} + assert out["all"] == {"new": 2000} + + +def test_legacy_tombstone_map_is_kept_for_rolling_clients(): + """An already-open client on the previous PR head only understands the + whole-map key, so migration must not delete it during a rolling session.""" + out = _run_node(_script(""" +setDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY, { legacy: 1000 }); +const A = makeClient(); +A.writeClear('new', 2000); +console.log(JSON.stringify({ legacy: readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY), all: readAllClears(A) })); +""")) + assert out["legacy"] == {"legacy": 1000, "new": 2000} + assert out["all"] == {"legacy": 1000, "new": 2000} + + +def test_same_session_clear_versions_fold_to_the_newest_operation(): + """An older clear operation that lands after a newer one must not lower the + ordering fact for that session.""" + out = _run_node(_script(""" +listed('Y'); +const A = makeClient(); +A.writeClear('Y', 2000); +A.writeClear('Y', 1000); +console.log(JSON.stringify({ tombs: readAllClears(A) })); +""")) + assert out["tombs"] == {"Y": 2000} + + +def test_clear_orders_out_a_marker_prepared_but_not_yet_persisted(): + """A user visit after completion intent but before its delayed marker write + must record a clear even when neither its cache nor storage has the marker.""" + out = _run_node(_script(""" +listed('Y'); +const A = makeClient(); +const B = makeClient(); +A.hasUnread('Y'); +B.hasUnread('Y'); +_now = 1000; +// B's marker carries t=1000. Its save captures the empty marker map, then A +// visits at t=2000 before B's delayed write commits. +_onRead = () => { _now = 2000; A.clearUnread('Y'); }; +B.markUnread('Y', 3); +const marker = readDisk(SESSION_COMPLETION_UNREAD_KEY).Y || null; +const tombstone = readAllClears(A).Y || null; +console.log(JSON.stringify({ marker, tombstone })); +""")) + assert out["tombstone"] == 2000, ( + "a clear must persist its ordering fact even when the marker write is delayed" + ) + assert out["marker"] is None, "the completion that predates the visit must remain cleared" + + def test_tombstones_are_pruned_when_their_session_is_gone(): """Tombstones are keyed by session id; one whose session no longer exists must not accumulate.""" @@ -467,10 +567,11 @@ listed('Y'); _now = 2000; A.markUnread('Y', 1); A.clearUnread('Y'); -console.log(JSON.stringify({ tombs: readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY) })); +console.log(JSON.stringify({ tombs: readAllClears(A) })); """)) - assert out["tombs"] == {"Y": 2000}, ( - "only the tombstone for a session that still exists may be kept" + assert out["tombs"] == {"GONE": 1000, "Y": 2000}, ( + "the versioned tombstone may be pruned, while its legacy compatibility " + "fact remains available to already-open clients" ) @@ -486,21 +587,25 @@ const young = _now + 60 * 60 * 1000; _now = young; A.markUnread('Z', 1); A.clearUnread('Z'); -const keptYoung = readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY); +const keptYoung = readAllClears(A); _now = young + 8 * 24 * 60 * 60 * 1000; A.markUnread('Y', 1); A.clearUnread('Y'); console.log(JSON.stringify({ keptYoung: keptYoung, - afterAge: readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY), + afterAge: readAllClears(A), young: young, })); """)) assert out["keptYoung"] == {"Y": 1000, "Z": out["young"]}, ( "a tombstone younger than the cap must survive a newer clear" ) - assert out["afterAge"] == {"Y": out["young"] + 8 * 24 * 60 * 60 * 1000}, ( - "a tombstone older than the cap must be dropped" + assert out["afterAge"] == { + "Y": out["young"] + 8 * 24 * 60 * 60 * 1000, + "Z": out["young"], + }, ( + "the old versioned record is dropped, while the rollout compatibility " + "map remains available to older clients" ) @@ -529,12 +634,13 @@ const repairStable = storageWrites === writesAfterRepair; setDisk(SESSION_COMPLETION_UNREAD_KEY, { Q: { message_count: 2, completed_at: 5000 } }); const qStale = A.hasUnread('Q'); dispatch(SESSION_COMPLETION_UNREAD_KEY); -setDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY, { R: 9000 }); +const rClearKey = `${SESSION_COMPLETION_UNREAD_CLEARED_PREFIX}${encodeURIComponent('R')}:9000`; +_store[rClearKey] = '9000'; setDisk(SESSION_COMPLETION_UNREAD_KEY, { Q: { message_count: 2, completed_at: 5000 }, R: { message_count: 3, completed_at: 8000 }, }); -dispatch(SESSION_COMPLETION_UNREAD_CLEARED_KEY); +dispatch(rClearKey); const disk = readDisk(SESSION_COMPLETION_UNREAD_KEY); console.log(JSON.stringify({ handlers: _storageHandlers.length, From 221a241e61c3525d53a1208bdf80606f70551118 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 15 Sep 2026 18:25:39 +0300 Subject: [PATCH 044/446] test: expose same-tick unread ordering races --- tests/test_unread_cross_client_sync.py | 44 ++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_unread_cross_client_sync.py b/tests/test_unread_cross_client_sync.py index 45077dd3..9b702fa6 100644 --- a/tests/test_unread_cross_client_sync.py +++ b/tests/test_unread_cross_client_sync.py @@ -432,6 +432,50 @@ console.log(JSON.stringify({ ) +def test_later_clear_wins_after_same_millisecond_re_mark(): + """A clear must advance from the marker's logical order, not reuse the wall + clock tick and lose to the marker's synthetic ``clearedAt + 1`` timestamp.""" + out = _run_node(_script(""" +listed('Y'); +const A = makeClient(); +_now = 1000; +A.clearUnread('Y'); +A.markUnread('Y', 3); +const marked = A.hasUnread('Y'); +// The user visits again before the millisecond clock advances. +A.clearUnread('Y'); +console.log(JSON.stringify({ + marked, + final: A.hasUnread('Y'), + disk: readDisk(SESSION_COMPLETION_UNREAD_KEY), +})); +""")) + assert out["marked"] is True, "precondition: the same-ms re-mark must be visible" + assert out["final"] is False + assert out["disk"] == {}, "the later clear must remove the same-ms marker from disk" + + +def test_same_millisecond_clear_beats_marker_prepared_before_it(): + """A marker whose intent was prepared before a concurrent clear must lose, + even when both operations observe the same wall-clock millisecond.""" + out = _run_node(_script(""" +listed('Y'); +const A = makeClient(); +const B = makeClient(); +A.hasUnread('Y'); +B.hasUnread('Y'); +_now = 1000; +_onRead = () => { A.clearUnread('Y'); }; +B.markUnread('Y', 3); +console.log(JSON.stringify({ + marker: readDisk(SESSION_COMPLETION_UNREAD_KEY).Y || null, + visible: B.hasUnread('Y'), +})); +""")) + assert out["marker"] is None + assert out["visible"] is False + + def test_repair_removes_a_marker_that_lost_to_a_clear(): """A stale client's write can land after the clear. The repair must drop the losing marker from the store, or the dot returns on every client.""" From d32e558a8b5d8170bec3076bec9c976043574baa Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 15 Sep 2026 18:28:38 +0300 Subject: [PATCH 045/446] fix: order unread transitions logically within a clock tick --- static/sessions.js | 51 +++++++++++++++++++------- tests/_unread_store_helpers.py | 2 + tests/test_unread_cross_client_sync.py | 20 ++++++---- 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index ef5f1492..53cfcc91 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -611,7 +611,11 @@ function _getSessionCompletionUnread() { function _markerLosesToUnreadClear(marker, clearedAt) { const cleared = Number(clearedAt) || 0; if (!cleared) return false; - return (Number(marker && marker.completed_at) || 0) <= cleared; + return _sessionCompletionUnreadOrder(marker) <= cleared; +} + +function _sessionCompletionUnreadOrder(marker) { + return Number(marker && (marker.unread_order || marker.completed_at)) || 0; } function _sessionCompletionUnreadClearedKey(sid, clearedAt = null) { @@ -635,10 +639,17 @@ function _parseSessionCompletionUnreadClearedKey(key) { // clear writes a new key rather than replacing an older operation, so readers // can fold the maximum timestamp without a cross-context compare-and-set. function _readSessionCompletionUnreadCleared() { + // Snapshot keys before values. A clear that starts after this operation began + // is deliberately excluded, so a marker prepared first gets the same logical + // order and loses the clear-wins tie. A marker started after a completed clear + // observes that clear and advances beyond it. + let keys = []; + try { + keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i)); + } catch (_) {} const cleared = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY); try { - for (let i = 0; i < localStorage.length; i += 1) { - const key = localStorage.key(i); + for (const key of keys) { const parsed = _parseSessionCompletionUnreadClearedKey(key); if (!parsed) continue; const value = Math.max(parsed.version, Number(localStorage.getItem(key)) || 0); @@ -650,6 +661,18 @@ function _readSessionCompletionUnreadCleared() { return cleared; } +function _nextSessionCompletionUnreadOrder(sid, cleared = null) { + const clears = cleared || _readSessionCompletionUnreadCleared(); + const cached = _getSessionCompletionUnread()[sid]; + const stored = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_KEY)[sid]; + return Math.max( + Date.now(), + Number(clears[sid]) || 0, + _sessionCompletionUnreadOrder(cached), + _sessionCompletionUnreadOrder(stored) + ) + 1; +} + // Merge the persisted marker map with our cache under the tombstones. Markers // the store holds that a clear already outranks are dropped from the result, so // the caller can write the repaired map back and no reader sees them again. @@ -703,15 +726,12 @@ function _markSessionCompletionUnread(sid, messageCount = 0, meta = null) { if (!sid) return; const unread = _getSessionCompletionUnread(); const count = Number.isFinite(messageCount) ? Number(messageCount) : 0; - // If a clear and a genuine later completion share one millisecond, preserve - // event order rather than letting the clear's <= comparison erase the later - // marker. - const intendedAt = Date.now(); - const clearedAt = Number(_readSessionCompletionUnreadCleared()[sid]) || 0; - const completedAt = clearedAt && clearedAt <= intendedAt - ? Math.max(intendedAt, clearedAt + 1) - : intendedAt; - const entry = {message_count: count, completed_at: completedAt}; + const clearsAtIntent = _readSessionCompletionUnreadCleared(); + const entry = { + message_count: count, + completed_at: Date.now(), + unread_order: _nextSessionCompletionUnreadOrder(sid, clearsAtIntent), + }; // Cron markers carry source+profile so profile switches can clear only that // cross-profile leak without wiping ordinary chat completion unread (#5960). if (meta && typeof meta === 'object' && !Array.isArray(meta)) { @@ -811,10 +831,11 @@ function _clearSessionCompletionUnread(sid, recordIfMissing = true) { _readStoredJsonMap(SESSION_COMPLETION_UNREAD_KEY), sid); if (!stored && !recordIfMissing) return; } + const order = _nextSessionCompletionUnreadOrder(sid); delete unread[sid]; // Write the ordering fact before the marker map. This is required even when // the marker has been prepared by another client but has not reached storage. - _writeSessionCompletionUnreadCleared(sid, Date.now()); + _writeSessionCompletionUnreadCleared(sid, order); _saveSessionCompletionUnread(); } @@ -944,7 +965,9 @@ function _clearCronSessionCompletionUnreadForInactiveProfiles(activeProfile) { // Each cleared marker needs its durable ordering fact, or the merge in // _saveSessionCompletionUnread() (and a stale client's later save) would // restore it from the store. - _writeSessionCompletionUnreadCleared(clearedSids, Date.now()); + for (const sid of clearedSids) { + _writeSessionCompletionUnreadCleared(sid, _nextSessionCompletionUnreadOrder(sid)); + } _saveSessionCompletionUnread(); if (typeof renderSessionListFromCache === 'function') renderSessionListFromCache(); return true; diff --git a/tests/_unread_store_helpers.py b/tests/_unread_store_helpers.py index 6837f69e..c59788c1 100644 --- a/tests/_unread_store_helpers.py +++ b/tests/_unread_store_helpers.py @@ -21,6 +21,8 @@ _HELPER_NAMES = ( "_sessionListLoaded", "_mergeSessionViewedCounts", "_markerLosesToUnreadClear", + "_sessionCompletionUnreadOrder", + "_nextSessionCompletionUnreadOrder", "_sessionCompletionUnreadClearedKey", "_parseSessionCompletionUnreadClearedKey", "_readSessionCompletionUnreadCleared", diff --git a/tests/test_unread_cross_client_sync.py b/tests/test_unread_cross_client_sync.py index 9b702fa6..03f2c399 100644 --- a/tests/test_unread_cross_client_sync.py +++ b/tests/test_unread_cross_client_sync.py @@ -113,6 +113,8 @@ _HELPER_FNS = [ "_sessionListLoaded", "_mergeSessionViewedCounts", "_markerLosesToUnreadClear", + "_sessionCompletionUnreadOrder", + "_nextSessionCompletionUnreadOrder", "_sessionCompletionUnreadClearedKey", "_parseSessionCompletionUnreadClearedKey", "_readSessionCompletionUnreadCleared", @@ -424,7 +426,11 @@ console.log(JSON.stringify({ bView: B.hasUnread('Y'), })); """)) - assert out["marker"] == {"message_count": 9, "completed_at": 3000}, ( + assert out["marker"] == { + "message_count": 9, + "completed_at": 3000, + "unread_order": 3001, + }, ( "a completion that postdates the clear must be kept" ) assert out["aView"] is True and out["bView"] is True, ( @@ -531,7 +537,7 @@ A.clearUnread('X'); const tombs = readAllClears(A); console.log(JSON.stringify({ tombs })); """)) - assert out["tombs"] == {"X": 2000, "Y": 2000}, ( + assert out["tombs"] == {"X": 2001, "Y": 2001}, ( "concurrent clears for different sessions must retain both tombstones" ) @@ -592,7 +598,7 @@ const marker = readDisk(SESSION_COMPLETION_UNREAD_KEY).Y || null; const tombstone = readAllClears(A).Y || null; console.log(JSON.stringify({ marker, tombstone })); """)) - assert out["tombstone"] == 2000, ( + assert out["tombstone"] == 2001, ( "a clear must persist its ordering fact even when the marker write is delayed" ) assert out["marker"] is None, "the completion that predates the visit must remain cleared" @@ -613,7 +619,7 @@ A.markUnread('Y', 1); A.clearUnread('Y'); console.log(JSON.stringify({ tombs: readAllClears(A) })); """)) - assert out["tombs"] == {"GONE": 1000, "Y": 2000}, ( + assert out["tombs"] == {"GONE": 1002, "Y": 2002}, ( "the versioned tombstone may be pruned, while its legacy compatibility " "fact remains available to already-open clients" ) @@ -641,12 +647,12 @@ console.log(JSON.stringify({ young: young, })); """)) - assert out["keptYoung"] == {"Y": 1000, "Z": out["young"]}, ( + assert out["keptYoung"] == {"Y": 1002, "Z": out["young"] + 2}, ( "a tombstone younger than the cap must survive a newer clear" ) assert out["afterAge"] == { - "Y": out["young"] + 8 * 24 * 60 * 60 * 1000, - "Z": out["young"], + "Y": out["young"] + 8 * 24 * 60 * 60 * 1000 + 2, + "Z": out["young"] + 2, }, ( "the old versioned record is dropped, while the rollout compatibility " "map remains available to older clients" From 1fd8d6fe2f402376c02b866d0d320806fce75eb0 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 15 Sep 2026 18:29:19 +0300 Subject: [PATCH 046/446] test: require bounded one-way unread clear migration --- tests/test_unread_cross_client_sync.py | 37 +++++++++++++------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/tests/test_unread_cross_client_sync.py b/tests/test_unread_cross_client_sync.py index 03f2c399..722fdebe 100644 --- a/tests/test_unread_cross_client_sync.py +++ b/tests/test_unread_cross_client_sync.py @@ -542,28 +542,34 @@ console.log(JSON.stringify({ tombs })); ) -def test_new_clear_is_also_published_for_rolling_clients(): - """An already-open client on the previous PR head must see a clear created - by the new implementation in the legacy whole-map key.""" +def test_new_clear_does_not_dual_write_the_legacy_whole_map(): + """The independently addressable representation is authoritative. Keeping + dual-write compatibility would retain the old lost-update race forever.""" out = _run_node(_script(""" const A = makeClient(); A.writeClear('new', 2000); -console.log(JSON.stringify({ legacy: readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY), all: readAllClears(A) })); +console.log(JSON.stringify({ + legacyPresent: Object.prototype.hasOwnProperty.call(_store, SESSION_COMPLETION_UNREAD_CLEARED_KEY), + all: readAllClears(A), +})); """)) - assert out["legacy"] == {"new": 2000} + assert out["legacyPresent"] is False assert out["all"] == {"new": 2000} -def test_legacy_tombstone_map_is_kept_for_rolling_clients(): - """An already-open client on the previous PR head only understands the - whole-map key, so migration must not delete it during a rolling session.""" +def test_legacy_tombstone_map_is_migrated_then_removed(): + """A reloaded client imports existing clear facts once, then removes the + unbounded whole map instead of pretending concurrent old/new writes are safe.""" out = _run_node(_script(""" setDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY, { legacy: 1000 }); const A = makeClient(); A.writeClear('new', 2000); -console.log(JSON.stringify({ legacy: readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY), all: readAllClears(A) })); +console.log(JSON.stringify({ + legacyPresent: Object.prototype.hasOwnProperty.call(_store, SESSION_COMPLETION_UNREAD_CLEARED_KEY), + all: readAllClears(A), +})); """)) - assert out["legacy"] == {"legacy": 1000, "new": 2000} + assert out["legacyPresent"] is False assert out["all"] == {"legacy": 1000, "new": 2000} @@ -619,10 +625,7 @@ A.markUnread('Y', 1); A.clearUnread('Y'); console.log(JSON.stringify({ tombs: readAllClears(A) })); """)) - assert out["tombs"] == {"GONE": 1002, "Y": 2002}, ( - "the versioned tombstone may be pruned, while its legacy compatibility " - "fact remains available to already-open clients" - ) + assert out["tombs"] == {"Y": 2002} def test_tombstones_are_capped_by_age(): @@ -652,11 +655,7 @@ console.log(JSON.stringify({ ) assert out["afterAge"] == { "Y": out["young"] + 8 * 24 * 60 * 60 * 1000 + 2, - "Z": out["young"] + 2, - }, ( - "the old versioned record is dropped, while the rollout compatibility " - "map remains available to older clients" - ) + }, "a tombstone older than the cap must be removed from storage" # ── The registration itself ────────────────────────────────────────────────── From 6d0a59da1012e18cbb0f192e8b847a4b4822bc41 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 15 Sep 2026 18:36:37 +0300 Subject: [PATCH 047/446] fix(sessions): bound unread clear storage and stop list-based pruning Two defects share one cause: a fact was being discarded because a session was missing from the visible list. The sidebar filters by profile, project, and source, so an absent row may simply be hidden. Clear records were pruned on session existence, which deleted the ordering fact for any session outside the current filter and re-lit a dot the user had cleared. They are now pruned by age and supersession only. The same reasoning rules out pruning stored viewed counts by list membership, so a deletion records its own key instead. A client that still caches the acknowledgement now prunes it rather than writing it back. The previous clear representation is imported once and then removed. Keeping it would preserve both of its defects: concurrent clears could replace one another in the shared blob, and it grew with every session ever cleared. --- .../webui-run-state-consistency-contract.md | 49 ++++--- static/sessions.js | 124 ++++++++++++++---- tests/_unread_store_helpers.py | 5 + tests/test_unread_cross_client_sync.py | 66 +++++++++- 4 files changed, 199 insertions(+), 45 deletions(-) diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index d15a4a20..a822d3b2 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -170,22 +170,39 @@ writes. - **Viewed counts merge by maximum.** The fact is monotonic per session and additive across clients, so a save reads the store, merges its cache in with a - max, and writes only when the store does not already hold the merged result. A - count held only in a client's cache is written back only for a session the - sidebar list still shows, so a session deleted in another client is not - resurrected. -- **Completion markers are ordered by per-session tombstones.** Markers are - add/remove and cannot be max-ordered, so each clear records `clearedAt` under - `hermes-session-completion-unread-cleared:v1::`. Independent - immutable version keys mean clients clearing different sessions—or clearing the - same session in an interleaved operation—cannot replace newer ordering facts. A - marker that does not postdate its clear loses; a genuine later - completion still wins. During rolling upgrades, the previous unsuffixed - `hermes-session-completion-unread-cleared` map is both read and dual-written so - already-open clients on the prior revision observe new clears; it remains a - temporary compatibility store and is not pruned in this revision. Versioned - tombstones are bounded by session existence and a 7-day retention cap, and are - never part of the marker map consumers read. + max, and writes only when the store does not already hold the merged result. + A deletion records its own key under + `hermes-session-viewed-counts:deleted:v1:`; merges drop any count + whose session has a live deletion record, so a client that still caches the + acknowledgement prunes it instead of writing it back. List membership cannot + decide this, because the sidebar filters by profile, project, and source, so an + absent row is not evidence of deletion. Deletion records expire on the same + 7-day policy as clear records. +- **Completion markers are ordered by logical stamps, not wall clock.** + Markers are add/remove and cannot be max-ordered, so each clear records a stamp + under `hermes-session-completion-unread-cleared:v1::`. + Independent immutable keys mean clients clearing different sessions—or clearing + the same session in an interleaved operation—cannot replace newer ordering + facts, and a reader folds the maximum per session. Markers carry + `unread_order`: the greatest stamp the marker's creator had observed (its own + clear state, its cached and stored markers) plus one. A marker whose stamp does + not exceed its session's clear stamp loses, so a clear wins the tie when a + marker was prepared before it but written after; a completion that happens after + the clear observes it and stamps higher, so it still wins. Milliseconds are not + used for ordering: a clear and a genuine later completion can share one tick, and + a single observed clock cannot order them. +- **The previous clear representation is migrated once, then dropped.** The + unsuffixed `hermes-session-completion-unread-cleared` whole map is read, its + facts are imported as independent records, and the key is removed. Keeping it + would retain both of the defects it caused: concurrent clears could replace one + another in the shared blob, and the blob grew with every session ever cleared. + A client still running the previous revision therefore does not observe clears + recorded after the migration; that reload boundary is deliberate, because a + dual write cannot make the shared blob concurrency-safe. Versioned records are + pruned by age (7 days) and supersession only — never by session existence, + because the sidebar list is filtered by profile, project, and source, so an + absent row may simply be hidden. They are never part of the marker map + consumers read. - **Cross-client repair, not cache invalidation.** The `storage` listener routes a changed unread key (including any per-session clear key) back through the same merge instead of only dropping the local cache. A client that still holds an diff --git a/static/sessions.js b/static/sessions.js index 53cfcc91..f3bea7d6 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -346,6 +346,13 @@ function _clearComposerDraft(sid, text, files) { } const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts'; +// A deleted session's acknowledgement must not return from a stale client's +// cache. Membership in the rendered list cannot decide this: the sidebar filters +// by profile, project, and source, so an absent row is not evidence of deletion. +// Each deletion therefore records its own key, and merges drop any entry whose +// session has one. Records are pruned by age so the store stays bounded. +const SESSION_VIEWED_COUNTS_DELETED_PREFIX = 'hermes-session-viewed-counts:deleted:v1:'; +const SESSION_VIEWED_COUNTS_DELETED_TTL_MS = 7 * 24 * 60 * 60 * 1000; const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread'; // Clear ordering is stored per session so two clients clearing different // sessions never replace one another's tombstones with whole-map writes. The @@ -530,10 +537,19 @@ function _sessionListLoaded() { // is always kept in the live view regardless: dropping it here would lose the // acknowledgement for a session that still exists. function _mergeSessionViewedCounts(disk, cache) { + const deleted = _readSessionViewedCountDeletions(); + const gone = (sid) => Object.prototype.hasOwnProperty.call(deleted, sid); const counts = {}; - for (const sid of Object.keys(disk || {})) counts[sid] = Number(disk[sid]) || 0; let changed = false; + for (const sid of Object.keys(disk || {})) { + if (gone(sid)) { + changed = true; + continue; + } + counts[sid] = Number(disk[sid]) || 0; + } for (const sid of Object.keys(cache || {})) { + if (gone(sid)) continue; const ours = Number(cache[sid]) || 0; if (Object.prototype.hasOwnProperty.call(counts, sid)) { if (ours > counts[sid]) { @@ -550,6 +566,7 @@ function _mergeSessionViewedCounts(disk, cache) { const live = {}; for (const sid of Object.keys(counts)) live[sid] = counts[sid]; for (const sid of Object.keys(cache || {})) { + if (gone(sid)) continue; const ours = Number(cache[sid]) || 0; if (ours > (Object.prototype.hasOwnProperty.call(live, sid) ? live[sid] : 0)) { live[sid] = ours; @@ -558,6 +575,53 @@ function _mergeSessionViewedCounts(disk, cache) { return {counts, live, changed}; } +function _sessionViewedCountDeletedKey(sid) { + return `${SESSION_VIEWED_COUNTS_DELETED_PREFIX}${encodeURIComponent(String(sid))}`; +} + +// Read the deletion records, dropping any that have aged out. +function _readSessionViewedCountDeletions() { + const deleted = {}; + let keys = []; + try { + keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i)); + } catch (_) { + return deleted; + } + const now = Date.now(); + for (const key of keys) { + if (typeof key !== 'string' || !key.startsWith(SESSION_VIEWED_COUNTS_DELETED_PREFIX)) continue; + const encoded = key.slice(SESSION_VIEWED_COUNTS_DELETED_PREFIX.length); + let sid = ''; + try { + sid = decodeURIComponent(encoded); + } catch (_) { + continue; + } + if (!sid) continue; + let stamp = 0; + try { + stamp = Number(localStorage.getItem(key)) || 0; + } catch (_) {} + if (stamp && now - stamp > SESSION_VIEWED_COUNTS_DELETED_TTL_MS) { + try { localStorage.removeItem(key); } catch (_) {} + continue; + } + deleted[sid] = stamp; + } + return deleted; +} + +function _recordSessionViewedCountDeleted(sid) { + if (!sid) return; + try { + localStorage.setItem(_sessionViewedCountDeletedKey(sid), String(Date.now())); + } catch (_) { + // Without the record the deletion still removed the entry; the next delete + // or merge attempt records it again. + } +} + function _saveSessionViewedCounts() { try { const merged = _mergeSessionViewedCounts( @@ -774,34 +838,35 @@ function _writeSessionCompletionUnreadCleared(sidOrSids, clearedAt) { (Array.isArray(sidOrSids) ? sidOrSids : [sidOrSids]).map((sid) => String(sid)) ); const at = Number(clearedAt) || 0; - const legacyClears = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY); + + // One-way migration. The previous representation kept every clear fact in one + // shared blob, so concurrent clears could replace one another and the blob + // only ever grew. Import those facts as independently addressable records and + // drop the blob. A client still running the previous revision will not observe + // clears recorded after this point; that is a reload boundary, not something a + // dual write could have fixed, because the shared blob cannot be written + // concurrently without losing an update. + const legacy = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY); + let migrated = true; + for (const [sid, value] of Object.entries(legacy)) { + const stamp = Number(value) || 0; + if (!stamp) continue; + try { + localStorage.setItem(_sessionCompletionUnreadClearedKey(sid, stamp), String(stamp)); + } catch (_) { + migrated = false; + } + } for (const sid of justCleared) { try { localStorage.setItem(_sessionCompletionUnreadClearedKey(sid, at), String(at)); } catch (_) {} - // Rolling compatibility: clients already open on the previous revision only - // understand the whole-map key. Publish new clears there too, while the - // immutable key remains authoritative for clients on this revision. - legacyClears[sid] = Math.max(Number(legacyClears[sid]) || 0, at); } - try { localStorage.setItem(SESSION_COMPLETION_UNREAD_CLEARED_KEY, JSON.stringify(legacyClears)); } catch (_) {} - - // Copy ordering facts from the old whole-map representation, but keep the - // legacy map until this PR no longer needs rolling compatibility. An - // already-open older client only understands that key; deleting it would let - // that client reassert stale markers. Keeping it also avoids a racy - // read/copy/remove migration losing a clear written by an older client. - for (const [sid, value] of Object.entries(legacyClears)) { - const version = Number(value) || 0; - if (!version) continue; - try { - localStorage.setItem( - _sessionCompletionUnreadClearedKey(sid, version), String(version)); - } catch (_) {} + if (Object.keys(legacy).length && migrated) { + try { localStorage.removeItem(SESSION_COMPLETION_UNREAD_CLEARED_KEY); } catch (_) {} } const cleared = _readSessionCompletionUnreadCleared(); - const listLoaded = _sessionListLoaded(); let keys = []; try { keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i)); @@ -812,10 +877,12 @@ function _writeSessionCompletionUnreadCleared(sidOrSids, clearedAt) { try { const version = Math.max(parsed.version, Number(localStorage.getItem(key)) || 0); const expired = at - version > SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS; - const deleted = listLoaded && !justCleared.has(parsed.sid) - && !_sessionExistsForUnreadState(parsed.sid); const superseded = version < (Number(cleared[parsed.sid]) || 0); - if (expired || deleted || superseded) localStorage.removeItem(key); + // Records are pruned by age and supersession only. Session existence is + // not a pruning signal: the sidebar list is filtered by profile, project, + // and source, so a session missing from it may simply be hidden, and + // dropping its clear record would re-light a cleared dot. + if (expired || superseded) localStorage.removeItem(key); } catch (_) {} } } @@ -978,6 +1045,10 @@ function _clearSessionViewedCount(sid) { const counts = _getSessionViewedCounts(); const hadCachedEntry = Object.prototype.hasOwnProperty.call(counts, sid); delete counts[sid]; + // Record the deletion before any merge, so this client cannot re-adopt the + // entry it is deleting and another client holding the same acknowledgement + // prunes it instead of writing it back. + _recordSessionViewedCountDeleted(sid); // A removal must actually leave disk, unlike the additive max-merge used on // the set path (which never deletes). Delete just this key from the persisted // map so entries a concurrent client added are preserved. The store is @@ -9373,7 +9444,10 @@ function _handleUnreadStorageEvent(e){ // as the live cache, and write back only what the store is missing. So this // client re-asserts the acknowledgement it still holds instead of dropping it // and re-reading a value the other client's write already lost. - if(e.key === SESSION_VIEWED_COUNTS_KEY) _saveSessionViewedCounts(); + if(e.key === SESSION_VIEWED_COUNTS_KEY + || (typeof e.key === 'string' && e.key.startsWith(SESSION_VIEWED_COUNTS_DELETED_PREFIX))){ + _saveSessionViewedCounts(); + } else if(e.key === SESSION_COMPLETION_UNREAD_KEY || e.key === SESSION_COMPLETION_UNREAD_CLEARED_KEY || (typeof e.key === 'string' diff --git a/tests/_unread_store_helpers.py b/tests/_unread_store_helpers.py index c59788c1..93ba4ed6 100644 --- a/tests/_unread_store_helpers.py +++ b/tests/_unread_store_helpers.py @@ -20,6 +20,9 @@ _HELPER_NAMES = ( "_sessionExistsForUnreadState", "_sessionListLoaded", "_mergeSessionViewedCounts", + "_sessionViewedCountDeletedKey", + "_readSessionViewedCountDeletions", + "_recordSessionViewedCountDeleted", "_markerLosesToUnreadClear", "_sessionCompletionUnreadOrder", "_nextSessionCompletionUnreadOrder", @@ -33,6 +36,8 @@ _HELPER_NAMES = ( # Declared from the module source so a harness cannot drift from the values the # module uses. Harnesses keep their own stub keys for the two older stores. _CONST_NAMES = ( + "SESSION_VIEWED_COUNTS_DELETED_PREFIX", + "SESSION_VIEWED_COUNTS_DELETED_TTL_MS", "SESSION_COMPLETION_UNREAD_CLEARED_KEY", "SESSION_COMPLETION_UNREAD_CLEARED_PREFIX", "SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS", diff --git a/tests/test_unread_cross_client_sync.py b/tests/test_unread_cross_client_sync.py index 722fdebe..743b7d26 100644 --- a/tests/test_unread_cross_client_sync.py +++ b/tests/test_unread_cross_client_sync.py @@ -112,6 +112,9 @@ _HELPER_FNS = [ "_sessionExistsForUnreadState", "_sessionListLoaded", "_mergeSessionViewedCounts", + "_sessionViewedCountDeletedKey", + "_readSessionViewedCountDeletions", + "_recordSessionViewedCountDeleted", "_markerLosesToUnreadClear", "_sessionCompletionUnreadOrder", "_nextSessionCompletionUnreadOrder", @@ -141,6 +144,10 @@ const localStorage = {{ get length() {{ return Object.keys(_store).length; }}, }}; const SESSION_VIEWED_COUNTS_KEY = {VIEWED_KEY!r}; +const SESSION_VIEWED_COUNTS_DELETED_PREFIX = `${{SESSION_VIEWED_COUNTS_KEY}}:deleted:v1:`; +const SESSION_VIEWED_COUNTS_DELETED_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const readViewedDeletions = () => + Object.keys(_store).filter((k) => k.startsWith(SESSION_VIEWED_COUNTS_DELETED_PREFIX)); const SESSION_COMPLETION_UNREAD_KEY = {UNREAD_KEY!r}; const SESSION_COMPLETION_UNREAD_CLEARED_KEY = {CLEARED_KEY!r}; const SESSION_COMPLETION_UNREAD_CLEARED_PREFIX = `${{SESSION_COMPLETION_UNREAD_CLEARED_KEY}}:v1:`; @@ -203,6 +210,9 @@ function makeClient() {{ handleStorage: (typeof _handleUnreadStorageEvent === 'function') ? _handleUnreadStorageEvent : () => {{}}, + deletions: (typeof _readSessionViewedCountDeletions === 'function') + ? _readSessionViewedCountDeletions + : () => ({{}}), }}; }} """ @@ -375,6 +385,53 @@ console.log(JSON.stringify({ # ── Completion unread: the later event wins ────────────────────────────────── +def test_stale_client_cannot_resurrect_a_deleted_sessions_count(): + """Deleting a session records its own fact, so a client that still caches the + acknowledgement prunes it instead of writing it back — list membership cannot + decide this, because the sidebar filters by profile, project, and source.""" + out = _run_node(_script(""" +listed('S', 'T'); +const A = makeClient(); +const B = makeClient(); +A.setViewed('S', 5); +B.viewed('S'); +A.clearViewed('S'); +B.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY }); +B.setViewed('T', 1); +console.log(JSON.stringify({ + disk: readDisk(SESSION_VIEWED_COUNTS_KEY), + deletions: readViewedDeletions().length, + bView: B.viewed('S'), +})); +""")) + assert out["disk"] == {"T": 1}, ( + "a deleted session's count must not come back from a stale client" + ) + assert out["deletions"] == 1 + assert out["bView"] is None + + +def test_viewed_count_deletion_records_are_pruned_by_age(): + """The deletion records are bounded like the other stores: a later merge + drops one that has aged out.""" + out = _run_node(_script(""" +listed('S', 'X'); +const A = makeClient(); +const young = (() => { A.clearViewed('S'); return readViewedDeletions().length; })(); +const keptYoung = (() => { A.setViewed('X', 2); return readViewedDeletions().length; })(); +_now = 1000 + 8 * 24 * 60 * 60 * 1000; +A.setViewed('X', 3); +console.log(JSON.stringify({ + young: young, + keptYoung: keptYoung, + afterAge: readViewedDeletions().length, +})); +""")) + assert out["young"] == 1 + assert out["keptYoung"] == 1, "a fresh deletion record must survive an unrelated merge" + assert out["afterAge"] == 0, "an expired deletion record must be pruned" + + def test_stale_client_cannot_resurrect_a_cleared_completion_marker(): """The field symptom: a marker cleared by opening the chat must not come back when another client that still holds it saves later.""" @@ -610,9 +667,10 @@ console.log(JSON.stringify({ marker, tombstone })); assert out["marker"] is None, "the completion that predates the visit must remain cleared" -def test_tombstones_are_pruned_when_their_session_is_gone(): - """Tombstones are keyed by session id; one whose session no longer exists - must not accumulate.""" +def test_clear_records_survive_a_session_missing_from_the_list(): + """A session absent from the visible list is not proof of deletion: the list + is filtered by profile, project, and source. Dropping its clear record would + re-light a dot the user already cleared, so age is the only pruning signal.""" out = _run_node(_script(""" listed('Y', 'GONE'); const A = makeClient(); @@ -625,7 +683,7 @@ A.markUnread('Y', 1); A.clearUnread('Y'); console.log(JSON.stringify({ tombs: readAllClears(A) })); """)) - assert out["tombs"] == {"Y": 2002} + assert out["tombs"] == {"GONE": 1002, "Y": 2002} def test_tombstones_are_capped_by_age(): From ae0a798f132cb91e36d8a7c6c8a930125fd7345f Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 15 Sep 2026 18:44:26 +0300 Subject: [PATCH 048/446] test(storage): widen the viewed-count clear slice to cover the function The assertion slices a fixed 1600 characters from the start of _clearSessionViewedCount, so growing the clear path pushes its last statement out of the window and fails an assertion about persistence that is still true. Bound the slice to the function instead of to an arbitrary width. --- tests/test_issue2389_storage_pressure.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_issue2389_storage_pressure.py b/tests/test_issue2389_storage_pressure.py index 5ab8baac..cd9a623e 100644 --- a/tests/test_issue2389_storage_pressure.py +++ b/tests/test_issue2389_storage_pressure.py @@ -48,7 +48,10 @@ def test_deleted_sessions_prune_all_session_tracking_maps(): def test_session_viewed_count_prune_is_best_effort_and_persists_when_changed(): - viewed_block = _function_block(SESSIONS_SRC, "_clearSessionViewedCount") + # The window is a source slice, not a semantic bound: keep it wide enough to + # cover the whole function so unrelated edits to the clear path do not fail + # this assertion by pushing its last statement out of range. + viewed_block = _function_block(SESSIONS_SRC, "_clearSessionViewedCount", window=2200) assert "Object.prototype.hasOwnProperty.call(counts, sid)" in viewed_block assert "delete counts[sid]" in viewed_block assert "_saveSessionViewedCounts()" in viewed_block From d61d7212fbfd310e8d274a47a55ffa2930dbffab Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Tue, 15 Sep 2026 18:56:21 +0300 Subject: [PATCH 049/446] fix(sessions): merge completion markers by logical order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When both the store and the cache held a marker for one session, the merge chose between them by completed_at while the clear comparison used unread_order. Two completions in the same millisecond therefore kept the earlier marker and its message count and cron profile metadata. Compare unread_order on both sides, matching the ordering the rest of the unread state uses, and record in the RFC that a deletion record's expiry can let a very long-lived tab re-add a count for a session deleted more than seven days ago — bounded and invisible, since that session is no longer listed. --- .../webui-run-state-consistency-contract.md | 5 +++- static/sessions.js | 7 ++++-- tests/test_unread_cross_client_sync.py | 25 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index a822d3b2..d7da1ec3 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -177,7 +177,10 @@ writes. acknowledgement prunes it instead of writing it back. List membership cannot decide this, because the sidebar filters by profile, project, and source, so an absent row is not evidence of deletion. Deletion records expire on the same - 7-day policy as clear records. + 7-day policy as clear records, so a tab left open longer than that can re-add a + count for a session deleted more than seven days earlier. That consequence is + bounded and invisible: the session is no longer listed, so the retained entry + produces no indicator, and it is re-examined only on the next deletion or clear. - **Completion markers are ordered by logical stamps, not wall clock.** Markers are add/remove and cannot be max-ordered, so each clear records a stamp under `hermes-session-completion-unread-cleared:v1::`. diff --git a/static/sessions.js b/static/sessions.js index f3bea7d6..30005822 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -755,8 +755,11 @@ function _mergeSessionCompletionUnread(disk, cache, cleared) { const ours = cache[sid]; if (_markerLosesToUnreadClear(ours, (cleared || {})[sid])) continue; if (Object.prototype.hasOwnProperty.call(markers, sid)) { - const theirs = Number((markers[sid] || {}).completed_at) || 0; - if ((Number(ours && ours.completed_at) || 0) > theirs) { + // Both sides hold a marker: the later logical operation wins, which is the + // same order the clear comparison uses. Comparing completed_at here would + // keep the earlier of two markers written in one millisecond and leave that + // one's message count or cron profile metadata for later cleanup. + if (_sessionCompletionUnreadOrder(ours) > _sessionCompletionUnreadOrder(markers[sid])) { markers[sid] = ours; changed = true; } diff --git a/tests/test_unread_cross_client_sync.py b/tests/test_unread_cross_client_sync.py index 743b7d26..1e40706d 100644 --- a/tests/test_unread_cross_client_sync.py +++ b/tests/test_unread_cross_client_sync.py @@ -432,6 +432,31 @@ console.log(JSON.stringify({ assert out["afterAge"] == 0, "an expired deletion record must be pruned" +def test_marker_merge_keeps_the_later_operation_within_one_tick(): + """Two clients can complete a session in the same millisecond. The merge must + pick the later logical operation, not compare wall-clock completion times, + or the retained marker keeps the older message count and metadata.""" + out = _run_node(_script(""" +listed('Y'); +const A = makeClient(); +const B = makeClient(); +_now = 1000; +A.markUnread('Y', 3); +B.markUnread('Y', 7); +const disk = readDisk(SESSION_COMPLETION_UNREAD_KEY); +console.log(JSON.stringify({ + count: (disk.Y || {}).message_count || null, + completedAt: (disk.Y || {}).completed_at || null, +})); +""")) + assert out["count"] == 7, ( + "the later completion must win even though both markers share a millisecond" + ) + assert out["completedAt"] == 1000, ( + "the wall-clock completion time is still recorded for display and pruning" + ) + + def test_stale_client_cannot_resurrect_a_cleared_completion_marker(): """The field symptom: a marker cleared by opening the chat must not come back when another client that still holds it saves later.""" From b71c88f2923611bfc73bfce6dc0280fe2aade7eb Mon Sep 17 00:00:00 2001 From: alfred-rootson Date: Wed, 16 Sep 2026 00:37:11 +0200 Subject: [PATCH 050/446] fix(workspace): preserve remote POSIX paths across Session & streaming callsites (#7131) (#7168) Closes the remote POSIX workspace path preservation gap across Session and streaming. Profile-aware runtime path resolution. Co-authored-by: alfred-rootson --- api/config.py | 24 +- api/gateway_chat.py | 4 +- api/models.py | 19 +- api/profiles.py | 18 +- api/routes.py | 261 +++- api/streaming.py | 18 +- api/upload.py | 9 +- api/workspace.py | 364 ++++- docs/CONTRACTS.md | 4 + docs/remote-workspaces.md | 29 + tests/conftest.py | 38 + tests/test_agent_runtime_revision_guard.py | 2 +- tests/test_bootstrap_dotenv.py | 84 ++ tests/test_claude_code_session_import.py | 2 +- tests/test_early_session_title.py | 2 +- tests/test_goal_command_webui.py | 12 +- tests/test_issue1217_transcript_compaction.py | 2 +- .../test_issue3930_source_filter_pushdown.py | 4 +- tests/test_issue492_workspace_reorder.py | 3 +- ...test_issue6611_regeneration_transaction.py | 2 +- ...test_issue6751_api_content_agent_replay.py | 8 +- ...issue7168_round6_default_isolated_clamp.py | 294 ++++ tests/test_profile_switch_1200.py | 4 +- tests/test_provider_mismatch.py | 4 +- ...est_recovered_anchor_settle_duplication.py | 2 +- tests/test_remote_terminal_workspace.py | 1232 ++++++++++++++++- tests/test_sprint5.py | 8 +- tests/test_stale_stream_cleanup.py | 8 +- tests/test_webhook_project_sessions.py | 4 +- tests/test_workspace_upload.py | 2 +- 30 files changed, 2260 insertions(+), 207 deletions(-) create mode 100644 docs/remote-workspaces.md create mode 100644 tests/test_issue7168_round6_default_isolated_clamp.py diff --git a/api/config.py b/api/config.py index 686d2392..24530ef7 100644 --- a/api/config.py +++ b/api/config.py @@ -713,10 +713,14 @@ def get_config_for_profile_home(profile_home: "Path | str | None") -> dict: bypassing the thread-local resolver entirely. When ``profile_home`` matches the path the ambient resolver would pick (the common single-profile case), we return the cached ``get_config()`` to preserve in-memory overrides used - by tests and runtime callers. Only when the session's profile home diverges - from the ambient path do we read the session profile's file directly — a - pure read with no global cache mutation, so it is race-free across - concurrent sessions on different profiles. + by tests and runtime callers, and to honour an authoritative + ``HERMES_CONFIG_PATH`` override. Only when the session's profile home + diverges from the ambient path do we read the session profile's file + directly — a pure read with no global cache mutation, so it is race-free + across concurrent sessions on different profiles. Divergent profiles stay + isolated: a nonexistent home returns ``{}`` and an existing home without a + ``config.yaml`` yields defaults — neither ever falls back to the ambient + config (profiles-are-islands). """ if not profile_home: return get_config() @@ -724,10 +728,18 @@ def get_config_for_profile_home(profile_home: "Path | str | None") -> dict: target = Path(profile_home).expanduser() except Exception: return get_config() + + from api.workspace import _safe_resolve as _cfg_safe_resolve + + # Canonicalize BOTH sides before every identity comparison (#7168 re-gate + # round 5): when HERMES_HOME (or the config parent) is a symlink alias, + # lexical equality fails and an authoritative HERMES_CONFIG_PATH inside + # the aliased home would be bypassed in favor of a direct — wrong — read. + target = _cfg_safe_resolve(target) try: from api.profiles import get_active_hermes_home - if Path(get_active_hermes_home()).expanduser() == target: + if _cfg_safe_resolve(Path(get_active_hermes_home()).expanduser()) == target: return get_config() except Exception: pass @@ -737,7 +749,7 @@ def get_config_for_profile_home(profile_home: "Path | str | None") -> dict: # whose directory doesn't physically exist yet (fresh install, monkeypatched # cfg) must still resolve through get_config(), not return {} (#4516 gate). try: - if _get_config_path().parent == target: + if _cfg_safe_resolve(_get_config_path().parent) == target: return get_config() except Exception: pass diff --git a/api/gateway_chat.py b/api/gateway_chat.py index 24b275ad..35ced6fd 100644 --- a/api/gateway_chat.py +++ b/api/gateway_chat.py @@ -584,7 +584,7 @@ def _run_gateway_runs_api_streaming( try: from api.streaming import _build_native_multimodal_message - message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg, active_provider=active_provider, active_model=(model or ""), requested_provider=active_provider) + message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg, active_provider=active_provider, active_model=(model or ""), requested_provider=active_provider, profile=getattr(session, "profile", None)) except Exception: logger.debug("Failed to build runs-API multimodal attachment payload", exc_info=True) message_content = str(msg_text or "") @@ -1120,7 +1120,7 @@ def _run_gateway_chat_streaming( try: from api.streaming import _build_native_multimodal_message - message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg, active_provider=(model_provider or ""), active_model=(model or ""), requested_provider=(model_provider or "")) + message_content = _build_native_multimodal_message("", str(msg_text or ""), attachments, str(workspace), cfg=cfg, active_provider=(model_provider or ""), active_model=(model or ""), requested_provider=(model_provider or ""), profile=getattr(s, "profile", None)) except Exception: logger.debug("Failed to build gateway multimodal attachment payload", exc_info=True) message_content = str(msg_text or "") diff --git a/api/models.py b/api/models.py index 775124dc..754c372f 100644 --- a/api/models.py +++ b/api/models.py @@ -34,7 +34,7 @@ from api.config import ( LOCK, STREAMS, STREAMS_LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME, get_effective_default_model, _get_session_agent_lock, ) -from api.workspace import get_last_workspace +from api.workspace import get_last_workspace, _resolve_path from api.usage import prompt_cache_hit_percent from api.agent_sessions import ( _is_continuation_session, @@ -1264,7 +1264,8 @@ class Session: **kwargs): self.session_id = session_id or uuid.uuid4().hex[:12] self.title = title - self.workspace = str(Path(workspace).expanduser().resolve()) + self.profile = profile + self.workspace = str(_resolve_path(workspace, profile=profile)) # #6672: immutable snapshot of the workspace at session creation time. # s.workspace is updated on every turn when the user switches workspaces # mid-session via the WebUI header dropdown; interpolating the live @@ -1276,7 +1277,7 @@ class Session: # without a persisted created_workspace fall back to the workspace # recorded on disk, which is the best available approximation. self.created_workspace = ( - str(Path(created_workspace).expanduser().resolve()) + str(_resolve_path(created_workspace, profile=profile)) if created_workspace else self.workspace ) @@ -5098,7 +5099,7 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p wt = worktree_info if isinstance(worktree_info, dict) else None workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace s = Session( - workspace=workspace_path or get_last_workspace(), + workspace=workspace_path or get_last_workspace(profile=profile), model=effective_model, model_provider=effective_model_provider, profile=profile, @@ -5763,6 +5764,7 @@ def get_session_for_file_ops(sid: str): workspace, recovered = resolve_implicit_workspace_with_recovery( stored_workspace, get_last_workspace, + profile=session_profile or None, ) except ValueError: # Preserve the existing file-handler behavior for non-missing trust or @@ -6797,7 +6799,7 @@ def import_cli_session( s = Session( session_id=session_id, title=title, - workspace=get_last_workspace(), + workspace=get_last_workspace(profile=profile), model=model, messages=messages, profile=profile, @@ -7720,7 +7722,10 @@ def _load_cli_sessions_uncached( _cli_workspace_cache: list = [None] # list-as-cell; None = not yet resolved def _cli_workspace(): if _cli_workspace_cache[0] is None: - _cli_workspace_cache[0] = str(get_last_workspace()) + try: + _cli_workspace_cache[0] = str(get_last_workspace(profile=_cli_profile)) + except TypeError: + _cli_workspace_cache[0] = str(get_last_workspace()) return _cli_workspace_cache[0] _webhook_pid_cache: list[str | None] = [None] @@ -7934,7 +7939,7 @@ def _load_cli_sessions_uncached( cli_sessions.append({ 'session_id': sid, 'title': _display_title, - 'workspace': str(get_last_workspace()), + 'workspace': str(get_last_workspace(profile=_cli_profile)), 'model': row['model'] or None, 'message_count': row['message_count'] or row['actual_message_count'] or 0, 'created_at': row['started_at'], diff --git a/api/profiles.py b/api/profiles.py index f5e8432a..78f18735 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -1711,19 +1711,22 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: default_workspace = None try: from api.config import DEFAULT_WORKSPACE as _DW + from api.workspace import _resolve_path, _remote_terminal_workspace_candidate lw_file = home / 'webui_state' / 'last_workspace.txt' if lw_file.exists(): _p = lw_file.read_text(encoding='utf-8').strip() if _p: - _pp = Path(_p).expanduser() - if _pp.is_dir(): - default_workspace = str(_pp.resolve()) + _pp = _resolve_path(_p, profile=name) + remote_cand = _remote_terminal_workspace_candidate(_p, profile=name) + if remote_cand is not None or _pp.is_dir(): + default_workspace = str(_pp) if default_workspace is None: for _key in ('workspace', 'default_workspace'): _v = cfg.get(_key) if _v: - _pp = Path(str(_v)).expanduser().resolve() - if _pp.is_dir(): + _pp = _resolve_path(str(_v), profile=name) + remote_cand = _remote_terminal_workspace_candidate(str(_v), profile=name) + if remote_cand is not None or _pp.is_dir(): default_workspace = str(_pp) break if default_workspace is None: @@ -1731,8 +1734,9 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: if isinstance(_tc, dict): _cwd = _tc.get('cwd', '') if _cwd and str(_cwd) not in ('.', ''): - _pp = Path(str(_cwd)).expanduser().resolve() - if _pp.is_dir(): + _pp = _resolve_path(str(_cwd), profile=name) + remote_cand = _remote_terminal_workspace_candidate(str(_cwd), profile=name) + if remote_cand is not None or _pp.is_dir(): default_workspace = str(_pp) if default_workspace is None: default_workspace = str(_DW) diff --git a/api/routes.py b/api/routes.py index 4c220e29..701a51d7 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10544,6 +10544,7 @@ from api.workspace import ( safe_resolve_ws, raw_authorized_escape_target, resolve_trusted_workspace, + _resolve_path, resolve_implicit_workspace_with_recovery, open_anchored_fd, open_anchored_create_fd, @@ -14160,22 +14161,39 @@ def handle_get(handler, parsed) -> bool: return _handle_session_export(handler, parsed) if parsed.path == "/api/workspaces": + from api.profiles import get_active_profile_name + active_profile = get_active_profile_name() + try: + wss = load_workspaces(profile=active_profile) + except TypeError: + wss = load_workspaces() + try: + lw = get_last_workspace(profile=active_profile) + except TypeError: + lw = get_last_workspace() return j( handler, { - "workspaces": load_workspaces(), - "last": get_last_workspace(), + "workspaces": wss, + "last": lw, "terminal_remote_backend": _terminal_remote_backend_enabled(), }, ) if parsed.path == "/api/workspaces/suggest": + from api.profiles import get_active_profile_name + qs = parse_qs(parsed.query) prefix = qs.get("prefix", [""])[0] + active_profile = get_active_profile_name() + try: + suggestions = list_workspace_suggestions(prefix, profile=active_profile) + except TypeError: + suggestions = list_workspace_suggestions(prefix) return j( handler, { - "suggestions": list_workspace_suggestions(prefix), + "suggestions": suggestions, "prefix": prefix, }, ) @@ -14626,7 +14644,10 @@ def handle_get(handler, parsed) -> bool: # profile-scoped via the per-request hermes_profile cookie set in server.py. # Fail open: a resolution error must never 500 this boot-critical endpoint. try: - _profile_default_workspace = get_profile_default_workspace() + try: + _profile_default_workspace = get_profile_default_workspace(profile=active_profile_name) + except TypeError: + _profile_default_workspace = get_profile_default_workspace() except Exception: logger.debug("Failed to resolve profile default workspace for /api/profile/active", exc_info=True) _profile_default_workspace = None @@ -14849,26 +14870,41 @@ def _validate_session_toolsets_shape(toolsets): return toolsets -def _resolve_new_session_workspace(body, visible_prev_session_id): +def _resolve_new_session_workspace(body, visible_prev_session_id, profile=None): """Resolve a new-session workspace, recovering only verified inheritance.""" candidate = body.get("workspace") if not candidate: return None + + def _rtw(value): + # Legacy test doubles may predate the profile kwarg. + try: + return resolve_trusted_workspace(value, profile=profile) + except TypeError: + return resolve_trusted_workspace(value) + if ( body.get("workspace_inherited_from_prev_session") is not True or not visible_prev_session_id ): - return str(resolve_trusted_workspace(candidate)) + return str(_rtw(candidate)) try: previous_session = get_session(visible_prev_session_id, metadata_only=True) except KeyError: - return str(resolve_trusted_workspace(candidate)) + return str(_rtw(candidate)) if str(getattr(previous_session, "workspace", None) or "") != str(candidate): - return str(resolve_trusted_workspace(candidate)) - workspace, _recovered = resolve_implicit_workspace_with_recovery( - candidate, - get_last_workspace, - ) + return str(_rtw(candidate)) + try: + workspace, _recovered = resolve_implicit_workspace_with_recovery( + candidate, + get_last_workspace, + profile=profile, + ) + except TypeError: + workspace, _recovered = resolve_implicit_workspace_with_recovery( + candidate, + get_last_workspace, + ) return str(workspace) def handle_post(handler, parsed) -> bool: @@ -15210,7 +15246,9 @@ def handle_post(handler, parsed) -> bool: ): workspace_prev_session_id = None try: - workspace = _resolve_new_session_workspace(body, workspace_prev_session_id) + workspace = _resolve_new_session_workspace( + body, workspace_prev_session_id, profile=body.get("profile") or None + ) except (TypeError, ValueError) as e: return bad(handler, str(e)) worktree_info = None @@ -15239,7 +15277,17 @@ def handle_post(handler, parsed) -> bool: from api.worktrees import create_worktree_for_workspace base_workspace = workspace if not base_workspace: - base_workspace = str(resolve_trusted_workspace(get_last_workspace())) + _new_profile = body.get("profile") or None + try: + _lw = get_last_workspace(profile=_new_profile) + except TypeError: + _lw = get_last_workspace() + try: + base_workspace = str( + resolve_trusted_workspace(_lw, profile=_new_profile) + ) + except TypeError: + base_workspace = str(resolve_trusted_workspace(_lw)) worktree_info = create_worktree_for_workspace(base_workspace) workspace = worktree_info["path"] except (TypeError, ValueError) as e: @@ -15804,7 +15852,7 @@ def handle_post(handler, parsed) -> bool: old_model = getattr(s, "model", None) old_provider = getattr(s, "model_provider", None) try: - new_ws = str(resolve_trusted_workspace(body.get("workspace", s.workspace))) + new_ws = str(resolve_trusted_workspace(body.get("workspace", s.workspace), profile=getattr(s, "profile", None))) except ValueError as e: return bad(handler, str(e)) with _get_session_agent_lock(body["session_id"]): @@ -15838,7 +15886,7 @@ def handle_post(handler, parsed) -> bool: close_terminal(body["session_id"]) except Exception: logger.debug("Failed to close workspace terminal after workspace update") - set_last_workspace(new_ws) + set_last_workspace(new_ws, profile=getattr(s, "profile", None)) return j( handler, {"session": public_session_projection(s.compact() | {"messages": s.messages})}, @@ -17026,14 +17074,16 @@ def handle_post(handler, parsed) -> bool: if _arch_source_tag == "subagent" or _is_subagent_child_session_id(sid): return bad(handler, "Subagent sessions cannot be archived from WebUI", 400) if _is_messaging_session_record(cli_meta): + _arch_profile = cli_meta.get("profile") or None s = Session( session_id=sid, title=cli_meta.get("title") or title_from(get_cli_session_messages(sid), "CLI Session"), - workspace=get_last_workspace(), + workspace=get_last_workspace(profile=_arch_profile), messages=[], model=cli_meta.get("model") or "unknown", created_at=cli_meta.get("created_at"), updated_at=cli_meta.get("updated_at"), + profile=_arch_profile, ) s.is_cli_session = is_cli_session_row(cli_meta) s.source_tag = cli_meta.get("source_tag") @@ -18019,15 +18069,26 @@ def _handle_list_dir(handler, parsed): except Exception: return bad(handler, "Session not found", 404) try: + _list_profile = getattr(webui_session, "profile", None) if webui_session is None: - workspace = resolve_trusted_workspace(workspace) + try: + workspace = resolve_trusted_workspace(workspace, profile=_list_profile) + except TypeError: + workspace = resolve_trusted_workspace(workspace) recovered = False else: stored_workspace = workspace - workspace, recovered = resolve_implicit_workspace_with_recovery( - stored_workspace, - get_last_workspace, - ) + try: + workspace, recovered = resolve_implicit_workspace_with_recovery( + stored_workspace, + get_last_workspace, + profile=_list_profile, + ) + except TypeError: + workspace, recovered = resolve_implicit_workspace_with_recovery( + stored_workspace, + get_last_workspace, + ) if recovered: persisted = persist_recovered_workspace_binding( webui_session, @@ -19069,7 +19130,7 @@ def _handle_terminal_start(handler, body): }, status=400, ) - workspace = resolve_trusted_workspace(getattr(session, "workspace", "") or "") + workspace = resolve_trusted_workspace(getattr(session, "workspace", "") or "", profile=getattr(session, "profile", None)) from api.terminal import start_terminal term = start_terminal( sid, @@ -22053,10 +22114,11 @@ def _memory_project_context_workspace(parsed) -> Path | None: # fall through to Path("").resolve(), which returns the server's own # CWD and would surface the install's AGENTS.md/HERMES.md as if it # were the user's project context. - ws = (get_session(sid).workspace or "").strip() + session = get_session(sid) + ws = (session.workspace or "").strip() if not ws: return None - return Path(ws).expanduser().resolve() + return _resolve_path(ws, profile=getattr(session, "profile", None)) except Exception: return None @@ -22064,7 +22126,7 @@ def _memory_project_context_workspace(parsed) -> Path | None: if not raw_workspace: return None try: - return Path(resolve_trusted_workspace(raw_workspace)).expanduser().resolve() + return resolve_trusted_workspace(raw_workspace) except Exception: logger.debug("Skipping project context for untrusted workspace %s", raw_workspace, exc_info=True) return None @@ -22834,7 +22896,7 @@ def _start_regeneration_stream_locked( save_attempted = True s.save() accepted = True - set_last_workspace(workspace) + set_last_workspace(workspace, profile=getattr(s, "profile", None)) release_worker.set() except Exception as exc: abort_worker.set() @@ -23173,7 +23235,7 @@ def _start_chat_stream_for_session( except Exception: logger.warning("Failed to append submitted turn journal event", exc_info=True) diag.stage("set_last_workspace") if diag else None - set_last_workspace(workspace) + set_last_workspace(workspace, profile=getattr(s, "profile", None)) diag.stage("stream_registration") if diag else None stream = create_stream_channel() register_stream_owner(stream_id, s.session_id) @@ -23886,7 +23948,7 @@ def _handle_goal_command(handler, body): previous_goal_state = None if will_kickoff: try: - workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace)) + workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace, profile=getattr(s, "profile", None))) except ValueError as e: return bad(handler, str(e)) requested_model = body.get("model") or s.model @@ -23955,7 +24017,7 @@ def _handle_goal_command(handler, body): if kickoff_prompt: if workspace is None: try: - workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace)) + workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace, profile=getattr(s, "profile", None))) except ValueError as e: return bad(handler, str(e)) if model is None: @@ -24336,14 +24398,25 @@ def _handle_chat_start(handler, body, diag=None): def _resolve_chat_workspace_with_recovery(s, requested_workspace) -> str: """Recover stale implicit session workspaces without hiding explicit errors.""" + _session_profile = getattr(s, "profile", None) explicit = requested_workspace not in (None, "") if explicit: - return str(resolve_trusted_workspace(requested_workspace)) + try: + return str(resolve_trusted_workspace(requested_workspace, profile=_session_profile)) + except TypeError: + return str(resolve_trusted_workspace(requested_workspace)) stored_workspace = getattr(s, "workspace", None) - workspace, recovered = resolve_implicit_workspace_with_recovery( - stored_workspace, - get_last_workspace, - ) + try: + workspace, recovered = resolve_implicit_workspace_with_recovery( + stored_workspace, + get_last_workspace, + profile=_session_profile, + ) + except TypeError: + workspace, recovered = resolve_implicit_workspace_with_recovery( + stored_workspace, + get_last_workspace, + ) if not recovered: return str(workspace) persisted = persist_recovered_workspace_binding( @@ -24356,12 +24429,23 @@ def _resolve_chat_workspace_with_recovery(s, requested_workspace) -> str: def _resolve_chat_workspace_for_regeneration(s, requested_workspace) -> str: """Resolve regeneration's workspace without persisting before start acceptance.""" + _session_profile = getattr(s, "profile", None) or None if requested_workspace not in (None, ""): - return str(resolve_trusted_workspace(requested_workspace)) - workspace, _recovered = resolve_implicit_workspace_with_recovery( - getattr(s, "workspace", None), - get_last_workspace, - ) + try: + return str(resolve_trusted_workspace(requested_workspace, profile=_session_profile)) + except TypeError: + return str(resolve_trusted_workspace(requested_workspace)) + try: + workspace, _recovered = resolve_implicit_workspace_with_recovery( + getattr(s, "workspace", None), + get_last_workspace, + profile=_session_profile, + ) + except TypeError: + workspace, _recovered = resolve_implicit_workspace_with_recovery( + getattr(s, "workspace", None), + get_last_workspace, + ) return str(workspace) @@ -24407,7 +24491,10 @@ def _handle_chat_sync(handler, body): if not msg: return j(handler, {"error": "empty message"}, status=400) try: - workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace)) + try: + workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace, profile=getattr(s, "profile", None))) + except TypeError: + workspace = str(resolve_trusted_workspace(body.get("workspace") or s.workspace)) except ValueError as e: return bad(handler, str(e)) with _get_session_agent_lock(s.session_id): @@ -25790,38 +25877,49 @@ def _handle_workspace_add(handler, body): # macOS) so pytest's tmp_path_factory paths and other legit user-tmp dirs # still register cleanly. try: - candidate = Path(path_str).expanduser().resolve() + from api.workspace import _remote_terminal_workspace_candidate, _resolve_path + from api.profiles import get_active_profile_name + active_profile = get_active_profile_name() + remote_candidate = _remote_terminal_workspace_candidate(path_str, profile=active_profile) + candidate = _resolve_path(path_str, profile=active_profile) except (ValueError, OSError, RuntimeError) as e: # Invalid path (e.g. embedded null byte) — fail closed with a clean 400 # instead of letting .resolve() raise an uncaught 500. return bad(handler, f"Invalid path: {_sanitize_error(e)}") - if _is_blocked_system_path(candidate): - # Home-directory carve-out, mirroring the validators - # (resolve_trusted_workspace / validate_workspace_to_add): a workspace - # at or under the active user's home must stay allowed even when that - # home lives under an otherwise-blocked root (e.g. systemd-homed - # /var/home//...). Without this the route rejects valid - # /var/home workspaces before validate_workspace_to_add()'s carve-out - # can run. - _home = _home_path() - if not (_home != Path("/") and (candidate == _home or _is_within(candidate, _home))): - return bad(handler, f"Path points to a system directory: {candidate}") - # Now safe to create the directory if requested - if auto_create: - try: - candidate.mkdir(parents=True, exist_ok=True) - except (OSError, PermissionError) as e: - return bad(handler, f"Could not create directory: {_sanitize_error(e)}") + if remote_candidate is None: + if _is_blocked_system_path(candidate): + # Home-directory carve-out, mirroring the validators + # (resolve_trusted_workspace / validate_workspace_to_add): a workspace + # at or under the active user's home must stay allowed even when that + # home lives under an otherwise-blocked root (e.g. systemd-homed + # /var/home//...). Without this the route rejects valid + # /var/home workspaces before validate_workspace_to_add()'s carve-out + # can run. + _home = _home_path() + if not (_home != Path("/") and (candidate == _home or _is_within(candidate, _home))): + return bad(handler, f"Path points to a system directory: {candidate}") + # Now safe to create the directory if requested + if auto_create: + try: + candidate.mkdir(parents=True, exist_ok=True) + except (OSError, PermissionError) as e: + return bad(handler, f"Could not create directory: {_sanitize_error(e)}") # Full validation (exists, is_dir) — should pass now that dir exists try: - p = validate_workspace_to_add(path_str) + p = validate_workspace_to_add(path_str, profile=active_profile) except ValueError as e: return bad(handler, str(e)) - wss = load_workspaces() + try: + wss = load_workspaces(profile=active_profile) + except TypeError: + wss = load_workspaces() if any(w["path"] == str(p) for w in wss): return bad(handler, "Workspace already in list") wss.append({"path": str(p), "name": name or p.name}) - save_workspaces(wss) + try: + save_workspaces(wss, profile=active_profile) + except TypeError: + save_workspaces(wss) return j(handler, {"ok": True, "workspaces": wss}) @@ -25829,9 +25927,17 @@ def _handle_workspace_remove(handler, body): path_str = body.get("path", "").strip() if not path_str: return bad(handler, "path is required") - wss = load_workspaces() + from api.profiles import get_active_profile_name + active_profile = get_active_profile_name() + try: + wss = load_workspaces(profile=active_profile) + except TypeError: + wss = load_workspaces() wss = [w for w in wss if w["path"] != path_str] - save_workspaces(wss) + try: + save_workspaces(wss, profile=active_profile) + except TypeError: + save_workspaces(wss) return j(handler, {"ok": True, "workspaces": wss}) @@ -25840,14 +25946,22 @@ def _handle_workspace_rename(handler, body): name = body.get("name", "").strip() if not path_str or not name: return bad(handler, "path and name are required") - wss = load_workspaces() + from api.profiles import get_active_profile_name + active_profile = get_active_profile_name() + try: + wss = load_workspaces(profile=active_profile) + except TypeError: + wss = load_workspaces() for w in wss: if w["path"] == path_str: w["name"] = name break else: return bad(handler, "Workspace not found", 404) - save_workspaces(wss) + try: + save_workspaces(wss, profile=active_profile) + except TypeError: + save_workspaces(wss) return j(handler, {"ok": True, "workspaces": wss}) @@ -25861,7 +25975,12 @@ def _handle_workspace_reorder(handler, body): paths = body.get("paths", []) if not paths or not isinstance(paths, list): return bad(handler, "paths is required and must be a list") - wss = load_workspaces() + from api.profiles import get_active_profile_name + active_profile = get_active_profile_name() + try: + wss = load_workspaces(profile=active_profile) + except TypeError: + wss = load_workspaces() by_path = {w["path"]: w for w in wss} # Build reordered list: given order first, then any omitted entries reordered = [] @@ -25875,7 +25994,11 @@ def _handle_workspace_reorder(handler, body): for w in wss: if w["path"] not in seen: reordered.append(w) - save_workspaces(reordered) + try: + save_workspaces(reordered, profile=active_profile) + except TypeError: + # Legacy signature (test doubles with single-arg lambdas, older forks). + save_workspaces(reordered) return j(handler, {"ok": True, "workspaces": reordered}) @@ -28391,7 +28514,7 @@ def _handle_session_import_cli(handler, body): session_payload = { "session_id": sid, "title": title, - "workspace": str(get_last_workspace()), + "workspace": str(get_last_workspace(profile=profile)), "model": model, "message_count": len(msgs), "created_at": created_at, diff --git a/api/streaming.py b/api/streaming.py index f41ab71f..ba2223fd 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2556,7 +2556,7 @@ def _aiagent_import_error_detail() -> str: lines.append(' Full troubleshooting: docs/troubleshooting.md ("AIAgent not available")') return "\n".join(lines) from api.models import get_session, title_from -from api.workspace import set_last_workspace +from api.workspace import _resolve_path # Fields that are safe to send to LLM provider APIs. # Everything else (attachments, timestamp, _ts, etc.) is display-only @@ -3385,7 +3385,7 @@ def _resolve_image_input_mode(cfg: dict, active_provider: str = "", active_model return "native" -def _build_native_multimodal_message(workspace_ctx: str, msg_text: str, attachments, workspace: str, *, cfg: dict = None, active_provider: str = "", active_model: str = "", requested_provider: str = ""): +def _build_native_multimodal_message(workspace_ctx: str, msg_text: str, attachments, workspace: str, *, cfg: dict = None, active_provider: str = "", active_model: str = "", requested_provider: str = "", profile: str | Path | None = None): """Build native multimodal content parts for current-turn image uploads. WebUI uploads files into the active workspace. For image files, pass the @@ -3405,7 +3405,7 @@ def _build_native_multimodal_message(workspace_ctx: str, msg_text: str, attachme return workspace_ctx + msg_text parts = [{'type': 'text', 'text': workspace_ctx + msg_text}] - workspace_root = Path(workspace).expanduser().resolve() + workspace_root = _resolve_path(workspace, profile=profile) # Stage-361 maintainer fix (Opus SHOULD-FIX): chat uploads from #2319 now # land in ~/.hermes/webui/attachments// (outside workspace_root by # design). The pre-existing `path.relative_to(workspace_root)` guard would @@ -9466,19 +9466,19 @@ def _run_agent_streaming( # Resolved the same way as `s.workspace` below so the bound cwd and the # session's own record cannot disagree. Guarded: a turn must not die # here because a workspace path is malformed. + s = get_session(session_id) try: - _turn_workspace_cwd = str(Path(workspace).expanduser().resolve()) + _turn_workspace_cwd = str(_resolve_path(workspace, profile=getattr(s, 'profile', None))) except Exception: _turn_workspace_cwd = "" logger.debug("per-turn workspace cwd resolve failed", exc_info=True) _turn_session_identity_tokens = _set_turn_session_identity( session_id, workspace=_turn_workspace_cwd ) - s = get_session(session_id) _turn_pending_source = getattr(s, 'pending_user_source', None) or 'webui' _active_turn_identity = _active_turn_authority(s, stream_id, msg_text) update_active_run(stream_id, phase="running", session_id=session_id) - s.workspace = str(Path(workspace).expanduser().resolve()) + s.workspace = _turn_workspace_cwd _last_persisted_model = None _last_persisted_provider = None _turn_owns_persisted_model = False @@ -11006,7 +11006,7 @@ def _run_agent_streaming( _agent_msg_text = msg_text if _process_notifications: _agent_msg_text = "\n\n".join([*_process_notifications, msg_text]).strip() - user_message = _build_native_multimodal_message(workspace_ctx, _agent_msg_text, attachments, workspace, cfg=_cfg, active_provider=(resolved_provider or ""), active_model=(resolved_model or ""), requested_provider=(_session_requested_provider or "")) + user_message = _build_native_multimodal_message(workspace_ctx, _agent_msg_text, attachments, workspace, cfg=_cfg, active_provider=(resolved_provider or ""), active_model=(resolved_model or ""), requested_provider=(_session_requested_provider or ""), profile=(getattr(s, "profile", None) or Path(_profile_home))) _persistent_state_before = _persistent_state_snapshot(_profile_home) _run_conversation_kwargs = _build_run_conversation_kwargs( agent.run_conversation, @@ -11059,6 +11059,10 @@ def _run_agent_streaming( active_provider=(resolved_provider or ""), active_model=(resolved_model or ""), requested_provider=(_session_requested_provider or ""), + # Legacy fallback wraps the home STRING in Path: string + # profiles are logical ids only (round-5 grammar gate), + # explicit homes must arrive as Path values. + profile=(getattr(s, "profile", None) or Path(_profile_home)), ) _run_conversation_kwargs["user_message"] = user_message _result_partial_pre_call_context = list(_previous_context_messages) diff --git a/api/upload.py b/api/upload.py index 9adef00d..6269fca5 100644 --- a/api/upload.py +++ b/api/upload.py @@ -635,8 +635,13 @@ def handle_workspace_upload(handler): if _reject_invisible_session(handler, session): return True - # Resolve workspace root from session - workspace = resolve_trusted_workspace(session.workspace) + # Resolve workspace root using the session profile, not the ambient request profile. + try: + workspace = resolve_trusted_workspace( + session.workspace, profile=getattr(session, "profile", None) + ) + except TypeError: + workspace = resolve_trusted_workspace(session.workspace) # Resolve target subdirectory within workspace target_dir = safe_resolve_ws(workspace, subpath) if subpath else workspace diff --git a/api/workspace.py b/api/workspace.py index 12a46356..d8ffb282 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -12,6 +12,7 @@ import json import logging import os import posixpath +import re import secrets import shutil import stat @@ -40,8 +41,14 @@ from api.subprocess_utils import windows_hide_flags # ── Profile-aware path resolution ─────────────────────────────────────────── -def _profile_state_dir() -> Path: - """Return the webui_state directory for the active profile. +# Logical profile-name grammar — mirrors api.profiles._PROFILE_ID_RE. Kept as +# a local copy so workspace-layer validation does not import profiles at module +# load time (profiles may not be importable in every embedding context). +_PROFILE_NAME_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$') + + +def _profile_state_dir(profile: str | Path | None = None) -> Path: + """Return the webui_state directory for the active or given profile. For the default profile, returns the global STATE_DIR (respects HERMES_WEBUI_STATE_DIR env var for test isolation). @@ -49,6 +56,28 @@ def _profile_state_dir() -> Path: """ try: from api.profiles import get_active_profile_name, get_active_hermes_home + if profile is not None: + # Literal-"default" STATE routing (#7168 re-gate round 7): the + # default profile's workspace state always lives in the global + # state files, even when isolated mode pins the default home at + # /profiles/default. The round-6 resolver change made + # _resolve_profile_home_param("default") return that pinned home, + # so the canonical-home check below sent explicit + # profile="default" state reads/writes to {pinned}/webui_state/ + # while ambient calls kept using the global dir — splitting saved + # workspaces between two authorities. Config and workspace PATH + # resolution still use the pinned home via + # _resolve_profile_home_param; only this state-file tier stays + # global for the logical string "default". + if isinstance(profile, str) and profile.strip() == 'default': + return _GLOBAL_WS_FILE.parent + profile_home = _resolve_profile_home_param(profile) + if not _is_default_profile_home(profile_home): + d = profile_home / 'webui_state' + d.mkdir(parents=True, exist_ok=True) + return d + return _GLOBAL_WS_FILE.parent + name = get_active_profile_name() if name and name != 'default': d = get_active_hermes_home() / 'webui_state' @@ -59,14 +88,41 @@ def _profile_state_dir() -> Path: return _GLOBAL_WS_FILE.parent -def _workspaces_file() -> Path: - """Return the workspaces.json path for the active profile.""" - return _profile_state_dir() / 'workspaces.json' +def _workspaces_file(profile: str | Path | None = None) -> Path: + """Return the workspaces.json path for the active or given profile.""" + return _profile_state_dir(profile=profile) / 'workspaces.json' -def _last_workspace_file() -> Path: - """Return the last_workspace.txt path for the active profile.""" - return _profile_state_dir() / 'last_workspace.txt' +def _last_workspace_file(profile: str | Path | None = None) -> Path: + """Return the last_workspace.txt path for the active or given profile.""" + return _profile_state_dir(profile=profile) / 'last_workspace.txt' + + +def _workspaces_file_for_profile(profile: str | Path | None = None) -> Path | None: + """Profile-scoped workspaces.json path, or None for an INVALID profile. + + ``None`` is the fail-closed contract (#7168 re-gate round 4): a malformed + profile name must not be clamped onto the default/global state files, so + callers treat it as "no readable/writable profile-local state". + """ + try: + return _workspaces_file(profile=profile) if profile is not None else _workspaces_file() + except TypeError: + return _workspaces_file() + except ValueError: + logger.debug("Ignoring invalid profile name %r for workspaces file", profile) + return None + + +def _last_workspace_file_for_profile(profile: str | Path | None = None) -> Path | None: + """Profile-scoped last_workspace.txt path, or None for an INVALID profile.""" + try: + return _last_workspace_file(profile=profile) if profile is not None else _last_workspace_file() + except TypeError: + return _last_workspace_file() + except ValueError: + logger.debug("Ignoring invalid profile name %r for last-workspace file", profile) + return None def _expanduser_path(path: str | Path) -> Path: @@ -101,8 +157,11 @@ def _expanduser_path(path: str | Path) -> Path: return Path(raw) -def _resolve_path(path: str | Path) -> Path: - """Resolve *path* after env-aware home expansion, without raising.""" +def _resolve_path(path: str | Path, profile: str | Path | None = None) -> Path: + """Resolve *path* after env-aware home expansion, preserving remote POSIX paths without raising.""" + remote_candidate = _remote_terminal_workspace_candidate(path, profile=profile) + if remote_candidate is not None: + return remote_candidate return _safe_resolve(_expanduser_path(path)) @@ -148,12 +207,75 @@ def _is_remote_terminal_backend(terminal_cfg: dict | None) -> bool: return backend not in ('', 'local') -def _remote_terminal_cwd() -> str | None: - """Return target-side terminal cwd for remote profiles, without local stat().""" +def _resolve_profile_home_param(profile: str | Path | None) -> Path: + """Resolve a profile parameter (name string, directory path string, or Path) to a profile home Path. + + Logical profile names are validated strictly (#7168 re-gate round 4): a + name that fails the profile-id grammar raises ValueError instead of being + silently clamped onto the default home — the old clamp let a malformed + name such as ``"bad name"`` read/write the DEFAULT profile's state. + Round 5 tightens the grammar gate: a STRING profile value is strictly a + logical profile id and is NEVER treated as a path-shaped home — the old + ``"/" in raw`` branch resolved any slash-bearing string directly, so a + malformed value like ``"../evil"`` bypassed validation entirely and could + read/overwrite an arbitrary ``webui_state/last_workspace.txt`` + (#7168 re-gate round 5, path traversal on the profile-isolation boundary). + Round 6 closes the last isolation hole in this resolver: the logical + string ``"default"`` used to short-circuit to ``_DEFAULT_HERMES_HOME`` + before reaching ``get_hermes_home_for_profile()``, bypassing the + isolated-mode clamp in ``api.profiles._resolve_profile_home_for_name`` + (#7168 re-gate round 6). In an isolated deployment pinned at + ``/profiles/default``, a session created with ``profile="default"`` + therefore resolved workspace/config from the BASE root home instead of + the pinned one. The name now flows through the same delegated path as + every other logical id; literal-default routing to the global state + files is retained in ``_profile_state_dir``/``get_last_workspace`` via + canonical ``_is_default_profile_home`` identity. + An explicit home directory is expressed as a ``Path`` object (callers such + as streaming's legacy ``_profile_home`` fallback wrap their home strings + in ``Path``); Path values are honored and canonicalized so identity + comparisons never depend on lexical spelling (e.g. a symlink alias of the + default home must compare equal to it). + """ + if profile is None or str(profile).strip() == "": + from api.profiles import get_active_hermes_home + return get_active_hermes_home() + + raw = str(profile).strip() + + if isinstance(profile, Path): + return _safe_resolve(profile.expanduser()) + + # Strings are LOGICAL PROFILE IDS ONLY — no path-shaped strings, ever. + if not _PROFILE_NAME_RE.fullmatch(raw): + raise ValueError(f"invalid profile name: {raw!r}") + + from api.profiles import get_hermes_home_for_profile + return _safe_resolve(get_hermes_home_for_profile(raw)) + + +def _is_default_profile_home(profile_home: Path) -> bool: + """Canonical identity check against the root/default Hermes home. + + Compares resolved paths so a symlink alias of _DEFAULT_HERMES_HOME is + recognized as the default profile rather than treated as a foreign, + lexically-different directory (#7168 re-gate round 4). + """ try: - from api.config import get_config + from api.profiles import _DEFAULT_HERMES_HOME + return _safe_resolve(profile_home) == _safe_resolve(_DEFAULT_HERMES_HOME) + except Exception: + return False + + +def _remote_terminal_cwd(profile: str | Path | None = None) -> str | None: + """Return target-side terminal cwd for a remote profile, without local stat().""" + try: + from api.config import get_config_for_profile_home + + profile_home = _resolve_profile_home_param(profile) + terminal_cfg = get_config_for_profile_home(profile_home).get('terminal', {}) - terminal_cfg = get_config().get('terminal', {}) if not _is_remote_terminal_backend(terminal_cfg): return None cwd = str(terminal_cfg.get('cwd') or '').strip() @@ -165,8 +287,8 @@ def _remote_terminal_cwd() -> str | None: return None -def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None: - """Return a non-stat'ed target-side Path when it is under terminal.cwd. +def _remote_terminal_workspace_candidate(path: str | Path, profile: str | Path | None = None) -> Path | None: + """Return a non-stat'ed target-side Path when it is under terminal.cwd for the given profile. Remote workspace paths live on the target host (e.g., remote SSH/Docker backend). For valid target-side POSIX paths under ``terminal.cwd``, the @@ -174,7 +296,10 @@ def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None: local host-filesystem resolution (avoiding host-specific firmlink rewriting such as macOS synthetic ``/home`` -> ``/System/Volumes/Data/home``). """ - cwd = _remote_terminal_cwd() + try: + cwd = _remote_terminal_cwd(profile=profile) if profile is not None else _remote_terminal_cwd() + except TypeError: + cwd = _remote_terminal_cwd() if not cwd: return None raw = _strip_surrounding_quotes(str(path)).strip() @@ -192,8 +317,8 @@ def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None: if posix_candidate == posix_base or _posix_is_within(posix_candidate, posix_base): return Path(normalized_raw) return None - candidate = _resolve_path(raw) - base = _resolve_path(cwd) + candidate = _safe_resolve(_expanduser_path(raw)) + base = _safe_resolve(_expanduser_path(cwd)) if _is_blocked_workspace_path(candidate, raw) or _is_blocked_workspace_path(base, cwd): return None if candidate == base or _is_within(candidate, base): @@ -201,7 +326,7 @@ def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None: return None -def _profile_default_workspace() -> str: +def _profile_default_workspace(profile: str | Path | None = None) -> str: """Read the profile's default workspace from its config.yaml. Checks keys in priority order: @@ -217,8 +342,9 @@ def _profile_default_workspace() -> str: Falls back to the live DEFAULT_WORKSPACE from api.config. """ try: - from api.config import get_config - cfg = get_config() + from api.config import get_config_for_profile_home + profile_home = _resolve_profile_home_param(profile) + cfg = get_config_for_profile_home(profile_home) terminal_cfg = cfg.get('terminal', {}) remote_terminal = _is_remote_terminal_backend(terminal_cfg) # Explicit webui workspace keys first @@ -227,7 +353,7 @@ def _profile_default_workspace() -> str: if ws: if remote_terminal: return str(ws).strip() - p = _resolve_path(str(ws)) + p = _resolve_path(str(ws), profile=profile) if remote_terminal or p.is_dir(): return str(p) # Fall through to terminal.cwd — the agent's configured working directory @@ -236,7 +362,7 @@ def _profile_default_workspace() -> str: if cwd and str(cwd) not in ('.', ''): if remote_terminal: return str(cwd).strip() - p = _resolve_path(str(cwd)) + p = _resolve_path(str(cwd), profile=profile) if remote_terminal or p.is_dir(): return str(p) except (ImportError, Exception): @@ -244,14 +370,14 @@ def _profile_default_workspace() -> str: try: from api.config import DEFAULT_WORKSPACE as _LIVE_DEFAULT_WORKSPACE - return str(_resolve_path(_LIVE_DEFAULT_WORKSPACE)) + return str(_resolve_path(_LIVE_DEFAULT_WORKSPACE, profile=profile)) except Exception: - return str(_resolve_path(_BOOT_DEFAULT_WORKSPACE)) + return str(_resolve_path(_BOOT_DEFAULT_WORKSPACE, profile=profile)) # ── Public API ────────────────────────────────────────────────────────────── -def _clean_workspace_list(workspaces: list) -> list: +def _clean_workspace_list(workspaces: list, profile: str | Path | None = None) -> list: """Sanitize a workspace list: - Preserve target-side remote terminal workspace paths (SSH/Docker) without resolving them against the local WebUI host filesystem. @@ -270,7 +396,7 @@ def _clean_workspace_list(workspaces: list) -> list: name = w.get('name', '') if not path: continue - remote_cand = _remote_terminal_workspace_candidate(path) + remote_cand = _remote_terminal_workspace_candidate(path, profile=profile) if remote_cand is not None: p = remote_cand else: @@ -283,7 +409,14 @@ def _clean_workspace_list(workspaces: list) -> list: # p is under ~/.hermes/profiles/ — only skip if it's under a DIFFERENT profile try: from api.profiles import get_active_hermes_home - own_profile_dir = get_active_hermes_home().resolve() + if profile is not None: + # Explicit profile wins: the list belongs to that profile, + # so "own" is defined by the profile parameter, never by the + # ambient home (loading profile A's list under ambient B must + # not silently drop A's own workspaces). + own_profile_dir = _resolve_profile_home_param(profile).resolve() + else: + own_profile_dir = get_active_hermes_home().resolve() p.relative_to(own_profile_dir) # p is under our own profile dir — keep it except (ValueError, Exception): @@ -350,12 +483,12 @@ def _migrate_global_workspaces() -> list: return [] -def load_workspaces() -> list: - ws_file = _workspaces_file() - if ws_file.exists(): +def load_workspaces(profile: str | Path | None = None) -> list: + ws_file = _workspaces_file_for_profile(profile) + if ws_file is not None and ws_file.exists(): try: raw = json.loads(ws_file.read_text(encoding='utf-8')) - cleaned = _clean_workspace_list(raw) + cleaned = _clean_workspace_list(raw, profile=profile) if len(cleaned) != len(raw): # Persist the cleaned version so stale entries don't keep reappearing try: @@ -364,7 +497,7 @@ def load_workspaces() -> list: ) except Exception: logger.debug("Failed to persist cleaned workspace list") - return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}] + return cleaned or [{'path': _profile_default_workspace(profile=profile), 'name': 'Home'}] except Exception: logger.debug("Failed to load workspaces from %s", ws_file) # No profile-local file yet. @@ -372,7 +505,11 @@ def load_workspaces() -> list: # For NAMED profiles: always start clean with just their own workspace. try: from api.profiles import get_active_profile_name - is_default = get_active_profile_name() in ('default', None) + if profile is not None: + profile_home = _resolve_profile_home_param(profile) + is_default = _is_default_profile_home(profile_home) + else: + is_default = get_active_profile_name() in ('default', None) except ImportError: is_default = True if is_default: @@ -380,16 +517,20 @@ def load_workspaces() -> list: if migrated: return migrated # Fresh start: single entry from the profile's configured workspace, labeled "Home" - return [{'path': _profile_default_workspace(), 'name': 'Home'}] + return [{'path': _profile_default_workspace(profile=profile), 'name': 'Home'}] -def save_workspaces(workspaces: list) -> None: - ws_file = _workspaces_file() +def save_workspaces(workspaces: list, profile: str | Path | None = None) -> None: + ws_file = _workspaces_file_for_profile(profile) + if ws_file is None: + # Fail-closed: an invalid profile name must not write any state file + # (it would land in the default profile's directory via the old clamp). + raise ValueError(f"cannot save workspaces for invalid profile {profile!r}") ws_file.parent.mkdir(parents=True, exist_ok=True) ws_file.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8') -def get_profile_default_workspace() -> str: +def get_profile_default_workspace(profile: str | Path | None = None) -> str: """Resolve the ACTIVE PROFILE's default workspace, never the global file. Like get_last_workspace() but WITHOUT the global ``_GLOBAL_LW_FILE`` @@ -404,32 +545,38 @@ def get_profile_default_workspace() -> str: Priority: profile-scoped ``last_workspace.txt`` -> profile ``config.yaml`` ``workspace``/``default_workspace`` -> ``terminal.cwd`` -> process default. """ - remote_cwd = _remote_terminal_cwd() + try: + remote_cwd = _remote_terminal_cwd(profile=profile) if profile is not None else _remote_terminal_cwd() + except TypeError: + remote_cwd = _remote_terminal_cwd() def _valid(raw: str) -> str | None: if not raw: return None if remote_cwd: - if _remote_terminal_workspace_candidate(raw) is not None: + if _remote_terminal_workspace_candidate(raw, profile=profile) is not None: return raw return None if Path(raw).is_dir(): return raw return None - lw_file = _last_workspace_file() - if lw_file.exists(): + lw_file = _last_workspace_file_for_profile(profile) + if lw_file is not None and lw_file.exists(): try: p = _valid(lw_file.read_text(encoding='utf-8').strip()) if p: return p except Exception: logger.debug("Failed to read profile last workspace from %s", lw_file) - return _profile_default_workspace() + return _profile_default_workspace(profile=profile) -def get_last_workspace() -> str: - remote_cwd = _remote_terminal_cwd() +def get_last_workspace(profile: str | Path | None = None) -> str: + try: + remote_cwd = _remote_terminal_cwd(profile=profile) if profile is not None else _remote_terminal_cwd() + except TypeError: + remote_cwd = _remote_terminal_cwd() def valid_last_workspace(raw: str) -> str | None: if not raw: @@ -438,35 +585,58 @@ def get_last_workspace() -> str: # For remote/SSH profiles, last_workspace is target-side state. Do # not accept stale server-local paths merely because they exist on # the WebUI host; require the value to stay under terminal.cwd. - if _remote_terminal_workspace_candidate(raw) is not None: + if _remote_terminal_workspace_candidate(raw, profile=profile) is not None: return raw return None if Path(raw).is_dir(): return raw return None - lw_file = _last_workspace_file() - if lw_file.exists(): + lw_file = _last_workspace_file_for_profile(profile) + if lw_file is not None and lw_file.exists(): try: p = valid_last_workspace(lw_file.read_text(encoding='utf-8').strip()) if p: return p except Exception: logger.debug("Failed to read last workspace from %s", lw_file) - # Fallback: try global file - if _GLOBAL_LW_FILE.exists(): + # Fallback: try global file — but ONLY for the root/default profile. A named + # profile must never inherit another profile's last-workspace binding through + # the legacy global state (#7168 re-gate round 3). Identity is canonical: + # a symlink alias of the default home still counts as default, while a + # malformed profile name fails validation and is denied the fallback + # rather than being clamped onto the global file (#7168 re-gate round 4). + _global_fallback_allowed = True # ambient / no explicit profile: historical behavior + if profile is not None: + if str(profile).strip() == 'default': + _global_fallback_allowed = True + else: + try: + _global_fallback_allowed = _is_default_profile_home( + _resolve_profile_home_param(profile) + ) + except Exception: + # Conservative default: an unresolvable explicit profile is NOT the + # root profile — deny the global fallback rather than leak. + _global_fallback_allowed = False + if _global_fallback_allowed and _GLOBAL_LW_FILE.exists(): try: p = valid_last_workspace(_GLOBAL_LW_FILE.read_text(encoding='utf-8').strip()) if p: return p except Exception: logger.debug("Failed to read global last workspace") - return _profile_default_workspace() + return _profile_default_workspace(profile=profile) -def set_last_workspace(path: str) -> None: +def set_last_workspace(path: str, profile: str | Path | None = None) -> None: try: - lw_file = _last_workspace_file() + lw_file = _last_workspace_file_for_profile(profile) + if lw_file is None: + # Fail-closed: an invalid profile name must not write the default + # profile's last_workspace.txt (#7168 re-gate round 4). + logger.debug("Refusing to set last workspace for invalid profile %r", profile) + return lw_file.parent.mkdir(parents=True, exist_ok=True) lw_file.write_text(str(path), encoding='utf-8') except Exception: @@ -683,7 +853,15 @@ def _is_within(path: Path, root: Path) -> bool: return False -def _trusted_workspace_roots() -> list[Path]: +def _trusted_workspace_roots(profile: str | Path | None = None) -> list[Path]: + """Return the host directories workspace suggestions may traverse. + + Saved-workspace roots follow the same trust rule as + :func:`resolve_trusted_workspace`: with an explicit *profile*, only + workspaces saved under THAT profile widen the boundary (plus the ambient + home / boot-default carve-outs); ``None`` keeps the historical ambient / + global saved-list behaviour. + """ roots: list[Path] = [] def add(candidate: str | Path | None) -> None: @@ -702,24 +880,26 @@ def _trusted_workspace_roots() -> list[Path]: add(_home_path()) add(_BOOT_DEFAULT_WORKSPACE) - for w in load_workspaces(): + for w in load_workspaces(profile=profile): add(w.get("path")) roots.sort(key=lambda p: len(str(p))) return roots -def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]: +def list_workspace_suggestions( + prefix: str = "", limit: int = 12, profile: str | Path | None = None +) -> list[str]: """Return workspace path suggestions under trusted roots only. Suggestions are limited to directories under one of: - Path.home() - the boot default workspace - - already-saved workspace roots + - already-saved workspace roots (scoped to *profile* when given) Arbitrary system prefixes return an empty list rather than an error so the UI can safely autocomplete while the user types. """ - roots = _trusted_workspace_roots() + roots = _trusted_workspace_roots(profile=profile) if not roots: return [] @@ -815,7 +995,7 @@ def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]: return suggestions[:limit] -def resolve_trusted_workspace(path: str | Path | None = None) -> Path: +def resolve_trusted_workspace(path: str | Path | None = None, profile: str | Path | None = None) -> Path: """Resolve and validate a workspace path. A path is trusted if it satisfies at least one of: @@ -837,12 +1017,12 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path: trusted (it was validated at server startup). """ if path in (None, ""): - return _resolve_path(_BOOT_DEFAULT_WORKSPACE) + return _resolve_path(_BOOT_DEFAULT_WORKSPACE, profile) if profile is not None else _resolve_path(_BOOT_DEFAULT_WORKSPACE) - candidate = _resolve_path(path) + candidate = _resolve_path(path, profile) if profile is not None else _resolve_path(path) access_error = _workspace_access_error(candidate) - remote_candidate = _remote_terminal_workspace_candidate(path) + remote_candidate = _remote_terminal_workspace_candidate(path, profile=profile) if access_error: # For remote terminal profiles, workspace paths belong to the target # machine. Allow paths under terminal.cwd so session switching can @@ -869,6 +1049,11 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path: # (B) Trusted if already in the saved workspace list — covers non-home installs try: + saved = load_workspaces(profile=profile) + saved_paths = {_resolve_path(w["path"], profile) for w in saved if w.get("path")} + if candidate in saved_paths: + return candidate + except TypeError: saved = load_workspaces() saved_paths = {_resolve_path(w["path"]) for w in saved if w.get("path")} if candidate in saved_paths: @@ -898,15 +1083,22 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path: def resolve_implicit_workspace_with_recovery( candidate: str | Path | None, fallback: str | Path | None | Callable[[], str | Path | None], + profile: str | Path | None = None, ) -> tuple[Path, bool]: """Resolve an implicit workspace, recovering only a genuinely missing path. The fallback still passes through :func:`resolve_trusted_workspace`. Existing but untrusted, inaccessible, or non-directory candidates are not recovery cases: their original validation error is preserved so fallback cannot widen - the workspace trust boundary. + the workspace trust boundary. When *profile* is given, both the trust + resolution and the recovery fallback are scoped to that profile. """ try: + if profile is not None: + try: + return resolve_trusted_workspace(candidate, profile=profile), False + except TypeError: + pass return resolve_trusted_workspace(candidate), False except ValueError as original_error: if candidate in (None, ""): @@ -917,19 +1109,49 @@ def resolve_implicit_workspace_with_recovery( # never prove target-side deletion. Config-read uncertainty also fails # closed by preserving the original validation error. try: - from api.config import get_config + from api.config import get_config, get_config_for_profile_home - terminal_cfg = get_config().get("terminal", {}) + if profile is not None: + # Classify the backend from THIS profile's own config, never the + # ambient one: a remote profile without terminal.cwd must still be + # recognized as remote when loaded under a different ambient home. + terminal_cfg = get_config_for_profile_home( + _resolve_profile_home_param(profile) + ).get("terminal", {}) + else: + terminal_cfg = get_config().get("terminal", {}) except Exception: logger.debug("Failed to classify terminal backend for workspace recovery", exc_info=True) raise original_error from None if _is_remote_terminal_backend(terminal_cfg): raise original_error from None try: - local_candidate = _resolve_path(candidate) + local_candidate = ( + _resolve_path(candidate, profile=profile) + if profile is not None + else _resolve_path(candidate) + ) local_candidate.stat() except FileNotFoundError: - fallback_value = fallback() if callable(fallback) else fallback + def _profile_bound_fallback(): + if profile is not None and callable(fallback): + # Profile-bound getter FIRST (#7168 re-gate round 3): the + # production call sites pass profile-aware getters such as + # get_last_workspace, so binding the explicit profile must + # take precedence over any zero-argument compatibility call, + # which would read ambient/global state. + try: + return fallback(profile) + except TypeError: + pass + return fallback() if callable(fallback) else fallback + + fallback_value = _profile_bound_fallback() + if profile is not None: + try: + return resolve_trusted_workspace(fallback_value, profile=profile), True + except TypeError: + pass return resolve_trusted_workspace(fallback_value), True except (OSError, RuntimeError, ValueError): raise original_error from None @@ -957,7 +1179,7 @@ def _strip_surrounding_quotes(path: str) -> str: return s -def validate_workspace_to_add(path: str) -> Path: +def validate_workspace_to_add(path: str, profile: str | Path | None = None) -> Path: """Validate a path for *adding* to the workspace list (less restrictive than resolve_trusted_workspace). When a user explicitly adds a new workspace path, we trust their intent — they @@ -972,10 +1194,10 @@ def validate_workspace_to_add(path: str) -> Path: and users routinely paste those into the Add Space input. """ path = _strip_surrounding_quotes(path) - candidate = _resolve_path(path) + candidate = _resolve_path(path, profile) if profile is not None else _resolve_path(path) access_error = _workspace_access_error(candidate) - remote_candidate = _remote_terminal_workspace_candidate(path) + remote_candidate = _remote_terminal_workspace_candidate(path, profile=profile) if access_error: # Remote terminal profiles validate workspace existence on the target # machine, not on the WebUI server. Permit target-side paths under diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index fcfc5d54..da8a1022 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -23,6 +23,10 @@ contributor guidance; it does not change runtime behavior or CI gates. ## Runtime, durability, and state contracts +- [`docs/remote-workspaces.md`](remote-workspaces.md): + architecture contract for remote terminal workspaces (SSH/Docker), target-side + POSIX path preservation against macOS synthetic firmlink expansion, and + per-profile isolation boundaries. - [`docs/rfcs/webui-run-state-consistency-contract.md`](rfcs/webui-run-state-consistency-contract.md): proposed consistency rules for current WebUI streaming, recovery, replay, model-context reconstruction, compression, UI scene/cache, and sidebar metadata diff --git a/docs/remote-workspaces.md b/docs/remote-workspaces.md new file mode 100644 index 00000000..c32ba384 --- /dev/null +++ b/docs/remote-workspaces.md @@ -0,0 +1,29 @@ +# Remote Terminal Workspaces + +Architecture contract and path-resolution semantics for remote terminal profiles (SSH, Docker) in Hermes WebUI. + +--- + +## 1. Overview + +When a Hermes profile is configured with a remote terminal backend (e.g. `terminal.backend: "ssh"` or `"docker"`), its working directory (`terminal.cwd`) lives on the remote target host rather than the local WebUI host filesystem. + +On hosts such as macOS, local path resolution via `Path.resolve()` or `os.path.realpath()` expands synthetic firmlinks (e.g. rewriting `/home/` to `/System/Volumes/Data/home/`). Because the target-side path does not exist on the local macOS server, unconstrained local resolution causes runtime validation failures and session corruption. + +--- + +## 2. Path Resolution Contract + +1. **Target-side POSIX Path Preservation:** + - Any valid POSIX path at or beneath a remote profile's configured `terminal.cwd` is preserved verbatim as a `Path` object without calling host-local `Path.resolve()`. + - Traversal escapes (`..`), null bytes (`\0`), and blocked system roots (`/etc`, `/usr`, `/var`, `/sys`, `/proc`, `/dev`) continue to be strictly rejected. + +2. **Profile Isolation & Boundary Enforcement:** + - Remote path recognition is **strictly scoped to the target/active profile**. + - If an active profile has a local backend, target-side remote paths belonging to inactive named profiles are **not** treated as remote for the active local profile. + - For local profiles, workspace addition (`/api/workspaces/add`) enforces host-local directory existence and permissions. + - For remote profiles, workspace addition validates against the profile's remote `terminal.cwd` and skips host-local directory creation (`mkdir`). + +3. **Session & Streaming Lifecycle:** + - `Session.__init__` and `Session.load` normalize session workspace paths through `_resolve_path(..., profile=profile)`, preserving target-side paths for remote profiles and preventing corruption of `session.workspace` or `session.created_workspace`. + - Streaming execution (`_run_agent_streaming`) and multimodal asset root resolution preserve the remote session workspace when updating runtime run state. diff --git a/tests/conftest.py b/tests/conftest.py index 7dc77884..a492e666 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -231,6 +231,44 @@ def _reset_password_hash_cache(): _invalidate_password_hash_cache() +def _strip_leaked_webui_password_env() -> None: + """Remove a leaked HERMES_WEBUI_PASSWORD between tests (#7168 review). + + bootstrap.py runs _load_repo_dotenv() at import time, which copies values + from the developer's real repo .env straight into os.environ. When any + test imports bootstrap mid-session (e.g. tests/test_bootstrap_foreground.py + via its import_bootstrap fixture), a local .env containing + HERMES_WEBUI_PASSWORD leaks into the process environment OUTSIDE + monkeypatch's undo scope. Every later test then sees is_auth_enabled() + True and no-handler cookie helpers raise spurious + "build_profile_cookie requires a request handler" errors — exactly the + #5588 failure shape, but sourced from the repo .env instead of the hash + cache. Tests that legitimately enable auth set the var themselves AFTER + this strip; an intentionally-empty value ("") is preserved so + ctl.sh-style override semantics keep working. + + HERMES_COMMAND gets the same treatment (#7168 re-gate round 7): a local + .env carrying HERMES_COMMAND leaks past bootstrap imports and redirects + gateway_restart._resolve_hermes_command() away from its mocked + shutil.which result, failing every later active-profile-restart test + with a machine-specific CLI path. Upstream code has no + HERMES_COMMAND override, so stripping a leaked value restores exact + upstream semantics. + """ + if os.environ.get("HERMES_WEBUI_PASSWORD") == "": + pass # intentional empty override preserved for the password var + else: + os.environ.pop("HERMES_WEBUI_PASSWORD", None) + os.environ.pop("HERMES_COMMAND", None) + + +@pytest.fixture(autouse=True) +def _strip_leaked_webui_password(): + _strip_leaked_webui_password_env() + yield + _strip_leaked_webui_password_env() + + @pytest.fixture(autouse=True) def _invalidate_providers_cache(): """Clear the /api/providers TTL cache around every test (#6010). diff --git a/tests/test_agent_runtime_revision_guard.py b/tests/test_agent_runtime_revision_guard.py index b787bc11..40d5ee64 100644 --- a/tests/test_agent_runtime_revision_guard.py +++ b/tests/test_agent_runtime_revision_guard.py @@ -854,7 +854,7 @@ def test_stream_admission_uses_one_gateway_ownership_snapshot(monkeypatch, gatew monkeypatch.setattr(routes, "_active_run_stream_for_session", lambda _sid: None) monkeypatch.setattr(routes, "_is_hidden_empty_session", lambda _session: False) monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", prepare) - monkeypatch.setattr(routes, "set_last_workspace", lambda _workspace: None) + monkeypatch.setattr(routes, "set_last_workspace", lambda _workspace, **_kw: None) monkeypatch.setattr(routes.threading, "Thread", FakeThread) monkeypatch.setattr(turn_journal, "append_turn_journal_event", lambda *_args, **_kwargs: {}) diff --git a/tests/test_bootstrap_dotenv.py b/tests/test_bootstrap_dotenv.py index 2f354d1f..fbe6716d 100644 --- a/tests/test_bootstrap_dotenv.py +++ b/tests/test_bootstrap_dotenv.py @@ -197,3 +197,87 @@ class TestBootstrapStructure: assert "_load_repo_dotenv" in bootstrap_src, ( "bootstrap.py must load .env so direct invocation matches start.sh behaviour" ) + + +class TestLeakedWebuiPasswordIsolation: + """#7168 review: a local repo .env containing HERMES_WEBUI_PASSWORD leaks + into os.environ when any test imports bootstrap (import-time + _load_repo_dotenv() runs OUTSIDE monkeypatch's undo scope). The conftest + autouse guard strips the leaked var around every test so later tests + don't see is_auth_enabled()==True (the #5588 failure shape).""" + + def _load_guard(self): + import importlib.util + + cpath = Path(__file__).resolve().parent / "conftest.py" + spec = importlib.util.spec_from_file_location("_test_conftest", cpath) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod._strip_leaked_webui_password_env + + def test_conftest_guard_strips_leaked_password(self): + strip = self._load_guard() + + os.environ["HERMES_WEBUI_PASSWORD"] = "leaked-from-repo-dotenv" + try: + strip() + assert "HERMES_WEBUI_PASSWORD" not in os.environ + finally: + os.environ.pop("HERMES_WEBUI_PASSWORD", None) + + def test_conftest_guard_preserves_intentional_empty_override(self): + """ctl.sh-style override semantics: an explicitly empty value means + 'keep auth off' and must survive the strip.""" + strip = self._load_guard() + + sentinel = object() + os.environ["HERMES_WEBUI_PASSWORD"] = "" + try: + strip() + assert os.environ.get("HERMES_WEBUI_PASSWORD", sentinel) == "" + finally: + os.environ.pop("HERMES_WEBUI_PASSWORD", None) + + +class TestLeakedHermesCommandIsolation: + """#7168 re-gate round 7: a local repo .env carrying HERMES_COMMAND leaks + into os.environ via bootstrap import-time _load_repo_dotenv() and + redirects gateway_restart._resolve_hermes_command() away from its mocked + shutil.which result — every later active-profile-restart test then fails + on a machine-specific CLI path. The autouse conftest guard strips the + leaked var around every test; upstream code never reads HERMES_COMMAND, + so stripping restores exact upstream semantics.""" + + def _load_guard(self): + import importlib.util + + cpath = Path(__file__).resolve().parent / "conftest.py" + spec = importlib.util.spec_from_file_location("_test_conftest_hc", cpath) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod._strip_leaked_webui_password_env + + def test_conftest_guard_strips_leaked_hermes_command(self): + strip = self._load_guard() + + os.environ["HERMES_COMMAND"] = "/machine/local/hermes-gateway-wrapper" + try: + strip() + assert "HERMES_COMMAND" not in os.environ + finally: + os.environ.pop("HERMES_COMMAND", None) + + def test_conftest_guard_still_strips_password_when_command_leaks(self): + """Both leaked vars are stripped in one pass (password branch must + not be short-circuited by the HERMES_COMMAND handling).""" + strip = self._load_guard() + + os.environ["HERMES_WEBUI_PASSWORD"] = "leaked-from-repo-dotenv" + os.environ["HERMES_COMMAND"] = "/machine/local/hermes-gateway-wrapper" + try: + strip() + assert "HERMES_WEBUI_PASSWORD" not in os.environ + assert "HERMES_COMMAND" not in os.environ + finally: + os.environ.pop("HERMES_WEBUI_PASSWORD", None) + os.environ.pop("HERMES_COMMAND", None) diff --git a/tests/test_claude_code_session_import.py b/tests/test_claude_code_session_import.py index 9d9af034..7dd67581 100644 --- a/tests/test_claude_code_session_import.py +++ b/tests/test_claude_code_session_import.py @@ -200,7 +200,7 @@ def test_session_import_cli_returns_read_only_claude_code_payload(monkeypatch, t monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload) monkeypatch.setattr(routes, "get_cli_session_messages", lambda _sid, profile=None: messages if _sid == sid else []) monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: [meta]) - monkeypatch.setattr(routes, "get_last_workspace", lambda: tmp_path / "workspace") + monkeypatch.setattr(routes, "get_last_workspace", lambda profile=None: tmp_path / "workspace") monkeypatch.setattr(routes, "import_cli_session", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("read-only import must not persist"))) response = routes._handle_session_import_cli(object(), {"session_id": sid}) diff --git a/tests/test_early_session_title.py b/tests/test_early_session_title.py index 864e0e73..b236a642 100644 --- a/tests/test_early_session_title.py +++ b/tests/test_early_session_title.py @@ -93,7 +93,7 @@ def test_start_chat_stream_response_includes_provisional_title(tmp_path, monkeyp import api.routes as routes monkeypatch.setattr(Session, "save", lambda self, *a, **k: None) - monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) + monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None) monkeypatch.setattr(routes, "create_stream_channel", lambda: object()) monkeypatch.setattr(routes, "_run_agent_streaming", lambda *a, **k: None) diff --git a/tests/test_goal_command_webui.py b/tests/test_goal_command_webui.py index a049b27d..2fd7800c 100644 --- a/tests/test_goal_command_webui.py +++ b/tests/test_goal_command_webui.py @@ -397,7 +397,7 @@ def test_goal_endpoint_sets_goal_and_starts_kickoff_stream( monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager) monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession()) - monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path) monkeypatch.setattr( routes, "webui_gateway_chat_enabled", @@ -519,7 +519,7 @@ def test_goal_endpoint_adapter_keeps_full_set_text_and_legacy_payload_status(mon monkeypatch.setenv("HERMES_WEBUI_RUNTIME_ADAPTER", "legacy-journal") monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager) monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession()) - monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path) monkeypatch.setattr( routes, "_resolve_compatible_session_model_state", @@ -732,7 +732,7 @@ def test_goal_kickoff_forwards_explicit_model_pick_to_resolver(monkeypatch, tmp_ monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager) monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession()) - monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path) monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False) monkeypatch.setattr(routes, "get_config", lambda: {}) monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve) @@ -809,7 +809,7 @@ def test_goal_kickoff_defaults_explicit_model_pick_false(monkeypatch, tmp_path): monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager) monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession()) - monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path) monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False) monkeypatch.setattr(routes, "get_config", lambda: {}) monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve) @@ -901,7 +901,7 @@ def test_goal_kickoff_stamps_explicit_pick_signature(monkeypatch, tmp_path): monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager) monkeypatch.setattr(routes, "get_session", lambda sid: FakeSession()) - monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path) monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False) monkeypatch.setattr(routes, "get_config", lambda: {}) monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve) @@ -973,7 +973,7 @@ def test_goal_kickoff_does_not_stamp_signature_without_explicit_pick(monkeypatch return model, provider, False monkeypatch.setattr(webui_goals, "GoalManager", FakeGoalManager) - monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace: tmp_path) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda workspace, **_kw: tmp_path) monkeypatch.setattr(routes, "webui_gateway_chat_enabled", lambda _cfg: False) monkeypatch.setattr(routes, "get_config", lambda: {}) monkeypatch.setattr(routes, "_resolve_compatible_session_model_state", fake_resolve) diff --git a/tests/test_issue1217_transcript_compaction.py b/tests/test_issue1217_transcript_compaction.py index f2954b44..c4e12cc2 100644 --- a/tests/test_issue1217_transcript_compaction.py +++ b/tests/test_issue1217_transcript_compaction.py @@ -748,7 +748,7 @@ def test_handle_chat_sync_writeback_dedupes_full_context_replay(tmp_path, monkey monkeypatch.setattr(routes, "title_from", models.title_from) monkeypatch.setattr(config, "get_config", lambda: {"model": "test-model", "provider": "test-provider"}) monkeypatch.setattr(routes, "get_config", lambda: {"model": "test-model", "provider": "test-provider"}) - monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: tmp_path) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: tmp_path) monkeypatch.setattr(routes, "load_settings", lambda: {}) monkeypatch.setattr(routes, "_resolve_cli_toolsets", lambda: []) diff --git a/tests/test_issue3930_source_filter_pushdown.py b/tests/test_issue3930_source_filter_pushdown.py index 5503208a..ade4885d 100644 --- a/tests/test_issue3930_source_filter_pushdown.py +++ b/tests/test_issue3930_source_filter_pushdown.py @@ -241,7 +241,7 @@ def test_load_cli_sessions_uncached_pushes_specific_source_into_state_db_scan(mo monkeypatch.setattr(models, "get_claude_code_sessions", lambda: claude_calls.append(True) or []) monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows) - monkeypatch.setattr(models, "get_last_workspace", lambda: tmp_path) + monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path) monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False) monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project-id") monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) @@ -283,7 +283,7 @@ def test_cron_source_filter_uses_cron_rescue_limit(monkeypatch, tmp_path): ] monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows) - monkeypatch.setattr(models, "get_last_workspace", lambda: tmp_path) + monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path) monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False) monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project-id") monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) diff --git a/tests/test_issue492_workspace_reorder.py b/tests/test_issue492_workspace_reorder.py index 91e87a20..12a591e6 100644 --- a/tests/test_issue492_workspace_reorder.py +++ b/tests/test_issue492_workspace_reorder.py @@ -22,7 +22,8 @@ class TestWorkspaceReorderEndpoint: {"path": "/home/user/b", "name": "Beta"}, {"path": "/home/user/c", "name": "Gamma"}, ] - mock_save.side_effect = lambda wss: wss + # Accept the profile-scoped save signature (save_workspaces(wss, profile=...)). + mock_save.side_effect = lambda wss, **kwargs: wss handler = _make_handler() _handle_workspace_reorder(handler, { "paths": ["/home/user/c", "/home/user/a", "/home/user/b"] diff --git a/tests/test_issue6611_regeneration_transaction.py b/tests/test_issue6611_regeneration_transaction.py index 7f1db4d4..c84aa591 100644 --- a/tests/test_issue6611_regeneration_transaction.py +++ b/tests/test_issue6611_regeneration_transaction.py @@ -193,7 +193,7 @@ def test_locked_postacceptance_workspace_exception_does_not_restore_turn(monkeyp monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue()) monkeypatch.setattr(turn_journal, "append_turn_journal_event", lambda *_args, **_kwargs: {"turn_id": "turn-6611"}) monkeypatch.setattr(Session, "save", lambda *_args, **_kwargs: None) - monkeypatch.setattr(routes, "set_last_workspace", lambda *_args: (_ for _ in ()).throw(RuntimeError("workspace failed"))) + monkeypatch.setattr(routes, "set_last_workspace", lambda *_args, **_kw: (_ for _ in ()).throw(RuntimeError("workspace failed"))) class FakeThread: def __init__(self, *args, **kwargs): diff --git a/tests/test_issue6751_api_content_agent_replay.py b/tests/test_issue6751_api_content_agent_replay.py index e78850cd..b7f79259 100644 --- a/tests/test_issue6751_api_content_agent_replay.py +++ b/tests/test_issue6751_api_content_agent_replay.py @@ -435,7 +435,7 @@ def test_issue6751_sync_chat_agent_receives_original_api_content_bytes(monkeypat monkeypatch.setattr(routes, "title_from", models.title_from) monkeypatch.setattr(config, "get_config", lambda: {"model": "test-model", "provider": "test-provider"}) monkeypatch.setattr(routes, "get_config", lambda: {"model": "test-model", "provider": "test-provider"}) - monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: tmp_path) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: tmp_path) monkeypatch.setattr(routes, "load_settings", lambda: {}) monkeypatch.setattr(routes, "_resolve_cli_toolsets", lambda: []) @@ -526,7 +526,7 @@ def test_issue6751_json_import_strips_internal_aliases_before_persistence(monkey monkeypatch.setattr(models, "SESSION_INDEX_FILE", state_dir / "session_index.json") monkeypatch.setattr(models, "SESSIONS", sessions) monkeypatch.setattr(routes, "SESSIONS", sessions) - monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: tmp_path) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: tmp_path) monkeypatch.setattr(routes, "get_active_profile_name", lambda: "default") monkeypatch.setattr(routes, "publish_session_list_changed", lambda *args, **kwargs: None) monkeypatch.setattr(config, "load_settings", lambda: {"api_redact_enabled": False}) @@ -625,7 +625,7 @@ def test_issue6751_json_import_nested_tool_calls_are_removed_at_agent_boundary( monkeypatch.setattr(models, "SESSIONS", sessions) monkeypatch.setattr(routes, "SESSION_INDEX_FILE", state_dir / "session_index.json") monkeypatch.setattr(routes, "SESSIONS", sessions) - monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: tmp_path) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: tmp_path) monkeypatch.setattr(routes, "get_active_profile_name", lambda: "default") monkeypatch.setattr(routes, "publish_session_list_changed", lambda *args, **kwargs: None) monkeypatch.setattr(config, "load_settings", lambda: {"api_redact_enabled": False}) @@ -784,7 +784,7 @@ def test_issue6751_json_import_rejects_non_list_session_tool_calls(monkeypatch, monkeypatch.setattr(models, "SESSIONS", sessions) monkeypatch.setattr(routes, "SESSION_INDEX_FILE", state_dir / "session_index.json") monkeypatch.setattr(routes, "SESSIONS", sessions) - monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: tmp_path) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: tmp_path) monkeypatch.setattr(config, "load_settings", lambda: {"api_redact_enabled": False}) captured = {} diff --git a/tests/test_issue7168_round6_default_isolated_clamp.py b/tests/test_issue7168_round6_default_isolated_clamp.py new file mode 100644 index 00000000..a6528d8f --- /dev/null +++ b/tests/test_issue7168_round6_default_isolated_clamp.py @@ -0,0 +1,294 @@ +"""Re-gate round 6 (#7168): the logical "default" name must honor isolated-mode clamping. + +Maintainer round-6 re-gate CORE (one remaining instance of the isolation +class): ``_resolve_profile_home_param("default")`` short-circuited to +``_DEFAULT_HERMES_HOME`` BEFORE delegating to +``api.profiles.get_hermes_home_for_profile()``, so it never reached the +isolated-mode clamp in ``_resolve_profile_home_for_name`` (which pins every +lookup to ``_INITIAL_HERMES_HOME`` when ``HERMES_WEBUI_ISOLATED_PROFILE`` is +enabled). In an isolated deployment pinned at ``/profiles/default``, a +session created with ``profile="default"`` therefore used the BASE root +home's workspace + config instead of the pinned one. + +Fix under test: the literal shortcut is gone — the logical string ``"default"`` +flows through ``get_hermes_home_for_profile()`` like every other id, so the +clamp applies. Literal-default routing to the global state files is RETAINED +(canonical ``_is_default_profile_home`` identity), asserted by the fallback +tests below. +""" + +import pytest + +import api.workspace as workspace +from api import profiles + + +@pytest.fixture +def isolated_default_pinned(tmp_path, monkeypatch): + """Isolated-mode deployment pinned at /profiles/default. + + Mirrors the gate reproduction: HERMES_WEBUI_ISOLATED_PROFILE=1 with a + profile-shaped _INITIAL_HERMES_HOME whose directory NAME is literally + 'default' (the exact shape where base and pin diverge). Base home gets a + DISTINCT config/workspace marker so any leak of the root home is visible. + """ + base = tmp_path / ".hermes" + pinned = base / "profiles" / "default" + pinned.mkdir(parents=True) + (pinned / "webui_state").mkdir() + + # Distinct base-vs-pinned configs: only the PINNED one carries the marker. + (pinned / "config.yaml").write_text( + "workspace: /srv/pinned-workspace\n", encoding="utf-8" + ) + + monkeypatch.setenv("HERMES_WEBUI_ISOLATED_PROFILE", "1") + monkeypatch.setattr(profiles, "_INITIAL_ISOLATED_PROFILE_OPT_IN", "1") + monkeypatch.setattr(profiles, "_INITIAL_HERMES_HOME", str(pinned)) + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base) + monkeypatch.setattr(profiles, "_LIST_PROFILES_CACHE", None) + # Hermetic w.r.t. the runner's real state dir / remote terminal config. + monkeypatch.setattr(workspace, "_remote_terminal_cwd", lambda profile=None: None) + return {"base": base, "pinned": pinned, "tmp": tmp_path} + + +class TestRound6DefaultHonorsIsolationClamp: + """The logical "default" name resolves through the clamped delegated path.""" + + def test_resolver_pins_default_in_isolated_mode(self, isolated_default_pinned): + env = isolated_default_pinned + assert profiles._is_isolated_profile_mode() is True + assert ( + profiles.get_hermes_home_for_profile("default") == env["pinned"] + ) # pre-existing correct behavior (the clamp) + got = workspace._resolve_profile_home_param("default") + assert got == env["pinned"].resolve(), ( + f"_resolve_profile_home_param('default') must reach the isolated-mode " + f"clamp and return the PINNED home {env['pinned']}, got {got}" + ) + assert got != env["base"], "must not resolve to the base/root home" + + def test_get_last_workspace_uses_global_state_not_base(self, isolated_default_pinned, monkeypatch): + """Round 7 contract: 'default' STATE lives in the GLOBAL files only. + + Round-6 follow-up: once the resolver pins config/path resolution at + the isolated home, the canonical-home check in _profile_state_dir + split explicit profile="default" state I/O onto {pinned}/webui_state/ + while ambient calls kept the global dir. Round 7 restores ONE state + authority: literal "default" reads/writes the global files, so this + test now asserts the global binding WINS and the BASE home's + webui_state is never consulted. + """ + env = isolated_default_pinned + # The authoritative GLOBAL binding... + global_ws = env["tmp"] / "srv" / "global-ws" + global_ws.mkdir(parents=True) + global_lw = env["tmp"] / "global-state" + global_lw.mkdir(parents=True) + # Both global state files must move TOGETHER: in production + # _GLOBAL_WS_FILE and _GLOBAL_LW_FILE share one state dir. + monkeypatch.setattr(workspace, "_GLOBAL_WS_FILE", global_lw / "workspaces.json") + monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_lw / "last_workspace.txt") + (global_lw / "last_workspace.txt").write_text(str(global_ws), encoding="utf-8") + + # ...and a POISONED base binding that must NOT win. Point the base + # home's own webui_state at a foreign path to prove no base read. + poisoned_ws = env["tmp"] / "srv" / "poisoned-base-ws" + poisoned_ws.mkdir(parents=True) + base_state = env["base"] / "webui_state" + base_state.mkdir(parents=True) + (base_state / "last_workspace.txt").write_text(str(poisoned_ws), encoding="utf-8") + + got = workspace.get_last_workspace(profile="default") + assert str(got) == str(global_ws.resolve()), ( + f"profile='default' in isolated mode must read the GLOBAL state " + f"authority ({global_lw / 'last_workspace.txt'}), got {got!r}" + ) + + def test_get_last_workspace_global_fallback_only_when_canonically_default( + self, isolated_default_pinned, monkeypatch + ): + """No profile-local state + non-pinned layout → legacy global fallback. + + Literal-default routing to _GLOBAL_LW_FILE is retained OUTSIDE isolated + mode: when the resolved default home IS canonically the default home, + get_last_workspace(profile="default") still reads the global file + (historical behavior, re-gate round 3 contract). + """ + env = isolated_default_pinned + # Disable isolation so 'default' resolves canonically to the base home. + monkeypatch.delenv("HERMES_WEBUI_ISOLATED_PROFILE", raising=False) + monkeypatch.setattr(profiles, "_INITIAL_ISOLATED_PROFILE_OPT_IN", "") + monkeypatch.setattr(profiles, "_INITIAL_HERMES_HOME", str(env["base"])) + global_lw = env["tmp"] / "global-state" + global_lw.mkdir(parents=True) + monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_lw / "last_workspace.txt") + + shared_ws = env["tmp"] / "srv" / "shared-ws" + shared_ws.mkdir(parents=True) + (global_lw / "last_workspace.txt").write_text(str(shared_ws), encoding="utf-8") + + # The conftest autouse cleanup fixture writes TEST_STATE_DIR / + # last_workspace.txt at every teardown; for a canonically-default + # profile that IS the profile-local tier and would shadow the global + # file under test. Pin the profile-local tier to an absent path so the + # GLOBAL fallback is what actually gets exercised. + monkeypatch.setattr( + workspace, + "_last_workspace_file_for_profile", + lambda p=None: env["tmp"] / "absent-webui_state" / "last_workspace.txt", + ) + + got = workspace.get_last_workspace(profile="default") + assert str(got) == str(shared_ws), ( + "outside isolated mode, profile='default' keeps the legacy global " + "fallback (canonical identity retained after the round-6 fix)" + ) + + def test_new_session_binds_global_state_workspace(self, isolated_default_pinned, monkeypatch): + """Session creation with profile='default' honors the global state + authority and never leaks onto the base home (round-7 contract).""" + import api.models as models + + env = isolated_default_pinned + bound_ws = env["tmp"] / "srv" / "bound-session-ws" + bound_ws.mkdir(parents=True) + global_lw = env["tmp"] / "global-state" + global_lw.mkdir(parents=True) + # Both global state files must move TOGETHER: in production + # _GLOBAL_WS_FILE and _GLOBAL_LW_FILE share one state dir. + monkeypatch.setattr(workspace, "_GLOBAL_WS_FILE", global_lw / "workspaces.json") + monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_lw / "last_workspace.txt") + (global_lw / "last_workspace.txt").write_text(str(bound_ws), encoding="utf-8") + + # POISONED base-home binding must not win. + poisoned_ws = env["tmp"] / "srv" / "poisoned-base-ws" + poisoned_ws.mkdir(parents=True) + base_state = env["base"] / "webui_state" + base_state.mkdir(parents=True) + (base_state / "last_workspace.txt").write_text(str(poisoned_ws), encoding="utf-8") + + s = models.new_session(profile="default") + assert s.profile == "default" + assert str(s.workspace) == str(bound_ws.resolve()), ( + f"new_session(profile='default') in isolated mode must bind the " + f"GLOBAL state authority's workspace, got {s.workspace!r}" + ) + assert str(s.workspace) != str(poisoned_ws.resolve()), ( + "must never leak onto the BASE home's webui_state binding" + ) + + def test_session_config_reads_pinned_config( + self, isolated_default_pinned, monkeypatch + ): + """Config for the resolved 'default' home comes from the PINNED home. + + In isolated mode the pinned home IS the active home, so + get_config_for_profile_home takes the ambient-match branch — which is + exactly the authority we must verify: the ambient resolver (and its + HERMES_CONFIG_PATH) must be anchored at the PINNED home, never at the + base/root one. Point the authoritative config INSIDE the pinned home; + had the resolver leaked to the base home, this config would not win. + """ + from api import config as cfg_mod + from api.config import get_config_for_profile_home + + env = isolated_default_pinned + monkeypatch.setenv("HERMES_CONFIG_PATH", str(env["pinned"] / "config.yaml")) + cfg_mod.reload_config() + + home = workspace._resolve_profile_home_param("default") + assert home == env["pinned"].resolve() + got = get_config_for_profile_home(home) + assert got.get("workspace") == "/srv/pinned-workspace", ( + "config for the resolved 'default' home must be anchored at the " + "PINNED profile's home/config, never the base root's" + ) + # The BASE root has no config.yaml — a leak onto the base home would + # have produced the defaults-only dict (no 'workspace' key). + + +class TestRound7DefaultStateRouting: + """Explicit and ambient "default" STATE I/O share ONE authority (round 7). + + Round-6 CORE follow-up: routing the literal "default" name through the + delegated resolver made the canonical-home check in + ``_profile_state_dir`` send EXPLICIT ``profile="default"`` state + reads/writes to ``{pinned}/webui_state/`` while AMBIENT calls kept using + the global state dir — splitting saved workspaces between two + authorities and hiding the pre-upgrade list. Fix under test: + literal-"default" STATE routing stays on the global files, while config + and workspace PATH resolution keep the pinned home. + """ + + def _seed_global_state(self, env, monkeypatch): + """Point both global state files at an isolated tmp dir.""" + g = env["tmp"] / "global-state" + g.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(workspace, "_GLOBAL_WS_FILE", g / "workspaces.json") + monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", g / "last_workspace.txt") + return g + + def test_explicit_default_state_dir_is_global(self, isolated_default_pinned, monkeypatch): + env = isolated_default_pinned + g = self._seed_global_state(env, monkeypatch) + assert workspace._profile_state_dir(profile="default") == g, ( + "explicit profile='default' must route STATE to the global dir " + "(same authority as ambient), not {pinned}/webui_state/" + ) + assert workspace._workspaces_file("default") == g / "workspaces.json" + assert workspace._last_workspace_file("default") == g / "last_workspace.txt" + + def test_explicit_and_ambient_share_workspaces_authority( + self, isolated_default_pinned, monkeypatch + ): + import json + + env = isolated_default_pinned + g = self._seed_global_state(env, monkeypatch) + + # Pre-upgrade saved list lives ONLY in the global file. + pre_dir = env["tmp"] / "srv" / "pre-upgrade" + pre_dir.mkdir(parents=True) + pre = [{"path": str(pre_dir), "name": "Pre"}] + (g / "workspaces.json").write_text( + json.dumps(pre, ensure_ascii=False), encoding="utf-8" + ) + + # Explicit profile="default" reads the pre-upgrade list... + assert workspace.load_workspaces(profile="default") == pre + + # ...and explicit writes land in the SAME global file. + added = env["tmp"] / "srv" / "added" + added.mkdir(parents=True) + updated = pre + [{"path": str(added), "name": "Added"}] + workspace.save_workspaces(updated, profile="default") + assert json.loads((g / "workspaces.json").read_text(encoding="utf-8")) == updated + + # Ambient calls see the identical authority — no split. + assert workspace._profile_state_dir(None) == g + assert workspace.load_workspaces() == updated + + # No divergent webui_state authority may appear under the pinned home. + assert not (env["pinned"] / "webui_state" / "workspaces.json").exists() + assert not (env["pinned"] / "webui_state" / "last_workspace.txt").exists() + + def test_last_workspace_round_trip_shared_explicit_ambient( + self, isolated_default_pinned, monkeypatch + ): + env = isolated_default_pinned + g = self._seed_global_state(env, monkeypatch) + + ws = env["tmp"] / "srv" / "bound-ws" + ws.mkdir(parents=True) + + workspace.set_last_workspace(str(ws), profile="default") + assert (g / "last_workspace.txt").exists(), ( + "set_last_workspace('default') must write the GLOBAL file" + ) + assert not (env["pinned"] / "webui_state" / "last_workspace.txt").exists() + + assert workspace.get_last_workspace(profile="default") == str(ws) + # Ambient (isolated mode's active profile is literally "default") + # resolves to the same binding. + assert workspace.get_last_workspace() == str(ws) diff --git a/tests/test_profile_switch_1200.py b/tests/test_profile_switch_1200.py index 9c3f3801..e1c70e9c 100644 --- a/tests/test_profile_switch_1200.py +++ b/tests/test_profile_switch_1200.py @@ -696,7 +696,7 @@ def test_chat_start_retags_empty_session_to_request_profile(monkeypatch, tmp_pat "_resolve_compatible_session_model_state", lambda model, provider, **_: (model, provider, False), ) - monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) + monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None) monkeypatch.setattr(routes, "create_stream_channel", lambda: object()) started_threads = [] @@ -768,7 +768,7 @@ def test_chat_start_does_not_retag_non_empty_session(monkeypatch, tmp_path): "_resolve_compatible_session_model_state", lambda model, provider, **_: (model, provider, False), ) - monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) + monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None) monkeypatch.setattr(routes, "create_stream_channel", lambda: object()) class FakeThread: diff --git a/tests/test_provider_mismatch.py b/tests/test_provider_mismatch.py index 27e7a2e1..d22be3b9 100644 --- a/tests/test_provider_mismatch.py +++ b/tests/test_provider_mismatch.py @@ -1059,9 +1059,9 @@ def test_issue1734_chat_start_persists_repaired_codex_provider(monkeypatch): "_resolve_chat_workspace_with_recovery", lambda current, _requested: current.workspace, ) - monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: value) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: value) monkeypatch.setattr(routes, "_get_session_agent_lock", lambda sid: contextlib.nullcontext()) - monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) + monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None) monkeypatch.setattr(routes, "create_stream_channel", lambda: object()) monkeypatch.setattr(routes.threading, "Thread", FakeThread) diff --git a/tests/test_recovered_anchor_settle_duplication.py b/tests/test_recovered_anchor_settle_duplication.py index 10590a44..5dd7253c 100644 --- a/tests/test_recovered_anchor_settle_duplication.py +++ b/tests/test_recovered_anchor_settle_duplication.py @@ -537,7 +537,7 @@ def test_handle_chat_sync_passes_result_turn_authority_to_settlement(tmp_path, m monkeypatch.setattr(routes, "get_session", models.get_session) monkeypatch.setattr(routes, "title_from", models.title_from) monkeypatch.setattr(routes, "get_config", lambda: {"model": "m", "provider": "p"}) - monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: tmp_path) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value, **_kw: tmp_path) monkeypatch.setattr(routes, "load_settings", lambda: {}) monkeypatch.setattr(routes, "_resolve_cli_toolsets", lambda: []) monkeypatch.setattr(routes, "_agent_runtime_barrier_response", lambda **_k: None) diff --git a/tests/test_remote_terminal_workspace.py b/tests/test_remote_terminal_workspace.py index 2b896ce5..01475fb1 100644 --- a/tests/test_remote_terminal_workspace.py +++ b/tests/test_remote_terminal_workspace.py @@ -1,4 +1,6 @@ from pathlib import Path +import json +import pathlib import pytest @@ -155,7 +157,7 @@ def test_remote_terminal_linux_home_preserves_path_without_macos_synthetic_resol lambda: _remote_config(terminal={"backend": "ssh", "cwd": "/home/developer"}), ) - real_resolve = workspace._resolve_path + real_resolve = workspace._safe_resolve def fake_resolve(p): p_str = str(p) @@ -163,12 +165,1238 @@ def test_remote_terminal_linux_home_preserves_path_without_macos_synthetic_resol return Path(f"/System/Volumes/Data{p_str}") return real_resolve(p) - monkeypatch.setattr(workspace, "_resolve_path", fake_resolve) + monkeypatch.setattr(workspace, "_safe_resolve", fake_resolve) assert workspace.validate_workspace_to_add("/home/developer") == Path("/home/developer") assert workspace.resolve_trusted_workspace("/home/developer") == Path("/home/developer") assert workspace.get_profile_default_workspace() == "/home/developer" + assert workspace._resolve_path("/home/developer") == Path("/home/developer") assert workspace._clean_workspace_list([{"path": "/home/developer", "name": "Dev"}]) == [ {"path": "/home/developer", "name": "Dev"} ] + +def test_session_init_and_created_workspace_preserve_remote_posix_path(monkeypatch): + """Session initialization must not corrupt remote POSIX paths to macOS synthetic firmlinks.""" + monkeypatch.setattr( + api_config, + "get_config", + lambda: _remote_config(terminal={"backend": "ssh", "cwd": "/home/rootson"}), + ) + + from api.models import Session + + s = Session(workspace="/home/rootson", created_workspace="/home/rootson") + assert s.workspace == "/home/rootson" + assert s.created_workspace == "/home/rootson" + + +def test_named_profile_remote_terminal_workspace_candidate_isolated(monkeypatch, tmp_path): + """Remote paths must resolve per profile: remote for named remote profile, not for active local profile.""" + # Active profile is local + monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": "/Users/local"}}) + + # Named profile 'optiplex' under base home + profiles_dir = tmp_path / "profiles" / "optiplex" + profiles_dir.mkdir(parents=True) + (profiles_dir / "config.yaml").write_text( + "terminal:\n backend: ssh\n cwd: /home/rootson\n", encoding="utf-8" + ) + + from api import profiles + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path) + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path) + + # Scoped to named remote profile: candidate is recognized as remote + cand_optiplex = workspace._remote_terminal_workspace_candidate( + "/home/rootson/projects/app", profile="optiplex" + ) + assert cand_optiplex == Path("/home/rootson/projects/app") + + # Scoped to active local profile: candidate is NOT recognized as remote (prevents cross-profile bypass) + cand_local = workspace._remote_terminal_workspace_candidate( + "/home/rootson/projects/app", profile=None + ) + assert cand_local is None + + # Local validation fails on nonexistent path when active profile is local + with pytest.raises(ValueError, match="Path does not exist"): + workspace.validate_workspace_to_add("/home/rootson/projects/app", profile=None) + + # Remote validation succeeds when profile is optiplex + assert workspace.validate_workspace_to_add( + "/home/rootson/projects/app", profile="optiplex" + ) == Path("/home/rootson/projects/app") + + +def test_build_native_multimodal_message_preserves_remote_workspace(monkeypatch, tmp_path): + """Multimodal message builder must scope workspace resolution to the session profile.""" + # Active ambient profile is local + monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": "/Users/local"}}) + + profiles_dir = tmp_path / "profiles" / "optiplex" + profiles_dir.mkdir(parents=True) + (profiles_dir / "config.yaml").write_text( + "terminal:\n backend: ssh\n cwd: /home/rootson\n", encoding="utf-8" + ) + + from api import profiles, streaming + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path) + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path) + + # 1. Profile as logical name string + msg1 = streaming._build_native_multimodal_message( + "[Workspace::v1: /home/rootson]\n", + "hello", + attachments=[], + workspace="/home/rootson", + profile="optiplex", + ) + assert "[Workspace::v1: /home/rootson]" in msg1 + + # 2. Profile as filesystem path string (_profile_home) + msg2 = streaming._build_native_multimodal_message( + "[Workspace::v1: /home/rootson]\n", + "hello", + attachments=[], + workspace="/home/rootson", + profile=str(profiles_dir), + ) + assert "[Workspace::v1: /home/rootson]" in msg2 + + # 3. Profile as Path object + msg3 = streaming._build_native_multimodal_message( + "[Workspace::v1: /home/rootson]\n", + "hello", + attachments=[], + workspace="/home/rootson", + profile=profiles_dir, + ) + assert "[Workspace::v1: /home/rootson]" in msg3 + + +def test_resolve_profile_home_param_formats(tmp_path): + """_resolve_profile_home_param handles None, default, profile name, and Path. + + Round 5 (#7168): path-shaped STRINGS are no longer accepted as explicit + homes — a string is strictly a logical profile id (grammar-checked), so an + explicit home must be expressed as a Path value. + """ + from api.profiles import _DEFAULT_HERMES_HOME + + assert workspace._resolve_profile_home_param(None) == _DEFAULT_HERMES_HOME + assert workspace._resolve_profile_home_param("default") == _DEFAULT_HERMES_HOME + # Path-shaped string: rejected by the logical-id grammar. + with pytest.raises(ValueError): + workspace._resolve_profile_home_param(str(tmp_path / "profiles/custom")) + # Same location as a Path value: honored and canonicalized. + assert workspace._resolve_profile_home_param(tmp_path / "profiles/custom") == ( + tmp_path / "profiles/custom" + ) + + +def test_remote_profile_a_cannot_use_remote_profile_b_cwd(monkeypatch, tmp_path): + """Active remote profile Alice (/srv/remote-alice) cannot validate paths under remote profile Bob (/srv/remote-bob).""" + profiles_alice = tmp_path / "profiles" / "alice" + profiles_alice.mkdir(parents=True) + (profiles_alice / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /srv/remote-alice\n", encoding="utf-8") + + profiles_bob = tmp_path / "profiles" / "bob" + profiles_bob.mkdir(parents=True) + (profiles_bob / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /srv/remote-bob\n", encoding="utf-8") + + from api import profiles + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path) + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path) + + # Scoped to Alice: Bob's path must be rejected + with pytest.raises(ValueError, match="Path does not exist"): + workspace.validate_workspace_to_add("/srv/remote-bob/project", profile="alice") + + with pytest.raises(ValueError, match="Path does not exist"): + workspace.resolve_trusted_workspace("/srv/remote-bob/project", profile="alice") + + # Scoped to Alice: Alice's own path succeeds + assert workspace.validate_workspace_to_add("/srv/remote-alice/project", profile="alice") == Path("/srv/remote-alice/project") + assert workspace.resolve_trusted_workspace("/srv/remote-alice/project", profile="alice") == Path("/srv/remote-alice/project") + + +def test_local_symlink_resolving_to_system_path_is_rejected(monkeypatch, tmp_path): + """Local symlink pointing to a system directory (/etc) is strictly rejected.""" + # Active profile is local + monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": str(tmp_path)}}) + monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path / "home") + + symlink_to_etc = tmp_path / "symlink_etc" + try: + symlink_to_etc.symlink_to("/etc") + except OSError: + pytest.skip("Symlink creation requires permissions") + + with pytest.raises(ValueError, match="Path points to a system directory"): + workspace.validate_workspace_to_add(str(symlink_to_etc), profile=None) + + with pytest.raises(ValueError, match="Path points to a system directory"): + workspace.resolve_trusted_workspace(str(symlink_to_etc), profile=None) + + +def test_local_profile_add_workspace_auto_create_on_remote_path_collision(monkeypatch, tmp_path): + """Active local profile adding a path beneath an inactive remote profile's cwd still creates local directory.""" + # Active profile is local + monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": str(tmp_path)}}) + + # Inactive remote profile 'optiplex' has cwd /tmp/remote-test + profiles_dir = tmp_path / "profiles" / "optiplex" + profiles_dir.mkdir(parents=True) + (profiles_dir / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /tmp/remote-test\n", encoding="utf-8") + + from api import profiles + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path) + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path) + + local_target = tmp_path / "remote-test" / "subproject" + assert not local_target.exists() + + from api.routes import _handle_workspace_add + + class MockHandler: + def __init__(self): + self.response = None + def send_response(self, *args): pass + def send_header(self, *args): pass + def end_headers(self): pass + @property + def wfile(self): + class W: + def write(self, b): pass + return W() + + handler = MockHandler() + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default") + monkeypatch.setattr(workspace, "_workspaces_file", lambda: tmp_path / "workspaces.json") + monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) + + _handle_workspace_add(handler, {"path": str(local_target), "create": True}) + assert local_target.is_dir() + + +def test_detached_streaming_worker_preserves_session_profile_workspace(monkeypatch, tmp_path): + """Detached streaming worker preserves session profile Alice (/srv/remote-alice) even with ambient local profile.""" + # Ambient process profile is local + monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": "/Users/local"}}) + + profiles_dir = tmp_path / "profiles" / "alice" + profiles_dir.mkdir(parents=True) + (profiles_dir / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /srv/remote-alice\n", encoding="utf-8") + + from api import profiles, models + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path) + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path) + + # Simulate macOS firmlink expansion on host resolve + real_resolve = workspace._safe_resolve + def fake_resolve(p): + p_str = str(p) + if p_str == "/srv/remote-alice" or p_str.startswith("/srv/remote-alice/"): + return Path(f"/System/Volumes/Data{p_str}") + return real_resolve(p) + + monkeypatch.setattr(workspace, "_safe_resolve", fake_resolve) + + s = models.Session(session_id="test1234", workspace="/srv/remote-alice", profile="alice") + assert s.workspace == "/srv/remote-alice" + assert s.created_workspace == "/srv/remote-alice" + + # Streaming workspace update + resolved_ws = workspace._resolve_path("/srv/remote-alice", profile=s.profile) + assert str(resolved_ws) == "/srv/remote-alice" + + from api import streaming + turn_ws = streaming._resolve_path("/srv/remote-alice", profile=getattr(s, "profile", None)) + assert str(turn_ws) == "/srv/remote-alice" + + pytest.importorskip("agent.runtime_cwd", reason="hermes-agent not installed") + tokens = streaming._set_turn_session_identity(s.session_id, workspace=str(turn_ws)) + try: + from agent.runtime_cwd import _SESSION_CWD + assert _SESSION_CWD.get() == "/srv/remote-alice" + finally: + streaming._reset_turn_session_identity(tokens) + + +def test_gateway_multimodal_message_preserves_remote_profile_workspace(monkeypatch, tmp_path): + """Gateway chat multimodal payload builder preserves remote profile workspace containment.""" + # Ambient process profile is local + monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": "/Users/local"}}) + + profiles_dir = tmp_path / "profiles" / "optiplex" + profiles_dir.mkdir(parents=True) + (profiles_dir / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /home/rootson\n", encoding="utf-8") + + from api import profiles, streaming, models + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path) + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path) + + s = models.Session(session_id="gw_test", workspace="/home/rootson", profile="optiplex") + + msg = streaming._build_native_multimodal_message( + "", + "hello", + attachments=[], + workspace=str(s.workspace), + profile=getattr(s, "profile", None), + ) + assert msg == "hello" + + +def test_isolated_profile_config_read_does_not_mutate_or_read_shared_cache(monkeypatch, tmp_path): + """Explicit profile config lookup must not leak into or read from mutable global config cache.""" + alice_home = tmp_path / "profiles" / "alice" + alice_home.mkdir(parents=True) + (alice_home / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /srv/remote-alice\n", encoding="utf-8") + + bob_home = tmp_path / "profiles" / "bob" + bob_home.mkdir(parents=True) + (bob_home / "config.yaml").write_text("terminal:\n backend: local\n cwd: /Users/bob\n", encoding="utf-8") + + from api import profiles + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path) + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path) + + # NO ambient TLS profile: simulates a detached worker resolving an explicit + # session profile while the process-global active profile is 'default' + # (local). Alice diverges from the ambient target, so her on-disk + # config.yaml is authoritative — regardless of any concurrent reload. + monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": "/Users/local"}}) + + # 1. alice resolves to remote cwd (divergent profile reads its own disk file) + assert workspace._remote_terminal_cwd("alice") == "/srv/remote-alice" + + # 2. bob resolves to None (local backend) + assert workspace._remote_terminal_cwd("bob") is None + + # 3. Simulate concurrent reload on global _cfg_cache pointing to dirty state + monkeypatch.setattr(api_config, "_cfg_cache", {"terminal": {"backend": "local", "cwd": "/Users/polluted"}}) + + # Explicit lookup of alice still resolves to alice's on-disk terminal cwd, not polluted global cache + assert workspace._remote_terminal_cwd("alice") == "/srv/remote-alice" + assert workspace.validate_workspace_to_add("/srv/remote-alice/sub", profile="alice") == Path("/srv/remote-alice/sub") + + +def test_workspace_routes_profile_isolation(monkeypatch, tmp_path): + """Workspace route actions (add, remove, rename, reorder) must be fully isolated per profile.""" + from api import profiles + from api.routes import ( + _handle_workspace_add, + ) + + alice_home = tmp_path / "profiles" / "alice" + alice_home.mkdir(parents=True) + (alice_home / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /srv/remote-alice\n", encoding="utf-8") + + bob_home = tmp_path / "profiles" / "bob" + bob_home.mkdir(parents=True) + (bob_home / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /srv/remote-bob\n", encoding="utf-8") + + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path) + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path) + monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) + + # Ambient get_config() follows the per-request TLS profile exactly as + # production does (server.py sets the cookie context; the config loader + # resolves the active home). With the restored upstream authority rules, + # an ambient-target lookup delegates here — so each simulated request + # must see ITS OWN profile's terminal block. + def _ambient_cfg(): + name = profiles.get_active_profile_name() + if name == "bob": + return {"terminal": {"backend": "ssh", "cwd": "/srv/remote-bob"}} + if name == "alice": + return {"terminal": {"backend": "ssh", "cwd": "/srv/remote-alice"}} + return {"terminal": {"backend": "local", "cwd": str(tmp_path)}} + + monkeypatch.setattr(api_config, "get_config", _ambient_cfg) + + class MockHandler: + def __init__(self): + self.status = 200 + self.data = None + def send_response(self, code): self.status = code + def send_header(self, *args): pass + def end_headers(self): pass + @property + def wfile(self): + class W: + def write(inner_self, b): pass + return W() + + # Add workspace under Alice — TLS profile is alice, so the route's + # get_active_profile_name() == 'alice' and the add writes into alice's + # profile-scoped workspaces.json. + profiles.set_request_profile("alice") + try: + h1 = MockHandler() + _handle_workspace_add(h1, {"path": "/srv/remote-alice/project1", "name": "Project 1"}) + assert len(workspace.load_workspaces(profile="alice")) == 2 + assert any(w["path"] == "/srv/remote-alice/project1" for w in workspace.load_workspaces(profile="alice")) + finally: + profiles.clear_request_profile() + + # Bob's workspaces must be clean and not contain Alice's workspace + profiles.set_request_profile("bob") + try: + h2 = MockHandler() + assert len(workspace.load_workspaces(profile="bob")) == 1 + assert workspace.load_workspaces(profile="bob")[0]["path"] == "/srv/remote-bob" # Default workspace for bob + + _handle_workspace_add(h2, {"path": "/srv/remote-bob/app", "name": "Bob App"}) + bob_ws = workspace.load_workspaces(profile="bob") + assert any(w["path"] == "/srv/remote-bob/app" for w in bob_ws) + assert not any("remote-alice" in w["path"] for w in bob_ws) + finally: + profiles.clear_request_profile() + + # Re-verify Alice's workspaces did not get Bob's workspace + profiles.set_request_profile("alice") + try: + alice_ws = workspace.load_workspaces(profile="alice") + assert len(alice_ws) == 2 + assert any(w["path"] == "/srv/remote-alice/project1" for w in alice_ws) + assert not any("remote-bob" in w["path"] for w in alice_ws) + finally: + profiles.clear_request_profile() + + +def test_saved_workspace_trust_scoped_to_session_profile(monkeypatch, tmp_path): + """resolve_trusted_workspace's saved-workspace branch (B) must honor the explicit profile. + + Greptile #3838260381: when a session profile differs from the ambient + profile, the saved-workspace authorization branch called load_workspaces() + and _resolve_path() WITHOUT the session profile — so a session-owned + external workspace was rejected and a path saved only by the ambient + profile was wrongly trusted instead. + """ + from api import profiles + + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path) + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path) + monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path / "home") + (tmp_path / "home").mkdir(parents=True) + + # Session profile 'carol': remote SSH with cwd /srv/remote-carol. + carol_home = tmp_path / "profiles" / "carol" + carol_home.mkdir(parents=True) + (carol_home / "config.yaml").write_text( + "terminal:\n backend: ssh\n cwd: /srv/remote-carol\n", encoding="utf-8" + ) + # Ambient/default: local backend so nothing is remotely classified. + monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": str(tmp_path)}}) + + # Carol's saved list includes an external (non-home, non-cwd) workspace dir. + external_dir = tmp_path / "data" / "carol-projects" + external_dir.mkdir(parents=True) + ws_file = tmp_path / "state" + monkeypatch.setattr(workspace, "_workspaces_file_for_profile", lambda p=None: ws_file) + ws_file.parent.mkdir(parents=True, exist_ok=True) + ws_file.write_text( + json.dumps([{"path": str(external_dir), "name": "Carol External"}]), + encoding="utf-8", + ) + + # With the session profile passed explicitly, the saved branch trusts it. + resolved = workspace.resolve_trusted_workspace(str(external_dir), profile="carol") + assert resolved == Path(str(external_dir)).resolve() + + # Without the profile (ambient local), the same path is NOT trusted: + # it lives outside home/boot-default and the ambient profile has no such save. + monkeypatch.setattr(workspace, "_workspaces_file_for_profile", lambda p=None: tmp_path / "missing-workspaces.json") + with pytest.raises(ValueError, match="outside the user home directory"): + workspace.resolve_trusted_workspace(str(external_dir)) + + +def test_session_init_simulated_macos_resolution_is_red_on_master_shape(monkeypatch): + """Session.__init__ must preserve /home/ even when host resolve rewrites it. + + Maintainer gate r4 item 3: this test simulates macOS synthetic firmlink + expansion on the HOST resolver. On origin/master's shape + (Path(workspace).expanduser().resolve()) the assertion fails because the + stored workspace becomes /System/Volumes/Data/home/rootson; on this head + (_resolve_path with profile-scoped remote preservation) it passes — i.e. + genuinely RED -> GREEN across the fix boundary. + """ + monkeypatch.setattr( + api_config, + "get_config", + lambda: _remote_config(terminal={"backend": "ssh", "cwd": "/home/rootson"}), + ) + + real_resolve = workspace._safe_resolve + + def fake_resolve(p): + p_str = str(p) + if p_str == "/home/rootson" or p_str.startswith("/home/rootson/"): + return Path(f"/System/Volumes/Data{p_str}") + return real_resolve(p) + + monkeypatch.setattr(workspace, "_safe_resolve", fake_resolve) + + from api.models import Session + + s = Session(workspace="/home/rootson", created_workspace="/home/rootson") + assert s.workspace == "/home/rootson" + assert s.created_workspace == "/home/rootson" + + +def test_local_tilde_and_relative_paths_still_host_resolve(monkeypatch, tmp_path): + """Control: local ~/ paths keep host resolution while remotes are preserved. + + Guards the discriminator BOTH ways (maintainer gate r4): the remote-path + preservation must not swallow ordinary local path semantics — tilde + expansion and symlink normalization still run for local profiles. + """ + real_home = tmp_path / "users" / "local" + projects = real_home / "projects" + projects.mkdir(parents=True) + # A symlink inside home pointing elsewhere-under-home must normalize. + linked = real_home / "linked-projects" + try: + linked.symlink_to(projects) + except OSError: + pytest.skip("Symlink creation requires permissions") + + monkeypatch.setenv("HOME", str(real_home)) + monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": str(projects)}}) + + # Tilde expansion still resolves against the LOCAL home. + resolved = workspace._resolve_path("~/projects") + assert resolved == projects.resolve() + + # Symlinks under home still collapse to their real target. + resolved_link = workspace._resolve_path("~/linked-projects") + assert resolved_link == projects.resolve() + + +def test_config_authority_active_home_with_external_override(monkeypatch, tmp_path): + """Maintainer blocker 1: an authoritative HERMES_CONFIG_PATH override wins + over target/config.yaml when resolving the ACTIVE profile home.""" + from api import config as cfg, profiles as profiles_mod + import yaml + + active_home = tmp_path / "active-home" + active_home.mkdir() + (active_home / "config.yaml").write_text( + yaml.safe_dump({"terminal": {"backend": "ssh", "cwd": "/srv/from-active-home"}}, sort_keys=False), + encoding="utf-8", + ) + override_dir = tmp_path / "override-dir" + override_dir.mkdir() + override_cfg = override_dir / "config.yaml" + override_cfg.write_text( + yaml.safe_dump({"terminal": {"backend": "docker", "cwd": "/srv/from-override"}}, sort_keys=False), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_CONFIG_PATH", str(override_cfg)) + monkeypatch.setattr(profiles_mod, "get_active_hermes_home", lambda: active_home) + cfg.reload_config() + + result = cfg.get_config_for_profile_home(active_home) + assert result.get("terminal", {}).get("cwd") == "/srv/from-override" + # NOTE: no manual delenv/reload here — monkeypatch restores + # HERMES_CONFIG_PATH at teardown, and the stale (_cfg_path vs pinned) + # check invalidates the cache on the next reader. A trailing + # reload_config() would bake this test's override into the global cache + # and pollute later tests (#7168 review triage). + + +def test_divergent_profile_without_config_yaml_stays_isolated(monkeypatch, tmp_path): + """Maintainer blocker 2: an existing divergent named-profile directory with + NO config.yaml must NOT inherit the ambient default's terminal config.""" + from api import config as cfg, profiles as profiles_mod + + default_home = tmp_path / "default-home" + default_home.mkdir(parents=True) + (default_home / "config.yaml").write_text( + "terminal:\n backend: ssh\n cwd: /srv/remote-default\n", + encoding="utf-8", + ) + # Fresh non-cloned named profile: exists on disk, no config.yaml yet. + fresh_profile_home = tmp_path / "profiles" / "fresh" + fresh_profile_home.mkdir(parents=True) + + monkeypatch.setenv("HERMES_CONFIG_PATH", str(default_home / "config.yaml")) + monkeypatch.setattr(profiles_mod, "get_active_hermes_home", lambda: default_home) + cfg.reload_config() + + result = cfg.get_config_for_profile_home(fresh_profile_home) + # Defaults applied, but crucially NOT the ambient default profile's terminal block. + assert result.get("terminal") != {"backend": "ssh", "cwd": "/srv/remote-default"} + assert not result.get("terminal", {}).get("cwd") + + # And a divergent home that doesn't exist at all still returns {}. + assert cfg.get_config_for_profile_home(tmp_path / "profiles" / "ghost") == {} + # NOTE: no manual delenv/reload here (see test_config_authority_active_home_with_external_override) — + # monkeypatch teardown + path-change invalidation keep the global cache clean. + + +def test_suggestions_scoped_to_explicit_profile_saved_roots(tmp_path): + """Sweep regression (#7168 review): list_workspace_suggestions with an + explicit profile must widen trust only via THAT profile's saved + workspaces — a workspace saved under a different profile stays out.""" + other_dir = tmp_path / "other-profile-dir" + other_dir.mkdir(parents=True) + mine = tmp_path / "mine" + mine.mkdir() + + ws_file = workspace._workspaces_file_for_profile("alice") + ws_file.parent.mkdir(parents=True, exist_ok=True) + ws_file.write_text(json.dumps([{"path": str(other_dir)}]), encoding="utf-8") + + prefix = str(tmp_path) + "/" + suggestions = workspace.list_workspace_suggestions(prefix, profile="alice") + assert any(s.endswith("other-profile-dir") for s in suggestions) + + # A different profile's saved list does NOT include alice's external dir. + suggestions_bob = workspace.list_workspace_suggestions(prefix, profile="bob") + assert not any(s.endswith("other-profile-dir") for s in suggestions_bob) + assert mine.exists() # control: fixture dir is real, absence is scoping + + +def test_recovery_helper_threads_profile_to_trust_and_fallback(tmp_path): + """Sweep regression (#7168 review): resolve_implicit_workspace_with_recovery + passes the profile into both trust resolution and the recovery fallback, + so a deleted session workspace recovers to a fallback trusted for THAT + profile — never via another profile's saved list.""" + external = tmp_path / "external-carol" + external.mkdir() + stale = tmp_path / "stale-workspace" + fallback_dir = tmp_path / "fallback" + fallback_dir.mkdir() + + ws_file = workspace._workspaces_file_for_profile("carol") + ws_file.parent.mkdir(parents=True, exist_ok=True) + ws_file.write_text( + json.dumps([{"path": str(external)}, {"path": str(fallback_dir)}]), + encoding="utf-8", + ) + + resolved, recovered = workspace.resolve_implicit_workspace_with_recovery( + str(stale), + lambda: str(fallback_dir), + profile="carol", + ) + assert recovered is True + assert resolved == fallback_dir.resolve() + + # Under a DIFFERENT profile neither the stale candidate nor the recovery + # fallback may gain trust via carol's saved list. + with pytest.raises(ValueError): + workspace.resolve_implicit_workspace_with_recovery( + str(stale), + lambda: str(fallback_dir), + profile="not-carol", + ) + + + +def test_clean_workspace_list_explicit_profile_keeps_own_entries(monkeypatch, tmp_path): + """_clean_workspace_list must define 'own profile dir' by the explicit profile. + + Maintainer re-gate 2026-08-25 (CORE / data-loss): with an explicit + profile="alice", own_profile_dir was still derived from the AMBIENT home. + Loading Alice's saved list while ambient Bob was active dropped Alice's own + workspace (it sat under Alice's home, not Bob's) and load_workspaces() + persisted the emptied list back to disk — silent destruction of a + profile's saved workspaces. + + Regression: Alice request / Bob ambient — Alice's own entry survives and + is persisted intact; a genuinely foreign profile path is still removed. + """ + from api import profiles + + # _clean_workspace_list derives the profiles root as _home_path()/'.hermes'/profiles. + monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path / ".hermes") + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path / ".hermes") + (tmp_path / ".hermes" / "profiles").mkdir(parents=True) + + alice_home = tmp_path / ".hermes" / "profiles" / "alice" + alice_home.mkdir(parents=True) + bob_home = tmp_path / ".hermes" / "profiles" / "bob" + bob_home.mkdir(parents=True) + + # Alice owns a workspace that lives inside her OWN profile directory. + alice_own = alice_home / "projects" + alice_own.mkdir(parents=True) + # A foreign entry pointing into BOB's profile dir must still be pruned. + foreign = bob_home / "secret" + foreign.mkdir(parents=True) + + raw = [ + {"path": str(alice_own), "name": "AliceProjects"}, + {"path": str(foreign), "name": "BobLeak"}, + ] + + cleaned = workspace._clean_workspace_list(raw, profile="alice") + + paths = [w["path"] for w in cleaned] + assert str(alice_own) in paths, ( + "explicit-profile cleaning must keep the target profile's OWN entry" + ) + assert not any("bob" in p for p in paths), "cross-profile leak must still be pruned" + + # End-to-end through the loader: with ambient Bob active, loading ALICE's + # list must neither drop nor rewrite-away her own entry. + monkeypatch.setattr( + profiles, "get_active_profile_name", lambda: "bob", raising=False + ) + ws_file = tmp_path / "state" / "workspaces.json" + monkeypatch.setattr(workspace, "_workspaces_file_for_profile", lambda p=None: ws_file) + ws_file.parent.mkdir(parents=True, exist_ok=True) + ws_file.write_text(json.dumps(raw), encoding="utf-8") + + loaded = workspace.load_workspaces(profile="alice") + loaded_paths = [w["path"] for w in loaded] + assert str(alice_own) in loaded_paths + on_disk = json.loads(ws_file.read_text(encoding="utf-8")) + assert any(w["path"] == str(alice_own) for w in on_disk), ( + "persisted list must retain Alice's own workspace" + ) + + +def test_stale_workspace_recovery_scoped_to_explicit_profile(monkeypatch, tmp_path): + """Recovery classification/probe/fallback must honor the explicit profile. + + Maintainer re-gate 2026-08-25 (CORE): resolve_implicit_workspace_with_recovery + classified the backend via ambient get_config(), probed candidate paths + without profile=, and fell back to an unbound getter. With profile="alice" + under ambient Bob it returned BOB's last workspace as recovered=True, and an + Alice remote backend without terminal.cwd was misclassified as local. + + Regression A (remote misclassification): alice is ssh WITHOUT cwd, ambient + is local — recovery must fail closed preserving the original error. + Regression B (cross-profile bind): alice local, stored path missing, + fallback=get_last_workspace — must never return Bob's path nor claim + recovered=True for it. + """ + from api import profiles + + monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path / ".hermes") + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path / ".hermes") + (tmp_path / ".hermes" / "profiles").mkdir(parents=True) + + alice_home = tmp_path / ".hermes" / "profiles" / "alice" + alice_home.mkdir(parents=True) + bob_home = tmp_path / ".hermes" / "profiles" / "bob" + bob_home.mkdir(parents=True) + + def _ambient_cfg(): + # Ambient context belongs to Bob: LOCAL backend. + return {"terminal": {"backend": "local"}} + + monkeypatch.setattr(api_config, "get_config", _ambient_cfg) + + # ── A: remote backend without terminal.cwd fails closed ── + (alice_home / "config.yaml").write_text( + "terminal:\n backend: ssh\n", encoding="utf-8" + ) + missing_remote = tmp_path / "gone" / "remote-ws" + + with pytest.raises(ValueError): + workspace.resolve_implicit_workspace_with_recovery( + str(missing_remote), + None, + profile="alice", + ) + + # ── B: missing-path fallback can never bind another profile's workspace ── + (alice_home / "config.yaml").write_text( + "terminal:\n backend: local\n", encoding="utf-8" + ) + stored_missing = tmp_path / "data" / "vanished" + bob_ws = tmp_path / "srv" / "bobs-place" + bob_ws.mkdir(parents=True) + alice_ws = tmp_path / "srv" / "alice-own" + alice_ws.mkdir(parents=True) + + # Alice's SAVED list contains her own external workspace (so it is trusted), + # while Bob's list contains his. The saved-workspace trust branch is itself + # profile-scoped (earlier fix), so each name resolves only its own list. + alice_list = tmp_path / "state-alice" / "workspaces.json" + bob_list = tmp_path / "state-bob" / "workspaces.json" + alice_list.parent.mkdir(parents=True, exist_ok=True) + bob_list.parent.mkdir(parents=True, exist_ok=True) + alice_list.write_text(json.dumps([{"path": str(alice_ws), "name": "AliceOwn"}]), encoding="utf-8") + bob_list.write_text(json.dumps([{"path": str(bob_ws), "name": "BobWs"}]), encoding="utf-8") + + def _ws_file_for(p=None): + return alice_list if p is not None and str(p) == "alice" else bob_list + + monkeypatch.setattr(workspace, "_workspaces_file_for_profile", _ws_file_for) + + def _last_for(p=None): + if p is not None: + # Profile-aware getter: only ever returns THAT profile's state. + if str(p) in ("alice",): + return str(alice_ws) + return str(bob_ws) + return str(bob_ws) # unscoped/ambient getter leaks Bob's path + + resolved, recovered = workspace.resolve_implicit_workspace_with_recovery( + str(stored_missing), + _last_for, + profile="alice", + ) + assert recovered is True + assert resolved == alice_ws.resolve(), ( + "recovery under profile='alice' must bind Alice's own workspace" + ) + assert "bobs-place" not in str(resolved), ( + "recovery under profile='alice' must never return Bob's workspace" + ) + +def test_get_last_workspace_named_profile_never_reads_global_file(monkeypatch, tmp_path): + """get_last_workspace(profile=...) must not fall back to the global file. + + Maintainer re-gate round 3 (CORE): for a named profile the getter still + consulted _GLOBAL_LW_FILE after missing the profile-local file, so a direct + probe returned Bob's globally-recorded workspace for profile="alice". + The legacy global fallback is now allowed ONLY for the root/default + profile. Uses the REAL getter with a real global-file fixture. + """ + from api import profiles + + monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path / ".hermes") + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path / ".hermes") + (tmp_path / ".hermes" / "profiles").mkdir(parents=True) + (tmp_path / ".hermes" / "profiles" / "alice").mkdir(parents=True) + + bobs_dir = tmp_path / "srv" / "bobs-global" + bobs_dir.mkdir(parents=True) + + # The legacy GLOBAL last-workspace file records BOB's workspace. + global_lw = tmp_path / "global-state" + global_lw.mkdir(parents=True) + (global_lw / "last_workspace.txt").write_text(str(bobs_dir), encoding="utf-8") + monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_lw / "last_workspace.txt") + + # Alice has NO profile-local last-workspace file at all. + alice_state = tmp_path / ".hermes" / "profiles" / "alice" / "webui_state" + + def _lw_file_for(p=None): + if p is not None and str(p) == "alice": + return alice_state / "last_workspace.txt" + return global_lw / "last_workspace.txt" + + monkeypatch.setattr(workspace, "_last_workspace_file_for_profile", _lw_file_for) + + # Direct probe: named profile must NOT inherit Bob's global binding. + got = workspace.get_last_workspace(profile="alice") + assert "bobs-global" not in str(got), ( + "named profile must never fall back to the global last-workspace file" + ) + + # Control: the root/default profile KEEPS the historical global fallback. + monkeypatch.setattr( + profiles, + "_resolve_profile_home_param", + lambda p: tmp_path / ".hermes" if p is None or str(p) in ("default",) else _lw_file_for(p).parent.parent.parent, + raising=False, + ) + got_default = workspace.get_last_workspace(profile="default") + assert str(got_default) == str(bobs_dir), ( + "root/default profile retains the global last-workspace fallback" + ) + + +def test_new_session_binds_explicit_profile_not_ambient_last_workspace(monkeypatch, tmp_path): + """new_session()/import_cli_session() must scope their fallback getter. + + Maintainer re-gate round 3 (CORE): both Session constructions fell back to + get_last_workspace() WITHOUT the profile even though profile=profile was + set on the same Session — an Alice session created under ambient Bob bound + to BOB's workspace. Uses the real getters and real state files. + """ + import api.models as models + from api import profiles + + monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path) + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path / ".hermes") + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path / ".hermes") + (tmp_path / ".hermes" / "profiles").mkdir(parents=True) + (tmp_path / ".hermes" / "profiles" / "alice").mkdir(parents=True) + + bobs_ws = tmp_path / "srv" / "bobs-workspace" + bobs_ws.mkdir(parents=True) + alice_ws = tmp_path / "srv" / "alice-workspace" + alice_ws.mkdir(parents=True) + + # Ambient/global last-workspace points at BOB's directory. + global_lw = tmp_path / "global-state" + global_lw.mkdir(parents=True) + (global_lw / "last_workspace.txt").write_text(str(bobs_ws), encoding="utf-8") + monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_lw / "last_workspace.txt") + + # Alice's OWN profile-local last-workspace points at her own directory. + alice_state = tmp_path / ".hermes" / "profiles" / "alice" / "webui_state" + alice_state.mkdir(parents=True) + (alice_state / "last_workspace.txt").write_text(str(alice_ws), encoding="utf-8") + + def _lw_file_for(p=None): + if p is not None and str(p) == "alice": + return alice_state / "last_workspace.txt" + return global_lw / "last_workspace.txt" + + monkeypatch.setattr(workspace, "_last_workspace_file_for_profile", _lw_file_for) + monkeypatch.setattr(models, "get_last_workspace", workspace.get_last_workspace) + + s = models.new_session(profile="alice") + assert str(s.workspace) == str(alice_ws), ( + "new_session under profile='alice' must bind ALICE's last workspace, " + f"got {s.workspace!r}" + ) + + imported = models.import_cli_session( + session_id="cli-import-alice-1", + title="t", + messages=[], + profile="alice", + ) + assert str(imported.workspace) == str(alice_ws), ( + "import_cli_session under profile='alice' must bind ALICE's last " + f"workspace, got {imported.workspace!r}" + ) + + +# ── Re-gate round 4 (#7168): malformed names, aliases, unwired boundaries ──── + + +class TestRound4ProfileIsolation: + """Re-gate round 4: the four remaining profile-isolation instances.""" + + def test_invalid_profile_name_never_reads_default_state(self, monkeypatch, tmp_path): + """A malformed name must NOT be clamped onto the default home. + + Old behavior: _resolve_profile_home_param('bad name') returned + _DEFAULT_HERMES_HOME, so get_last_workspace(profile='bad name') read + (and could fall back to) the DEFAULT profile's global state file. + """ + import api.models as models # noqa: F401 (import parity with R3 tests) + from api import profiles + + default_home = tmp_path / ".hermes" + default_home.mkdir(parents=True) + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", default_home) + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: default_home) + + global_lw = tmp_path / "global-state" + global_lw.mkdir(parents=True) + poisoned = tmp_path / "poisoned-workspace" + poisoned.mkdir() + (global_lw / "last_workspace.txt").write_text(str(poisoned), encoding="utf-8") + monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_lw / "last_workspace.txt") + + # The invalid name resolves to NOTHING state-like and raises on resolve. + with pytest.raises(ValueError): + workspace._resolve_profile_home_param("bad name") + + # Getter: never returns the poisoned default/global binding. + got = workspace.get_last_workspace(profile="bad name") + assert str(got) != str(poisoned) + + # Setter: writes no file anywhere under the default home. + workspace.set_last_workspace(str(poisoned), profile="bad name") + assert not (default_home / "webui_state").exists() + + # save_workspaces refuses outright. + with pytest.raises(ValueError): + workspace.save_workspaces([{"path": str(poisoned)}], profile="bad name") + + def test_symlink_alias_of_default_home_keeps_legacy_fallback(self, monkeypatch, tmp_path): + """A symlink alias of _DEFAULT_HERMES_HOME is canonically the DEFAULT. + + Old behavior compared lexically: alias != _DEFAULT_HERMES_HOME, so the + default profile accessed through its alias wrongly LOST the legacy + global fallback. With canonical identity it keeps it. + """ + from api import profiles + + real_home = tmp_path / "real-hermes" + real_home.mkdir(parents=True) + alias = tmp_path / "alias-hermes" + try: + alias.symlink_to(real_home, target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable in this environment") + + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", real_home) + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: real_home) + + shared_ws = tmp_path / "shared-workspace" + shared_ws.mkdir() + # The default profile's state dir IS the global STATE_DIR — pin both + # the legacy global fallback file and the profile-scoped file path so + # no real ~/.hermes state leaks into the assertion. + global_state = tmp_path / "global-state" + global_state.mkdir(parents=True) + (global_state / "last_workspace.txt").write_text(str(shared_ws), encoding="utf-8") + monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_state / "last_workspace.txt") + monkeypatch.setattr( + workspace, + "_GLOBAL_WS_FILE", + global_state / "workspaces.json", + ) + monkeypatch.setattr(workspace, "_STATE_DIR_OVERRIDE", str(global_state), raising=False) + monkeypatch.setattr( + workspace, + "_last_workspace_file_for_profile", + lambda p=None: ( + global_state / "last_workspace.txt" + if p is None or workspace._is_default_profile_home( + workspace._resolve_profile_home_param(p) + ) + else tmp_path / "other" / "webui_state" / "last_workspace.txt" + ), + raising=False, + ) + + assert workspace._is_default_profile_home(alias), ( + "symlink alias must compare canonically equal to the default home" + ) + + # No profile-local last_workspace.txt exists → legacy global fallback applies. + # Explicit home is expressed as a Path value (#7168 re-gate round 5: + # path-shaped STRINGS are rejected profile ids). + got = workspace.get_last_workspace(profile=alias) + assert str(got) == str(shared_ws) + + def test_set_last_workspace_threads_session_profile(self, monkeypatch, tmp_path): + """set_last_workspace call sites write the SESSION's profile state file. + + Repro of review finding #1: a server-initiated turn for named-profile + session Alice wrote only the GLOBAL file, so Alice fell back to her + configured workspace while default incorrectly read Alice's selection. + """ + alice_state = tmp_path / "profiles" / "alice" / "webui_state" + alice_state.mkdir(parents=True) + + lw_paths = {} + real_set = workspace.set_last_workspace + + def spy(path, profile=None): + lw = workspace._last_workspace_file_for_profile(profile) + lw_paths["profile"] = profile + lw_paths["file"] = str(lw) + return real_set(path, profile=profile) + + # Pin the resolver so 'alice' maps into the fixture tree (the product + # code path under test is the routes.py call shape, not profiles lookup). + monkeypatch.setattr( + workspace, + "_resolve_profile_home_param", + lambda p: tmp_path / "profiles" / (p or "default"), + raising=False, + ) + + # Simulate exactly what the three routes.py sites now do: + s_like = type("S", (), {"profile": "alice"})() + spy("/srv/alice-ws", profile=getattr(s_like, "profile", None)) + + assert lw_paths["profile"] == "alice" + assert pathlib.PurePath(lw_paths["file"]).name == "last_workspace.txt" + assert "alice" in lw_paths["file"], ( + f"session-profile write must land in ALICE's webui_state, got {lw_paths['file']!r}" + ) + assert not (tmp_path / "global").exists() + + def test_cli_projection_uses_own_cli_profile(self, monkeypatch, tmp_path): + """The all-profile CLI sidebar projection scopes its workspace probe. + + Review finding #3: _load_cli_sessions_uncached had _cli_profile in + scope but called get_last_workspace() ambiently, producing + profile='alice', workspace=''. + """ + import api.models as models + + calls = [] + + def fake_getlw(profile=None): + calls.append(profile) + return f"workspace-for:{profile}" + + monkeypatch.setattr(models, "get_last_workspace", fake_getlw) + monkeypatch.setattr(models, "get_claude_code_sessions", lambda: []) + monkeypatch.setattr(models, "read_importable_agent_session_rows", lambda *a, **k: [ + { + "id": "tui-row-alice", + "title": "TUI Session", + "model": "test-model", + "source": "tui", + "raw_source": "tui", + "message_count": 1, + "actual_message_count": 1, + "actual_user_message_count": 1, + "last_activity": 10.0, + "started_at": 9.0, + } + ]) + monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False) + monkeypatch.setattr(models, "ensure_cron_project", lambda **_: None) + monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) + + db = tmp_path / "state.db" + db.write_text("", encoding="utf-8") + rows = models._load_cli_sessions_uncached( + tmp_path, db, _cli_profile="alice" + ) + assert any(r.get("session_id") == "tui-row-alice" for r in rows), rows + assert calls and calls[0] == "alice", ( + f"_cli_workspace() must consult get_last_workspace(profile=_cli_profile); " + f"profiles seen: {calls}" + ) + alice_rows = [r for r in rows if r.get("workspace") == "workspace-for:alice"] + assert alice_rows, ( + f"projected row must carry the ALICE-scoped workspace; got " + f"{[r.get('workspace') for r in rows]}" + ) + + +# ── Re-gate round 5 (#7168): traversal gate + symlinked config authority ───── + + +class TestRound5TraversalAndSymlinkAuthority: + """Round 5: path-shaped profile STRINGS are rejected ids; config authority + survives a symlinked HERMES_HOME.""" + + def test_path_shaped_profile_string_rejected_no_traversal_write( + self, monkeypatch, tmp_path + ): + """'../evil' as a STRING profile must be rejected by the id grammar. + + Repro of review finding #1 (CORE / security): the old resolver treated + any string containing '/' or '\\' as an explicit home and resolved it + directly BEFORE the grammar check, so a malformed profile value like + ``../evil`` bypassed validation entirely and could read AND overwrite + an arbitrary ``webui_state/last_workspace.txt`` outside the profile + boundary. Real getters + real state files throughout. + """ + from api import profiles + + default_home = tmp_path / ".hermes" + default_home.mkdir(parents=True) + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", default_home) + monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: default_home) + + # The file the traversal WOULD reach if it escaped the boundary. + evil_dir = tmp_path / "evil" + evil_state = evil_dir / "webui_state" + evil_state.mkdir(parents=True) + victim = evil_state / "last_workspace.txt" + victim.write_text("/original-victim-binding", encoding="utf-8") + + traversal = "../evil" + + # Grammar gate: a path-shaped STRING is not a valid logical id. + with pytest.raises(ValueError): + workspace._resolve_profile_home_param(traversal) + + # Getter: reads NOTHING from the traversal target. + got = workspace.get_last_workspace(profile=traversal) + assert got != "/original-victim-binding" + + # Setter: writes NO file anywhere along the traversal. + workspace.set_last_workspace("/attacker-chosen", profile=traversal) + assert victim.read_text(encoding="utf-8") == "/original-victim-binding" + assert sorted(p.name for p in evil_state.iterdir()) == ["last_workspace.txt"], ( + "traversal profile must not create or modify any state file" + ) + + # Saved-list writer refuses outright too. + with pytest.raises(ValueError): + workspace.save_workspaces([{"path": str(evil_dir)}], profile=traversal) + + # Same location delivered as a REAL Path is still honored — explicit + # homes move to Path values; they are canonicalized, not banned. + assert workspace._resolve_profile_home_param(Path(traversal)) == Path( + traversal + ).expanduser().resolve() + + def test_symlinked_home_with_authoritative_config_path_keeps_authority( + self, monkeypatch, tmp_path + ): + """HERMES_HOME via symlink + authoritative HERMES_CONFIG_PATH. + + Repro of review finding #2 (CORE): workspace canonicalization hands + get_config_for_profile_home the RESOLVED alias target, but the config + authority compared the active home and the config parent LEXICALLY. + With HERMES_HOME reached through a symlink alias, both identity checks + failed and the function performed a DIRECT read of the symlink + target's own (wrong) config.yaml instead of deferring to the + authoritative HERMES_CONFIG_PATH — master trusts the remote + terminal.cwd from the override; the lexical head rejected it. + Canonicalize all three sides before comparing. + """ + import os + + import yaml + + from api import profiles + + real_home = tmp_path / "real-hermes" + real_home.mkdir(parents=True) + alias_home = tmp_path / "alias-hermes" + try: + alias_home.symlink_to(real_home, target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable in this environment") + + # The symlink target's OWN config is the WRONG one a naive direct + # read picks up (no remote cwd — exactly what "rejects the remote + # path" looks like downstream). + (real_home / "config.yaml").write_text( + yaml.safe_dump( + {"terminal": {"backend": "local", "cwd": "/srv/from-symlink-target"}}, + sort_keys=False, + ), + encoding="utf-8", + ) + # The AUTHORITATIVE config lives OUTSIDE the home, behind + # HERMES_CONFIG_PATH (maintainer-blocker-1 semantics). + override_dir = tmp_path / "override-dir" + override_dir.mkdir() + override_cfg = override_dir / "config.yaml" + override_cfg.write_text( + yaml.safe_dump( + {"terminal": {"backend": "ssh", "cwd": "/srv/from-override"}}, + sort_keys=False, + ), + encoding="utf-8", + ) + + # Ambient resolver reports the home under its ALIAS spelling (as an + # env-provided HERMES_HOME would); callers pass the canonicalized + # target — the exact divergence the lexical comparison mishandled. + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: alias_home) + monkeypatch.setenv("HERMES_CONFIG_PATH", str(override_cfg)) + api_config.reload_config() + + got = api_config.get_config_for_profile_home(real_home) + assert got.get("terminal", {}).get("cwd") == "/srv/from-override", ( + "canonicalized alias target must preserve HERMES_CONFIG_PATH " + f"authority; got {got.get('terminal')!r}" + ) + assert got.get("terminal", {}).get("backend") == "ssh" + + # The un-canonicalized alias spelling converges to the SAME answer — + # identity is by resolution, never by lexical spelling. + got_alias = api_config.get_config_for_profile_home(alias_home) + assert got_alias.get("terminal", {}).get("cwd") == "/srv/from-override" + + # Sanity: the fixture genuinely exercises alias-vs-resolved-target + # comparison rather than passing trivially. + assert os.path.realpath(alias_home) == str(real_home) + assert os.path.realpath(real_home) != str(alias_home) + # NOTE: no manual delenv/reload here (see + # test_config_authority_active_home_with_external_override) — + # monkeypatch teardown + stale-path check invalidate the cache. diff --git a/tests/test_sprint5.py b/tests/test_sprint5.py index a28f774f..a54ef090 100644 --- a/tests/test_sprint5.py +++ b/tests/test_sprint5.py @@ -117,7 +117,7 @@ def test_workspace_suggest_preserves_tilde_prefix(monkeypatch, tmp_path): child = home / "Projects" child.mkdir(parents=True) monkeypatch.setenv("HOME", str(home)) - monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda: [home.resolve()]) + monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda *a, **kw: [home.resolve()]) suggestions = workspace.list_workspace_suggestions("~/") @@ -132,7 +132,7 @@ def test_workspace_suggest_preserves_tilde_prefix_for_partial_child(monkeypatch, child = home / "Projects" child.mkdir(parents=True) monkeypatch.setenv("HOME", str(home)) - monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda: [home.resolve()]) + monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda *a, **kw: [home.resolve()]) suggestions = workspace.list_workspace_suggestions("~/Pro") @@ -152,7 +152,7 @@ def test_workspace_suggest_expands_tilde_when_home_is_symlink(monkeypatch, tmp_p pytest.skip(f"symlinks are not available in this environment: {exc}") monkeypatch.setenv("HOME", str(link_home)) - monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda: [actual_home.resolve()]) + monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda *a, **kw: [actual_home.resolve()]) suggestions = workspace.list_workspace_suggestions("~/Doc") @@ -166,7 +166,7 @@ def test_workspace_suggest_keeps_absolute_prefix_absolute(monkeypatch, tmp_path) child = home / "Projects" child.mkdir(parents=True) monkeypatch.setenv("HOME", str(home)) - monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda: [home.resolve()]) + monkeypatch.setattr(workspace, "_trusted_workspace_roots", lambda *a, **kw: [home.resolve()]) suggestions = workspace.list_workspace_suggestions(str(home) + "/Pro") diff --git a/tests/test_stale_stream_cleanup.py b/tests/test_stale_stream_cleanup.py index 2bd34e40..a41664f3 100644 --- a/tests/test_stale_stream_cleanup.py +++ b/tests/test_stale_stream_cleanup.py @@ -134,7 +134,7 @@ def test_chat_start_rechecks_active_stream_under_session_lock(monkeypatch, tmp_p monkeypatch.setattr(routes, "_get_session_agent_lock", lambda sid: MutatingSessionLock()) monkeypatch.setattr(routes.uuid, "uuid4", lambda: type("FakeUuid", (), {"hex": "new-stream"})()) - monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) + monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None) monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue()) monkeypatch.setattr(routes.threading, "Thread", NoopThread) @@ -199,7 +199,7 @@ def test_chat_start_blocks_same_session_active_run_after_cancel_clears_stream_id return None monkeypatch.setattr(routes.uuid, "uuid4", lambda: type("FakeUuid", (), {"hex": "new-stream"})()) - monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) + monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None) monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue()) monkeypatch.setattr(routes.threading, "Thread", NoopThread) @@ -257,7 +257,7 @@ def test_chat_start_allows_same_session_after_active_run_unregisters(monkeypatch return None monkeypatch.setattr(routes.uuid, "uuid4", lambda: type("FakeUuid", (), {"hex": "new-stream"})()) - monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) + monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None) monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue()) monkeypatch.setattr(routes.threading, "Thread", NoopThread) @@ -332,7 +332,7 @@ def test_chat_start_not_permanently_blocked_by_stale_active_run(monkeypatch, tmp return None monkeypatch.setattr(routes.uuid, "uuid4", lambda: type("FakeUuid", (), {"hex": "new-stream"})()) - monkeypatch.setattr(routes, "set_last_workspace", lambda workspace: None) + monkeypatch.setattr(routes, "set_last_workspace", lambda workspace, **_kw: None) monkeypatch.setattr(routes, "create_stream_channel", lambda: queue.Queue()) monkeypatch.setattr(routes.threading, "Thread", NoopThread) diff --git a/tests/test_webhook_project_sessions.py b/tests/test_webhook_project_sessions.py index fc60b6e3..0370e059 100644 --- a/tests/test_webhook_project_sessions.py +++ b/tests/test_webhook_project_sessions.py @@ -108,7 +108,7 @@ def test_webhook_rows_get_webhook_project_id(monkeypatch, tmp_path): db.write_text("", encoding="utf-8") monkeypatch.setattr(models, "read_importable_agent_session_rows", lambda *_a, **_kw: [_agent_row()]) - monkeypatch.setattr(models, "get_last_workspace", lambda: tmp_path) + monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path) monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project-id") monkeypatch.setattr(models, "ensure_webhook_project", lambda: "webhook-project-id", raising=False) monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) @@ -137,7 +137,7 @@ def test_webhook_second_pass_keeps_older_project_rows_available(monkeypatch, tmp return [] monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows) - monkeypatch.setattr(models, "get_last_workspace", lambda: tmp_path) + monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path) monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project-id") monkeypatch.setattr(models, "ensure_webhook_project", lambda: "webhook-project-id", raising=False) monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) diff --git a/tests/test_workspace_upload.py b/tests/test_workspace_upload.py index b71790ff..731b9893 100644 --- a/tests/test_workspace_upload.py +++ b/tests/test_workspace_upload.py @@ -136,7 +136,7 @@ def _configure_direct_office_upload(monkeypatch, tmp_path): monkeypatch.setattr(upload, "get_session", lambda _sid: session) monkeypatch.setattr(upload, "_reject_invisible_session", lambda *_args: False) - monkeypatch.setattr(upload, "resolve_trusted_workspace", lambda path: path) + monkeypatch.setattr(upload, "resolve_trusted_workspace", lambda path, **_kw: path) return upload, office_documents, workspace From 572c84005f086531b921e18a9152167c3ceda1ef Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 15 Sep 2026 15:44:39 -0700 Subject: [PATCH 051/446] Release: preserve remote POSIX workspace paths across session, streaming and upload resolvers (#7168) (#7586) Co-authored-by: n --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74fc11f0..0bea893d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Remote (SSH/Docker) workspaces keep their target-side POSIX paths across session restore, streaming, uploads and project context.** When the terminal backend runs off-host, a workspace path such as `/srv/remote-alice` belongs to the *target* machine — but several resolvers normalized it against the WebUI host's filesystem, so a host-side expansion (macOS firmlinks turning it into `/System/Volumes/Data/srv/remote-alice`, or a local `~` expansion) could be persisted back onto the session and then used as the per-turn runtime CWD. The workspace, streaming, upload and project-context resolvers now receive the owning **session's profile** explicitly instead of resolving against whatever profile happens to be ambient, so remote paths stay target-side and a session owned by one profile can never resolve through another's. Local (non-remote) workspaces normalize exactly as before, and symlink/`..`/outside-root rejection is unchanged. Thanks @alfred-rootson. (#7168, closes #7131) + - **Opening a session no longer stalls just because Codex refreshed its model cache in the background.** The WebUI keys its 24-hour `/api/models` cache partly on Codex's `~/.codex/models_cache.json`, but that file was fingerprinted by `mtime` + size — and Codex rewrites it on its own refresh timer, bumping those stat fields even when the model catalog is byte-identical (only a volatile `fetched_at` timestamp changed). Every Codex refresh therefore invalidated the cache, and the next session open paid a full live rebuild whose serial provider probes stalled the open (#7540). The Codex cache is now fingerprinted by its *content* with the refresh-timestamp fields (`fetched_at`, `updated_at`) stripped, so a timestamp-only rewrite keeps the cache while any genuine catalog change (models, `etag`, `client_version`) still invalidates it. A malformed or pathologically deep cache file degrades safely to the old stat fingerprint rather than erroring. Thanks @webtecnica. (#7556, closes #7540) - **A timed-out command-approval card can be dismissed again instead of getting stuck on screen forever.** When the agent raised a local approval with no gateway notifier registered, the raw pending entry was stored without an `approval_id`; the frontend needs that id to own the card, so a card that timed out (or otherwise went unanswered) could be neither approved nor dismissed and the poll re-served it indefinitely. Reconciliation now mints a stable `gwlocal-mirrorless:` id on the raw local entry itself — the same tokenization contract already used for gateway producers — so the id survives repeated polls and the client's dismiss marker sticks. Gateway/run-bearing entries are untouched, and the synthetic prefix can't be mistaken for gateway authority (routing keys on exact ids, `run_id`, and mirror tokens, never the prefix). Thanks @CharlesMcquade. (#7510) From 68008e824be9c4db1b15191105585d183dd19df6 Mon Sep 17 00:00:00 2001 From: webtecnica <75556242+webtecnica@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:16:57 -0300 Subject: [PATCH 052/446] fix: classify Signal gateway sessions as messaging (#7572) Classify Signal gateway sessions as messaging so they group, label, and list like other gateway platforms. Co-authored-by: webtecnica --- CHANGELOG.md | 2 ++ api/agent_sessions.py | 2 ++ static/sessions.js | 3 ++- tests/test_gateway_sync.py | 47 +++++++++++++++++++++++++++++++++++++- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bea893d..a331570b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - **Remote (SSH/Docker) workspaces keep their target-side POSIX paths across session restore, streaming, uploads and project context.** When the terminal backend runs off-host, a workspace path such as `/srv/remote-alice` belongs to the *target* machine — but several resolvers normalized it against the WebUI host's filesystem, so a host-side expansion (macOS firmlinks turning it into `/System/Volumes/Data/srv/remote-alice`, or a local `~` expansion) could be persisted back onto the session and then used as the per-turn runtime CWD. The workspace, streaming, upload and project-context resolvers now receive the owning **session's profile** explicitly instead of resolving against whatever profile happens to be ambient, so remote paths stay target-side and a session owned by one profile can never resolve through another's. Local (non-remote) workspaces normalize exactly as before, and symlink/`..`/outside-root rejection is unchanged. Thanks @alfred-rootson. (#7168, closes #7131) +- **Signal gateway conversations are classified as messaging sessions instead of falling into the default/other bucket.** The messaging-source allowlists never learned about Signal, so a session started on the Signal gateway was normalized to `other`, failed the client-side external-session check the session list uses to render and refresh gateway sessions, and never showed up in the sidebar. `signal` (labeled "Signal") is now in the messaging-source sets on both sides — the Python normalizer (`MESSAGING_SOURCES` / `SOURCE_LABELS`), which also feeds the server-side messaging allowlist, and the client-side classifier (`_MESSAGING_RAW_SOURCES` / `_MESSAGING_SOURCE_LABELS`) — so Signal sessions group, label, and list exactly like Telegram, Discord, Slack, WeCom, and Matrix. No other source's classification changes. Thanks @webtecnica. (#7571) + - **Opening a session no longer stalls just because Codex refreshed its model cache in the background.** The WebUI keys its 24-hour `/api/models` cache partly on Codex's `~/.codex/models_cache.json`, but that file was fingerprinted by `mtime` + size — and Codex rewrites it on its own refresh timer, bumping those stat fields even when the model catalog is byte-identical (only a volatile `fetched_at` timestamp changed). Every Codex refresh therefore invalidated the cache, and the next session open paid a full live rebuild whose serial provider probes stalled the open (#7540). The Codex cache is now fingerprinted by its *content* with the refresh-timestamp fields (`fetched_at`, `updated_at`) stripped, so a timestamp-only rewrite keeps the cache while any genuine catalog change (models, `etag`, `client_version`) still invalidates it. A malformed or pathologically deep cache file degrades safely to the old stat fingerprint rather than erroring. Thanks @webtecnica. (#7556, closes #7540) - **A timed-out command-approval card can be dismissed again instead of getting stuck on screen forever.** When the agent raised a local approval with no gateway notifier registered, the raw pending entry was stored without an `approval_id`; the frontend needs that id to own the card, so a card that timed out (or otherwise went unanswered) could be neither approved nor dismissed and the poll re-served it indefinitely. Reconciliation now mints a stable `gwlocal-mirrorless:` id on the raw local entry itself — the same tokenization contract already used for gateway producers — so the id survives repeated polls and the client's dismiss marker sticks. Gateway/run-bearing entries are untouched, and the synthetic prefix can't be mistaken for gateway authority (routing keys on exact ids, `run_id`, and mirror tokens, never the prefix). Thanks @CharlesMcquade. (#7510) diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 3bccfd2d..97086795 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -59,6 +59,7 @@ MESSAGING_SOURCES = { 'telegram', 'weixin', 'matrix', + 'signal', } CLI_MIN_UNTITLED_MESSAGE_COUNT = 6 @@ -82,6 +83,7 @@ SOURCE_LABELS = { 'webui': 'WebUI', 'weixin': 'Weixin', 'matrix': 'Matrix', + 'signal': 'Signal', } diff --git a/static/sessions.js b/static/sessions.js index 76c1fdc9..1eacb242 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2424,7 +2424,7 @@ const _HANDOFF_THRESHOLD = 10; // conversation rounds const _HANDOFF_STORAGE_PREFIX = 'handoff:'; const _HANDOFF_SUFFIX_DISMISSED_AT = 'dismissed_at'; const _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT = 'summary_handled_at'; -const _MESSAGING_RAW_SOURCES = new Set(['weixin', 'telegram', 'discord', 'slack', 'email', 'wecom', 'wecom_callback', 'matrix']); +const _MESSAGING_RAW_SOURCES = new Set(['weixin', 'telegram', 'discord', 'slack', 'email', 'wecom', 'wecom_callback', 'matrix', 'signal']); const _MESSAGING_SOURCE_LABELS = { weixin: 'WeChat', telegram: 'Telegram', @@ -2434,6 +2434,7 @@ const _MESSAGING_SOURCE_LABELS = { wecom: 'WeCom', wecom_callback: 'WeCom Callback', matrix: 'Matrix', + signal: 'Signal', }; function _isMessagingSession(session) { diff --git a/tests/test_gateway_sync.py b/tests/test_gateway_sync.py index 17ac4d23..2a656914 100644 --- a/tests/test_gateway_sync.py +++ b/tests/test_gateway_sync.py @@ -1020,6 +1020,7 @@ def test_agent_session_source_normalization_contract(): 'discord': ('messaging', 'Discord'), 'slack': ('messaging', 'Slack'), 'matrix': ('messaging', 'Matrix'), + 'signal': ('messaging', 'Signal'), 'cron': ('cron', 'Cron'), 'webhook': ('webhook', 'Webhook'), 'tool': ('tool', 'Tool'), @@ -1045,13 +1046,14 @@ def test_sessions_js_treats_email_as_messaging_source(): raw_section = src[src.find("_MESSAGING_RAW_SOURCES"):src.find("function _isMessagingSession")] label_section = src[src.find("_MESSAGING_SOURCE_LABELS"):src.find("function _isMessagingSession")] - for raw_source in ("email", "wecom", "wecom_callback", "matrix"): + for raw_source in ("email", "wecom", "wecom_callback", "matrix", "signal"): assert f"'{raw_source}'" in raw_section, f"Missing raw source {raw_source!r} in _MESSAGING_RAW_SOURCES" assert "email: 'Email'" in label_section assert "wecom: 'WeCom'" in label_section assert "wecom_callback: 'WeCom Callback'" in label_section assert "matrix: 'Matrix'" in label_section + assert "signal: 'Signal'" in label_section def test_sessions_js_treats_messaging_sidecars_behaviorally(): @@ -1067,6 +1069,8 @@ const cases = [ {{ session_source: 'other', raw_source: 'wecom_callback' }}, {{ session_source: 'other', source_tag: 'wecom' }}, {{ session_source: 'other', source: 'matrix' }}, + {{ session_source: 'other', source: 'signal' }}, + {{ session_source: 'other', raw_source: 'signal' }}, {{ session_source: 'messaging', source: 'anything' }}, ]; for (const c of cases) {{ @@ -1079,6 +1083,47 @@ if (_isMessagingSession({{ session_source: 'other', source: 'cli' }})) {{ subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) +def test_sessions_js_treats_signal_sidecar_as_external_session(): + """A Signal gateway session must be exposed as an external session. + + The sidebar only lists sessions the client classifier reports as external, + and _isExternalSession routes messaging sources through _isMessagingSession. + A Signal sidecar whose persisted session_source is the legacy 'other' value + therefore has to be recognized from its raw source, exactly like the Matrix + precedent (#6816). This exercises the real sessions.js classifiers in node + rather than asserting on the shape of the allowlists. + """ + src = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + start = src.index("const _MESSAGING_RAW_SOURCES") + end = src.index("\n}", src.index("function _isExternalSession")) + len("\n}") + block = src[start:end] + script = f""" +{block} +if (!_isMessagingSession({{ session_source: 'other', source: 'signal' }})) {{ + throw new Error('signal session was not classified as messaging'); +}} +if (_MESSAGING_SOURCE_LABELS['signal'] !== 'Signal') {{ + throw new Error('unexpected signal source label: ' + _MESSAGING_SOURCE_LABELS['signal']); +}} +for (const c of [ + {{ session_source: 'other', source: 'signal' }}, + {{ session_source: 'other', raw_source: 'signal' }}, + {{ session_source: 'other', source_tag: 'signal' }}, +]) {{ + if (!_isExternalSession(c)) {{ + throw new Error('signal sidecar is not exposed as an external session: ' + JSON.stringify(c)); + }} +}} +if (_isExternalSession({{ session_source: 'webui', source: 'signal' }})) {{ + throw new Error('a WebUI-origin session must not be treated as an external session'); +}} +if (_isExternalSession({{ session_source: 'other', source: 'some_future_platform' }})) {{ + throw new Error('an unrecognized source must not be treated as an external session'); +}} +""" + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_empty_active_gateway_session_does_not_hide_messaging_history(monkeypatch): """A zero-message active Gateway row must not hide older Discord history.""" import api.routes as routes From b56dd423e72658e6a3c1e4146dc67fce16eee4e8 Mon Sep 17 00:00:00 2001 From: Kenan <1465712+lowlandsheperd@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:31:54 +0800 Subject: [PATCH 053/446] fix(sidebar): nest delegated subagents under messaging parents (#7263) Nest delegated subagent sessions under the parent conversation that spawned them instead of forcing them to the sidebar top level. Co-authored-by: lowlandsheperd --- static/sessions.js | 17 ++- tests/test_session_lineage_collapse.py | 175 +++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 6 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 1eacb242..bb7c49fb 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -7134,6 +7134,12 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere const childRenderable=!!(child&&child.session_id&&renderableChildIds.has(child.session_id)); if(child&&child.session_id&&visibleBySid.has(child.session_id)) continue; const isForkChild=_isForkWithResolvableParent(child, sessionIdsInList)&&!(child&&child.pinned); + const childRawRole=[ + child&&child.raw_source, + child&&child.source_tag, + child&&child.source, + ].map(source=>String(source||'').trim().toLowerCase()).find(Boolean)||''; + const childIsDelegatedSubagent=_isChildSession(child)&&childRawRole==='subagent'; const childLineageKey=child&&(child._lineage_root_id||child.lineage_root_id||child.parent_session_id); const isHiddenLineageReferenceChild=!!(child&&child.archived&&child.parent_session_id&&childLineageKey&&!child.pinned&&!childRenderable); if(!_isChildSession(child)&&!isForkChild&&!isHiddenLineageReferenceChild) continue; @@ -7158,11 +7164,10 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere hiddenArchivedChildTree.add(child.session_id); continue; } - // Cross-surface rows (for example a WebUI continuation from a Telegram - // conversation) should remain top-level when there is no WebUI-owned parent - // row to stack under. But if the parent is visible in this same sidebar - // render, attach normally — delegated subagent rows are also cross-source - // relative to their WebUI parent and should not be forced into orphans. + // Independent cross-surface rows (for example a WebUI continuation from a + // Telegram conversation) remain top-level instead of nesting under an + // external parent. Delegated subagents are also cross-source, but they are + // parent-owned work and should still attach to the visible parent row. const parentSourceMarker=String(parentRow&&( parentRow.session_source||parentRow.raw_source||parentRow.source_tag||parentRow.source )||'').toLowerCase(); @@ -7173,7 +7178,7 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere parentRow.session_source==='messaging'|| (parentSourceMarker&&parentSourceMarker!=='webui'&&parentSourceMarker!=='subagent'&&parentSourceMarker!=='other'&&parentSourceMarker!=='fork') ); - if(parentRow&&child._cross_surface_child_session&&parentIsExternal){ + if(parentRow&&child._cross_surface_child_session&&parentIsExternal&&!childIsDelegatedSubagent){ if(childRenderable) orphans.push({...child,_orphan_child_session:true}); continue; } diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 65b262b3..2f01abf8 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -499,6 +499,181 @@ console.log(JSON.stringify(rows)); assert "_orphan_child_session" not in rows[0]["_child_sessions"][0] +def test_cross_surface_subagents_stack_under_visible_messaging_parent(): + """Delegated subagents stay nested even when their visible parent is external.""" + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +function _isExternalSession(s) {{ return !!(s && (s.is_cli_session || s.session_source === 'messaging')); }} +eval(extractFunc('_isChildSession')); +eval(extractFunc('_isForkWithResolvableParent')); +eval(extractFunc('_sidebarLineageKeyForRow')); +eval(extractFunc('_attachChildSessionsToSidebarRows')); +const collapsed = [{{ + session_id:'messaging_parent', + title:'Messaging parent', + raw_source:'weixin', + source_tag:'weixin', + session_source:'messaging', + message_count:3, +}}]; +const raw = [ + collapsed[0], + ...['delegate_a', 'delegate_b', 'delegate_c'].map(session_id => ({{ + session_id, + title:'Subagent Session', + parent_session_id:'messaging_parent', + relationship_type:'child_session', + raw_source:'subagent', + source_tag:'subagent', + session_source:'other', + source_label:'Subagent', + _parent_lineage_root_id:'messaging_parent', + _cross_surface_child_session:true, + }})), +]; +const rows = _attachChildSessionsToSidebarRows(collapsed, raw); +console.log(JSON.stringify(rows)); +""" + rows = json.loads(_run_node(source)) + assert [row["session_id"] for row in rows] == ["messaging_parent"] + assert rows[0]["_child_session_count"] == 3 + assert [child["session_id"] for child in rows[0]["_child_sessions"]] == [ + "delegate_a", + "delegate_b", + "delegate_c", + ] + assert all("_orphan_child_session" not in child for child in rows[0]["_child_sessions"]) + + + + +def test_delegated_subagent_source_precedence_ignores_stale_metadata(): + """Only the first nonblank raw-role marker may assert delegation.""" + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +eval(extractFunc('_isChildSession')); +eval(extractFunc('_isForkWithResolvableParent')); +eval(extractFunc('_sidebarLineageKeyForRow')); +eval(extractFunc('_attachChildSessionsToSidebarRows')); +const parent = {{ + session_id:'messaging_parent', + title:'Messaging parent', + raw_source:'weixin', + source_tag:'weixin', + session_source:'messaging', +}}; +function runCase(name, overrides, includeChildInCollapsed=false) {{ + const child = {{ + session_id:name, + title:name, + parent_session_id:'messaging_parent', + relationship_type:'child_session', + raw_source:'api_server', + source_tag:'api_server', + source:'api_server', + session_source:'api', + _parent_lineage_root_id:'messaging_parent', + _cross_surface_child_session:true, + ...overrides, + }}; + const collapsed = includeChildInCollapsed ? [{{...parent}}, child] : [{{...parent}}]; + const rows = _attachChildSessionsToSidebarRows(collapsed, [collapsed[0], child]); + const parentRow = rows.find(row => row.session_id === 'messaging_parent'); + return {{ + name, + topLevel:rows.map(row => row.session_id), + nested:(parentRow._child_sessions || []).map(row => row.session_id), + }}; +}} +const results = [ + runCase('stale_source_tag', {{ + raw_source:'api_server', + source_tag:'subagent', + }}), + runCase('stale_session_source', {{ + raw_source:'api_server', + source_tag:'api_server', + source:'api_server', + session_source:'subagent', + }}), + runCase('source_tag_fallback', {{ + raw_source:' ', + source_tag:' SubAgent ', + source:'api_server', + session_source:'other', + }}), + runCase('authentic_raw_source', {{ + raw_source:'subagent', + source_tag:'api_server', + source:'api_server', + session_source:'other', + }}), + runCase('not_child_session', {{ + relationship_type:null, + raw_source:'subagent', + source_tag:'subagent', + source:'subagent', + session_source:'other', + }}, true), +]; +console.log(JSON.stringify(results)); +""" + assert json.loads(_run_node(source)) == [ + { + "name": "stale_source_tag", + "topLevel": ["messaging_parent", "stale_source_tag"], + "nested": [], + }, + { + "name": "stale_session_source", + "topLevel": ["messaging_parent", "stale_session_source"], + "nested": [], + }, + { + "name": "source_tag_fallback", + "topLevel": ["messaging_parent"], + "nested": ["source_tag_fallback"], + }, + { + "name": "authentic_raw_source", + "topLevel": ["messaging_parent"], + "nested": ["authentic_raw_source"], + }, + { + "name": "not_child_session", + "topLevel": ["messaging_parent", "not_child_session"], + "nested": [], + }, + ] + def test_cross_surface_subagent_child_stacks_under_visible_fork_parent(): """Forked WebUI conversations can still own subagent child rows.""" From 8fa1244279c7e55565a06461a63d40530d01e5cf Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 15 Sep 2026 16:36:51 -0700 Subject: [PATCH 054/446] Release: nest delegated subagent sessions under their messaging parent (#7263) (#7588) Co-authored-by: n --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a331570b..c42da1f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Delegated subagent sessions nest under the conversation that spawned them instead of landing at the top of the sidebar.** The sidebar forces a *cross-surface* child row to top level when its parent row belongs to another surface (a Telegram/messaging parent), so an independent continuation doesn't get stacked under a foreign thread. A delegated subagent is also technically cross-source relative to its WebUI parent, so it was swept up by that rule and surfaced as a bare top-level row with no indication of what spawned it. Delegated subagents are now exempt from that branch and attach to their visible parent, while independent cross-surface continuations still stay top level. The delegated role is resolved in strict precedence order from the first non-blank of `raw_source` → `source_tag` → `source` and additionally requires the backend's child-session flag, so a stale lower-priority field can't promote an independent row into a delegated one. Thanks @lowlandsheperd. (#7263) + - **Remote (SSH/Docker) workspaces keep their target-side POSIX paths across session restore, streaming, uploads and project context.** When the terminal backend runs off-host, a workspace path such as `/srv/remote-alice` belongs to the *target* machine — but several resolvers normalized it against the WebUI host's filesystem, so a host-side expansion (macOS firmlinks turning it into `/System/Volumes/Data/srv/remote-alice`, or a local `~` expansion) could be persisted back onto the session and then used as the per-turn runtime CWD. The workspace, streaming, upload and project-context resolvers now receive the owning **session's profile** explicitly instead of resolving against whatever profile happens to be ambient, so remote paths stay target-side and a session owned by one profile can never resolve through another's. Local (non-remote) workspaces normalize exactly as before, and symlink/`..`/outside-root rejection is unchanged. Thanks @alfred-rootson. (#7168, closes #7131) - **Signal gateway conversations are classified as messaging sessions instead of falling into the default/other bucket.** The messaging-source allowlists never learned about Signal, so a session started on the Signal gateway was normalized to `other`, failed the client-side external-session check the session list uses to render and refresh gateway sessions, and never showed up in the sidebar. `signal` (labeled "Signal") is now in the messaging-source sets on both sides — the Python normalizer (`MESSAGING_SOURCES` / `SOURCE_LABELS`), which also feeds the server-side messaging allowlist, and the client-side classifier (`_MESSAGING_RAW_SOURCES` / `_MESSAGING_SOURCE_LABELS`) — so Signal sessions group, label, and list exactly like Telegram, Discord, Slack, WeCom, and Matrix. No other source's classification changes. Thanks @webtecnica. (#7571) From 2afcdf2676234ea7b9719a81d433fe8f58011ed8 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:51:24 -0600 Subject: [PATCH 055/446] perf: use native composer sizing where supported (#6760) Use native CSS field-sizing for composer auto-grow where supported, keeping the JS path as fallback. Includes a fallback-parity fix so an empty composer holds its resting height instead of measuring the placeholder. Co-authored-by: starship-s --- static/messages.js | 21 +++++ static/style.css | 3 +- ...test_issue5514_composer_grow_scroll_pin.py | 86 +++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/static/messages.js b/static/messages.js index 2d0c96d6..50f62a69 100644 --- a/static/messages.js +++ b/static/messages.js @@ -7236,7 +7236,28 @@ function autoResize(){ } const el=$('msg'); const _nextValue=String(el.value||''); + if(typeof CSS!=='undefined'&&typeof CSS.supports==='function'&&CSS.supports('field-sizing','content')){ + if(el.style.height) el.style.height=''; + _composerLastResizeValue=_nextValue; + updateSendBtn(); + return; + } const _isAppendOnly=_nextValue.length>_composerLastResizeValue.length&&_nextValue.startsWith(_composerLastResizeValue); + // An EMPTY composer has no content to measure, so clear any inline height and + // let the CSS `min-height` define the resting size. Measuring instead would + // read the PLACEHOLDER's scrollHeight — a long busy/compression hint wraps to + // two or three lines and would grow the empty composer (71px for the English + // busy hint, 97px for the French compression one) purely because of hint text. + // That made the empty height history-dependent on this path: 44px on a fresh + // send, but grown after any later resize while empty. The native + // `field-sizing` path above always holds the resting height, so clearing here + // keeps both paths on the same contract. + if(!_nextValue){ + if(el.style.height) el.style.height=''; + _composerLastResizeValue=_nextValue; + updateSendBtn(); + return; + } const _fitsCurrentHeight=el.scrollHeight<=el.offsetHeight; // Only a direct append at the natural one-row height can skip the height // round trip. Replacements and an already-tall composer must remeasure so the diff --git a/static/style.css b/static/style.css index f996dcbd..ef72a752 100644 --- a/static/style.css +++ b/static/style.css @@ -2594,7 +2594,8 @@ @media (hover: hover) { .attach-thumb:hover{filter:brightness(1.05);transform:scale(1.04);} } - textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:16px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;font-family:inherit;} + textarea#msg{width:100%;background:transparent;border:none;outline:none;color:var(--text);font-size:16px;line-height:1.65;padding:12px 16px 6px;resize:none;min-height:44px;max-height:200px;overflow-y:auto;field-sizing:content;font-family:inherit;} + textarea#msg:placeholder-shown{field-sizing:fixed;} textarea#msg::placeholder{color:var(--muted);} .composer-footer{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:6px 10px 10px;position:relative;container-type:inline-size;container-name:composer-footer;} .composer-left{display:flex;align-items:center;gap:4px;min-width:0;flex:1;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;} diff --git a/tests/test_issue5514_composer_grow_scroll_pin.py b/tests/test_issue5514_composer_grow_scroll_pin.py index 49abf9b0..6532f002 100644 --- a/tests/test_issue5514_composer_grow_scroll_pin.py +++ b/tests/test_issue5514_composer_grow_scroll_pin.py @@ -39,6 +39,7 @@ ROOT = Path(__file__).parents[1] UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") +STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") def _function_source(source: str, name: str) -> str: @@ -582,6 +583,91 @@ def test_steady_state_keystroke_preserves_near_bottom_unpinned_reader(): assert out["repinCalls"] == 0, "must not re-pin an unpinned reader" +# --------------------------------------------------------------------------- +# Behavioral (node vm) — native field-sizing avoids per-keystroke layout reads +# --------------------------------------------------------------------------- + +def test_composer_declares_native_field_sizing_and_capped_overflow(): + composer_rules = [part.split("}", 1)[0] for part in STYLE_CSS.split("textarea#msg{")[1:]] + required = ("field-sizing:content", "overflow-y:auto", "min-height:44px", "max-height:200px") + assert any(all(declaration in rule for declaration in required) for rule in composer_rules) + base_at = STYLE_CSS.index(" textarea#msg{") + state_rule = " textarea#msg:placeholder-shown{field-sizing:fixed;}" + assert state_rule in STYLE_CSS + state_at = STYLE_CSS.index(state_rule) + assert state_at > base_at + assert "field-sizing:content" in STYLE_CSS[base_at : STYLE_CSS.index("}", base_at)] + + +def test_native_field_sizing_skips_geometry_and_height_writes(): + node = shutil.which("node") + if not node: # pragma: no cover + pytest.skip("node not available") + body = _autoresize_body() + harness = textwrap.dedent( + """ + let geometryReads = 0, heightWrites = 0, sendUpdates = 0; + let _composerAutoResizeRaf = 0; + let _composerLastResizeValue = 'first line'; + const msg = { + value: 'first line', + style: { + _height: '', + set height(value) { heightWrites += 1; this._height = value; }, + get height() { return this._height; }, + }, + get scrollHeight() { geometryReads += 1; return 200; }, + get offsetHeight() { geometryReads += 1; return 44; }, + }; + const messages = { scrollTop: 0 }; + const $ = (id) => id === 'msg' ? msg : id === 'messages' ? messages : null; + let supportsNative = true; + const supportCalls = []; + const CSS = { + supports: (property, value) => { + supportCalls.push([property, value]); + return supportsNative && property === 'field-sizing' && value === 'content'; + }, + }; + function getComputedStyle() { geometryReads += 1; return { minHeight: '44px' }; } + function updateSendBtn() { sendUpdates += 1; } + function _repinMessagesAfterComposerResize() { throw new Error('native path must not repin'); } + %(autoresize)s + + msg.value += '\\nsecond line'; + autoResize(); + const first = { geometryReads, heightWrites, lastValue: _composerLastResizeValue, sendUpdates }; + + msg.style._height = '120px'; + msg.value += '\\nthird line'; + autoResize(); + const native = { first, geometryReads, heightWrites, height: msg.style.height, lastValue: _composerLastResizeValue, sendUpdates }; + + supportsNative = false; + geometryReads = 0; + heightWrites = 0; + msg.style._height = ''; + msg.value += '\\nfourth line'; + autoResize(); + console.log(JSON.stringify({ native, fallback: { geometryReads, heightWrites }, supportCalls })); + """ + ) % {"autoresize": body} + proc = subprocess.run([node, "-e", harness], capture_output=True, text=True, timeout=30) + assert proc.returncode == 0, proc.stderr + result = json.loads(proc.stdout) + assert result["native"] == { + "first": {"geometryReads": 0, "heightWrites": 0, "lastValue": "first line\nsecond line", "sendUpdates": 1}, + "geometryReads": 0, + "heightWrites": 1, + "height": "", + "lastValue": "first line\nsecond line\nthird line", + "sendUpdates": 2, + } + assert result["fallback"]["geometryReads"] > 0 + assert result["fallback"]["heightWrites"] == 2 + assert result["supportCalls"] == [["field-sizing", "content"]] * 3 + + # --------------------------------------------------------------------------- # Strict min-height parse (#6349 Codex re-gate): the single-row fast path must # only fire when getComputedStyle(el).minHeight is a genuine "px". A From 294577a1d1ff32a87e9d72c81bb29541d4e51926 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 15 Sep 2026 16:56:11 -0700 Subject: [PATCH 056/446] Release: native composer field-sizing with fallback parity (#6760) (#7589) Co-authored-by: n --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c42da1f8..9f465640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,14 @@ ## [Unreleased] +### Changed + +- **The chat composer grows natively instead of being resized by JavaScript on every keystroke.** `autoResize()` measured `scrollHeight` and wrote `style.height` on each input event — a forced synchronous reflow on the most-typed-in surface in the app. Browsers that support CSS `field-sizing: content` (Chromium today; also Firefox 152 and Safari 26.2) now own the geometry directly, gated on `CSS.supports()`, and the existing JavaScript path is untouched for every other engine. Measured behaviour is identical across both paths: 44px resting height, no jump when the first character is typed or the last deleted, growth to the 200px ceiling, then internal scrolling. Because `field-sizing` deliberately includes placeholder text in content sizing, `:placeholder-shown` pins fixed sizing while the composer is empty so a long placeholder can't inflate it. Thanks @starship-s. (#6760, #5514) + ### Fixed +- **An empty composer no longer inflates to fit its own placeholder on the JavaScript sizing path.** The fallback resizer measured `scrollHeight` for an empty textarea, which reflects the *placeholder* — so a long busy or compression hint (which wraps to two or three lines) grew the empty composer to roughly 71px, and only after a resize that happened while empty, making the resting height depend on history rather than content. The inline height is now cleared when the value is empty so CSS `min-height` defines the resting size, matching the native path. (#6760) + - **Delegated subagent sessions nest under the conversation that spawned them instead of landing at the top of the sidebar.** The sidebar forces a *cross-surface* child row to top level when its parent row belongs to another surface (a Telegram/messaging parent), so an independent continuation doesn't get stacked under a foreign thread. A delegated subagent is also technically cross-source relative to its WebUI parent, so it was swept up by that rule and surfaced as a bare top-level row with no indication of what spawned it. Delegated subagents are now exempt from that branch and attach to their visible parent, while independent cross-surface continuations still stay top level. The delegated role is resolved in strict precedence order from the first non-blank of `raw_source` → `source_tag` → `source` and additionally requires the backend's child-session flag, so a stale lower-priority field can't promote an independent row into a delegated one. Thanks @lowlandsheperd. (#7263) - **Remote (SSH/Docker) workspaces keep their target-side POSIX paths across session restore, streaming, uploads and project context.** When the terminal backend runs off-host, a workspace path such as `/srv/remote-alice` belongs to the *target* machine — but several resolvers normalized it against the WebUI host's filesystem, so a host-side expansion (macOS firmlinks turning it into `/System/Volumes/Data/srv/remote-alice`, or a local `~` expansion) could be persisted back onto the session and then used as the per-turn runtime CWD. The workspace, streaming, upload and project-context resolvers now receive the owning **session's profile** explicitly instead of resolving against whatever profile happens to be ambient, so remote paths stay target-side and a session owned by one profile can never resolve through another's. Local (non-remote) workspaces normalize exactly as before, and symlink/`..`/outside-root rejection is unchanged. Thanks @alfred-rootson. (#7168, closes #7131) From 963e1bae8a221bf0281114b2a74e88aa11d4c039 Mon Sep 17 00:00:00 2001 From: totalitarian Date: Wed, 16 Sep 2026 01:12:45 +0100 Subject: [PATCH 057/446] Decode state.db content sentinel in the WebUI message projection (#7492) Decode the state.db content sentinel in the WebUI message projection so structured/multimodal content renders instead of a placeholder, with an out-of-band tuple identity that no scalar can forge and a per-call ContextVar memo for the canonical serialisation. Co-authored-by: totalitarian --- api/models.py | 292 +++++++++++- docs/architecture/agent-api-contract.md | 101 ++++- ...t_merge_append_only_normalization_cache.py | 35 +- .../test_state_db_content_sentinel_decode.py | 416 ++++++++++++++++++ 4 files changed, 820 insertions(+), 24 deletions(-) create mode 100644 tests/test_state_db_content_sentinel_decode.py diff --git a/api/models.py b/api/models.py index 754c372f..22c22128 100644 --- a/api/models.py +++ b/api/models.py @@ -1,5 +1,6 @@ """Hermes Web UI -- Session model and in-memory session store.""" import collections +import contextvars import copy import datetime import hashlib @@ -8227,18 +8228,138 @@ def _state_db_active_rows_digest(rows) -> str: return digest.hexdigest() if stamped else '' +# hermes_state stores list/dict message content (multimodal parts) as a +# sentinel-prefixed JSON string because sqlite3 binds only scalars; see +# hermes_state._CONTENT_JSON_PREFIX and _decode_content(). This module reads +# that table with its own SQL, so it must apply the same decode. Without it an +# image part's base64 data URI reaches the transcript as literal text -- a +# single unbreakable ~65k-character run -- and WebKit computes min-content +# width by scanning every line-break position, pinning a core for minutes. +# +# The decode deliberately does NOT widen `content` beyond the one shape the +# rest of the WebUI already accepts: +# * list roots only. A dict root would reach _getCachedRender() unchanged and +# _renderCacheKey() would call text.slice() on an object, blanking the turn. +# * no non-finite numbers. Python emits NaN/Infinity, which the browser's +# JSON.parse() rejects, breaking the whole /api/session response. +# Anything else is returned as the original string, exactly as before. +_STATE_DB_CONTENT_JSON_PREFIX = "\x00json:" + + +def _is_valid_image_part_payload(part) -> bool: + """Explicit per-type payload validation for an image content part.""" + part_type = part.get("type") + if part_type in ("image_url", "input_image"): + ref = part.get("image_url") + if isinstance(ref, dict): + ref = ref.get("url") + if isinstance(ref, str) and ref.strip(): + return True + file_id = part.get("file_id") + return part_type == "input_image" and isinstance(file_id, str) and bool(file_id.strip()) + if part_type == "image": + source = part.get("source") + if not isinstance(source, dict): + return False + if source.get("type") == "base64": + data = source.get("data") + return ( + isinstance(data, str) + and bool(data) + and isinstance(source.get("media_type"), str) + ) + if source.get("type") == "url": + url = source.get("url") + return isinstance(url, str) and bool(url.strip()) + return False + return False + + +def _is_supported_content_part_list(parts) -> bool: + """True only for lists the current WebUI will actually render. + + The shared JS readers keep text parts and discard every image part: + ``msgContent()`` joins the text parts and trims, and ``_messageIsRenderable()`` + hides the row when that is empty. This projection supplies no attachments, + so image parts do not render from it either. A list is therefore decoded only + when it carries non-whitespace text to show. Image-only lists, and lists with + a malformed image part, stay undecoded so the row cannot silently vanish. + """ + if not parts: + return False + has_text = False + for part in parts: + if not isinstance(part, dict): + return False + part_type = part.get("type") + if not isinstance(part_type, str): + return False + if part_type == "text": + text = part.get("text") + if not isinstance(text, str): + return False + if text.strip(): + has_text = True + elif part_type in _SESSION_MESSAGE_IMAGE_PART_TYPES: + if not _is_valid_image_part_payload(part): + return False + else: + return False + return has_text + + +def _reject_non_finite_state_db_json_constant(value): + raise ValueError(f"unsupported JSON constant: {value}") + + +def _parse_finite_state_db_json_float(value): + parsed = float(value) + if not math.isfinite(parsed): + raise ValueError(f"unsupported JSON float: {value}") + return parsed + + +def _decode_state_db_content(value): + """Decode the Agent's structured-content storage form without widening it. + + Returns the original value unchanged for anything that is not a + sentinel-prefixed, finite, list-rooted payload. + """ + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + if not isinstance(value, str) or not value.startswith(_STATE_DB_CONTENT_JSON_PREFIX): + return value + try: + decoded = json.loads( + value[len(_STATE_DB_CONTENT_JSON_PREFIX):], + parse_constant=_reject_non_finite_state_db_json_constant, + parse_float=_parse_finite_state_db_json_float, + ) + except Exception: + return value + if not isinstance(decoded, list) or not _is_supported_content_part_list(decoded): + return value + try: + # Prove the decoded value survives the trip to the browser before + # handing it on: re-serialisable, finite, UTF-8 encodable. + json.dumps(decoded, ensure_ascii=False, allow_nan=False).encode("utf-8") + except Exception: + return value + return decoded + + def _project_state_db_message(row, available, id_col, optional): """Authoritative state.db row → WebUI message projection (#6826 r4). Shared by ``get_state_db_session_messages`` and the regeneration single-snapshot helper so the bounded tail can never drift from the - canonical reader: JSON-decode tool_calls/reasoning payloads, omit + canonical reader: JSON-decode content/tool_calls/reasoning payloads, omit empty fields, keep durable row id private (``_state_db_row_id`` only for real Agent api_content replays), and apply ``tool_name → name``. """ msg = { 'role': row['role'], - 'content': row['content'], + 'content': _decode_state_db_content(row['content']), 'timestamp': row['timestamp'], } for col in optional: @@ -8641,7 +8762,11 @@ def get_state_db_session_message_keys_before_timestamp( _session_message_visible_key( { "role": row["role"], - "content": row["content"], + # Same guarded decode as the projected tail: prefix and + # tail keys must share one representation or the + # prefix/tail collision proof can miss a genuine + # repeated recovered turn. + "content": _decode_state_db_content(row["content"]), "tool_calls": _json_loads_if_string(row["tool_calls"]), "api_content": row["api_content"] if "api_content" in available else None, }, @@ -8749,7 +8874,7 @@ def get_state_db_regeneration_tail_snapshot( prefix_keys = [ _session_message_visible_key({ "role": r["role"], - "content": r["content"], + "content": _decode_state_db_content(r["content"]), "tool_calls": _json_loads_if_string(r["tool_calls"]) if "tool_calls" in r.keys() and r["tool_calls"] is not None else None, "api_content": r["api_content"] if "api_content" in r.keys() else None, }, normalize_workspace_prefix=True) @@ -8936,14 +9061,27 @@ def _session_message_multimodal_mirror_key( msg: dict, *, require_image_parts: bool = False, + require_scalar_mirror: bool = False, ): - """Return exact cross-store identity for a native multimodal mirror.""" + """Return exact cross-store identity for a native multimodal mirror. + + The bridge exists to pair ONE rich image-bearing row with ONE scalar row + holding the Agent's stored projection of it. It must never pair two rich + rows: distinct image turns can project to the same "[screenshot] " + string, so rich-to-rich matching collapses different images into one turn. + Callers pass ``require_image_parts`` on the rich side and + ``require_scalar_mirror`` on the scalar side to keep the pairing asymmetric. + """ if not isinstance(msg, dict): return None + if require_image_parts and require_scalar_mirror: + return None role = str(msg.get("role") or "").strip().lower() if role != "user": return None raw_content = msg.get("content") + if require_scalar_mirror and not isinstance(raw_content, str): + return None content = _agent_durable_multimodal_content(msg) if require_image_parts and content is None: return None @@ -8969,6 +9107,53 @@ def _session_message_multimodal_mirror_key( ) +# Per-call memo of structured-content identities. Reconciliation derives merge, +# dedup, content and visible keys for every message, several per source, so a +# large multimodal payload would otherwise be serialised once per key. The memo +# is scoped to a single merge call (set/reset in +# merge_session_messages_append_only) and keyed by object identity with the +# object held alive, so a later call always recomputes after mutation and an id +# can never be reused for a different list within one call. +_STRUCTURED_IDENTITY_MEMO = contextvars.ContextVar( + "_STRUCTURED_IDENTITY_MEMO", default=None +) + + +def _canonical_structured_content(content) -> str: + """Canonical serialisation of structured content -- the expensive step.""" + try: + return json.dumps(content, sort_keys=True, ensure_ascii=False, default=str) + except Exception: + return repr(content) + + +def _content_identity_for_key(content): + """Identity component for message content in every reconciliation key. + + Non-list values key exactly as they always have: ``str(content or "")``. + + Non-empty list content -- which reaches these paths once the state.db + sentinel is decoded -- gets an OUT-OF-BAND identity: a tuple, not a string. + No scalar can ever compare equal to it, so there is no in-band marker for a + message body to imitate. (An earlier revision tagged lists with a string + prefix; a scalar containing that prefix collided with the rich row.) + + Merge, dedup, content and visible keys all derive structured content + through this one function so the discriminator cannot drift between them. + """ + if not (isinstance(content, list) and content): + return str(content or "") + memo = _STRUCTURED_IDENTITY_MEMO.get() + if memo is not None: + hit = memo.get(id(content)) + if hit is not None and hit[0] is content: + return hit[1] + identity = ("structured_content", _canonical_structured_content(content)) + if memo is not None: + memo[id(content)] = (content, identity) + return identity + + def _session_message_merge_key(msg: dict): if not isinstance(msg, dict): return ("non_dict", repr(msg)) @@ -8988,7 +9173,7 @@ def _session_message_merge_key(msg: dict): return _session_message_key_with_sidecar(( "legacy", str(msg.get("role") or ""), - str(msg.get("content") or ""), + _content_identity_for_key(msg.get("content")), _normalized_message_timestamp_for_key(msg.get("timestamp")), str(msg.get("tool_call_id") or ""), str(msg.get("tool_name") or msg.get("name") or ""), @@ -9239,7 +9424,9 @@ def _copy_api_content_sidecar(target: dict | None, source: dict | None) -> bool: ) if ( target_mirror_key is None - or target_mirror_key != _session_message_multimodal_mirror_key(source) + or target_mirror_key != _session_message_multimodal_mirror_key( + source, require_scalar_mirror=True + ) ): return False if target.get("api_content") not in (None, ""): @@ -9679,7 +9866,7 @@ def _session_message_dedup_key(msg: dict): return _session_message_key_with_sidecar(( "legacy", str(msg.get("role") or ""), - str(msg.get("content") or ""), + _content_identity_for_key(msg.get("content")), str(msg.get("timestamp") or ""), str(msg.get("tool_call_id") or ""), str(msg.get("tool_name") or msg.get("name") or ""), @@ -9687,10 +9874,19 @@ def _session_message_dedup_key(msg: dict): ), msg) -def _normalized_session_message_content(msg: dict) -> str: +def _normalized_session_message_content(msg: dict): + """Visible identity for a message's content. + + Scalars normalise whitespace as before. Structured content returns the same + out-of-band tuple as the merge/dedup keys, so content and visible keys can + never place a list and a string in the same identity space. + """ if not isinstance(msg, dict): return repr(msg) - return " ".join(str(msg.get("content") or "").split()) + content = msg.get("content") + if isinstance(content, list) and content: + return _content_identity_for_key(content) + return " ".join(str(content or "").split()) def _loose_session_message_content(value: str) -> str: @@ -9706,7 +9902,7 @@ def _session_message_content_key( return ("non_dict", repr(msg)) role = str(msg.get("role") or "") content = _normalized_session_message_content(msg) - if role == "user" and normalize_workspace_prefix: + if role == "user" and normalize_workspace_prefix and isinstance(content, str): # WebUI sends the model a workspace-prefixed user_message # ("[Workspace::v1: /path]\n") while the visible/optimistic # bubble and the WebUI sidecar row carry only the bare "". The @@ -9749,7 +9945,7 @@ def _session_message_visible_key( _tc_key = json.dumps(_tc, sort_keys=True, default=str) if _tc else "" role = str(msg.get("role") or "") content = _normalized_session_message_content(msg) - if role == "user" and normalize_workspace_prefix: + if role == "user" and normalize_workspace_prefix and isinstance(content, str): # state.db stores the model-facing workspace-prefixed prompt while the # WebUI sidecar owns the bare visible text. Fold that protocol wrapper # into the exact key so large-session reconciliation does not depend on @@ -9774,7 +9970,9 @@ def _build_visible_duplicate_lookup(visible_keys: set[tuple]) -> dict: content = key[1] except (TypeError, IndexError): continue - if not content: + # Only text identities take part in fuzzy matching; structured content + # is exact-identity only and must never fuzzy-match a scalar. + if not content or not isinstance(content, str): continue by_role.setdefault(role, []).append(key) # Keep loose_by_key lazy. Some transcripts contain multi-megabyte tool @@ -9794,6 +9992,11 @@ def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple], lo sidecar = visible_key[3] if len(visible_key) > 3 else None if not content: return None + # Structured content is matched by exact identity only (checked above). + # Substring/token matching would otherwise compare a canonical list + # serialisation against arbitrary text and pair a rich row with a scalar. + if not isinstance(content, str): + return None # Exact identity above remains authoritative at every size. The fallback # below scans the existing keys for every candidate, so it becomes # quadratic on long transcripts even when each individual message is small. @@ -9808,7 +10011,12 @@ def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple], lo existing_role = existing_key[0] existing_content = existing_key[1] if len(existing_key) > 1 else "" existing_sidecar = existing_key[3] if len(existing_key) > 3 else None - if role != existing_role or sidecar != existing_sidecar or not existing_content: + if ( + role != existing_role + or sidecar != existing_sidecar + or not existing_content + or not isinstance(existing_content, str) + ): continue # Exact visible-key equality was checked above. For very large payloads # (tool logs / request dumps), Python-in substring and fuzzy-token @@ -10143,6 +10351,30 @@ def merge_session_messages_append_only( ) -> list: """Merge sidecar/context and state.db messages without deleting local rows. + Thin wrapper that scopes the structured-content identity memo to this one + call; see :func:`_merge_session_messages_append_only_impl` for the merge. + """ + token = _STRUCTURED_IDENTITY_MEMO.set({}) + try: + return _merge_session_messages_append_only_impl( + sidecar_messages, + state_messages, + truncation_watermark=truncation_watermark, + truncation_boundary=truncation_boundary, + ) + finally: + _STRUCTURED_IDENTITY_MEMO.reset(token) + + +def _merge_session_messages_append_only_impl( + sidecar_messages: list, + state_messages: list, + *, + truncation_watermark=None, + truncation_boundary=None, +) -> list: + """Merge sidecar/context and state.db messages without deleting local rows. + ``truncation_boundary``: the original truncate cutoff — the timestamp of the last message kept by the truncate operation. When the watermark is later advanced (new turn committed), this boundary is preserved @@ -10233,7 +10465,16 @@ def merge_session_messages_append_only( value = helper(msg) # If this is a legacy message key, keep the already-stringified # content payload for downstream helper calls. - if isinstance(value, tuple) and value and value[0] == "legacy": + # Structured content is never substituted: its key component is + # an identity token, not content, and writing it back would let + # a scalar equal to that token impersonate the rich row. + if ( + isinstance(value, tuple) + and value + and value[0] == "legacy" + and isinstance(value[2], str) + and not isinstance(msg.get("content"), list) + ): prepared_msg = dict(msg) prepared_msg["content"] = value[2] _cached_msg_prepared[msg_cache_key] = prepared_msg @@ -10248,13 +10489,20 @@ def merge_session_messages_append_only( prepared_msg = _cached_msg_prepared.get(msg_cache_key) if prepared_msg is None: prepared_msg = dict(msg) - prepared_msg["content"] = ( - merge_key[2] - if isinstance(merge_key, tuple) + raw_content = msg.get("content") + if isinstance(raw_content, list): + # Keep the real structure; downstream keys derive their own + # out-of-band identity from it. + prepared_msg["content"] = raw_content + elif ( + isinstance(merge_key, tuple) and len(merge_key) > 2 and merge_key[0] == "legacy" - else str(msg.get("content") or "") - ) + and isinstance(merge_key[2], str) + ): + prepared_msg["content"] = merge_key[2] + else: + prepared_msg["content"] = str(raw_content or "") _cached_msg_prepared[msg_cache_key] = prepared_msg value = helper(prepared_msg) @@ -10397,7 +10645,9 @@ def merge_session_messages_append_only( if sidecar_multimodal_mirrors: state_multimodal_mirror_identities = {} for state_message in state_messages: - mirror_key = _session_message_multimodal_mirror_key(state_message) + mirror_key = _session_message_multimodal_mirror_key( + state_message, require_scalar_mirror=True + ) state_multimodal_mirror_keys[id(state_message)] = mirror_key if ( mirror_key is not None diff --git a/docs/architecture/agent-api-contract.md b/docs/architecture/agent-api-contract.md index 9dabe79a..e6516de5 100644 --- a/docs/architecture/agent-api-contract.md +++ b/docs/architecture/agent-api-contract.md @@ -41,10 +41,109 @@ helpers are genuinely shared code. | `docker_agent_source_volume` | Compose files and Docker docs expose `hermes-agent-src` and `/opt/hermes` to make the agent checkout visible to WebUI. | Remove the WebUI source mount only after startup install and runtime imports have migrated. This needs Docker/compose follow-up work, not a runtime behavior change in this audit PR. | | `startup_dependency_install` | `api/startup.py` discovers `HERMES_WEBUI_AGENT_DIR` or `$HERMES_HOME/hermes-agent`; `server.py` calls `auto_install_agent_deps()` after import verification fails; `docker_init.bash` installs from the staged agent source. | Replace source-tree pip installs with a packaged hermes-agent WebUI client plus an agent health/version capability contract. Keep `HERMES_WEBUI_AGENT_DIR` during migration as an override/debug path, but it should stop being required in normal multi-container startup. | | `runtime_auxiliary_model_metadata` | `api/streaming.py`, `api/routes.py`, `api/config.py`, and `api/providers.py` import `agent.auxiliary_client`, `agent.model_metadata`, `agent.models_dev`, `hermes_cli.models`, and `agent.account_usage`. | Existing provider/model WebUI endpoints can keep serving UI data where they already wrap agent helpers. Missing surfaces need hermes-agent endpoints or a client package for auxiliary task config, text auxiliary calls, context length, token estimate, provider catalog, and account usage. | -| `runtime_session_state` | `api/streaming.py`, `api/goals.py`, and `api/state_sync.py` import `hermes_state.SessionDB` directly. `api/models.py` also opens the active profile's canonical `state.db` for scoped session deletion because the current canonical helper does not preserve branch/compression evidence ahead of inherited delegate metadata or expose retryable artifact-cleanup semantics. | Move cross-container state reads and writes, including destructive session deletion, behind hermes-agent session/state endpoints once the agent API provides equivalent lineage precedence, transaction, and retry-manifest guarantees. WebUI-only presentation state can remain local, but agent session storage should not be opened from the WebUI container. | +| `runtime_session_state` | `api/streaming.py`, `api/goals.py`, and `api/state_sync.py` import `hermes_state.SessionDB` directly. `api/models.py` also reads the `messages` table directly (see [state.db message content encoding](#statedb-message-content-encoding) for the storage-format coupling that creates) and opens the active profile's canonical `state.db` for scoped session deletion because the current canonical helper does not preserve branch/compression evidence ahead of inherited delegate metadata or expose retryable artifact-cleanup semantics. | Move cross-container state reads and writes, including destructive session deletion, behind hermes-agent session/state endpoints once the agent API provides equivalent lineage precedence, transaction, and retry-manifest guarantees. WebUI-only presentation state can remain local, but agent session storage should not be opened from the WebUI container. | | `runtime_gateway_provider` | `api/streaming.py` and `api/routes.py` import `hermes_cli.runtime_provider`; adapter helpers such as `agent.anthropic_adapter` are also imported for gateway normalization. | Provider resolution, runtime routing, and gateway invocation should be hermes-agent API calls. WebUI can keep request validation and display formatting, but it should not import runtime provider internals from the agent checkout. | | `webui_local_or_client_package` | WebUI imports `hermes_cli.auth`, `hermes_cli.config`, `hermes_cli.plugins`, `hermes_cli.profiles`, `hermes_cli.goals`, `agent.skill_utils`, `agent.credential_pool`, and `hermes_constants`. | Pure schemas, constants, and parsing helpers can move into a small versioned client/shared package. Privileged data such as credential pools, auth status, profile mutation, plugin discovery, and goal persistence need hermes-agent endpoints. UI-only formatting can remain in WebUI. | +## state.db message content encoding + +`api/models.py` reads the agent's `messages` table with its own SQL, so it also +depends on how hermes-agent *encodes* that table, not only on its schema. This +is a storage-format coupling and belongs with the `runtime_session_state` +dependency class above. + +`hermes_state` stores list/dict message content (multimodal parts) as a +sentinel-prefixed JSON string, because sqlite3 binds only scalars: + +``` +_CONTENT_JSON_PREFIX = "\x00json:" # hermes_state.py +``` + +It provides `_decode_content()` to reverse this. Any WebUI read path that +projects that column must apply an equivalent decode; a raw read hands the +frontend an encoded string that no reader recognises, and an image part's +base64 data URI then renders as literal transcript text. + +### WebUI decoding contract + +`_decode_state_db_content()` in `api/models.py` is the single decode point. It +is deliberately narrower than the agent's own decoder, because the WebUI can +only accept shapes the rest of its pipeline already renders: + +| Input | Result | Why | +| --- | --- | --- | +| Sentinel + list with non-whitespace text and only valid image parts | decoded `list` | `msgContent()` joins the text parts, so the row renders its text | +| Sentinel + image-only list, or text that is empty/whitespace | unchanged string | `msgContent()` discards image parts, so `_messageIsRenderable()` would hide the row with no error | +| Sentinel + list containing a malformed image part | unchanged string | a part must carry a valid per-type payload, not just a matching `type` | +| Sentinel + dict or scalar root | unchanged string | a dict reaches `_getCachedRender()`, and `_renderCacheKey()` calls `text.slice()` on it, blanking the turn | +| Sentinel + `NaN`/`Infinity`/overflowed float | unchanged string | Python emits them, browser `JSON.parse()` rejects the whole `/api/session` payload | +| Sentinel + unsupported part shapes | unchanged string | `input_text`, `output_text`, scalar and unknown parts are dropped by the JS readers, so decoding them would silently lose content that is visible today | +| Anything without the sentinel | unchanged | non-sentinel content is not this contract's concern | + +Supported parts are `{"type": "text", "text": }` plus image parts whose +payload validates for their type: `image_url` with a non-empty URL (string or +`{"url": ...}`), `input_image` with a URL or `file_id`, and `image` with a +`base64` source carrying `data` and `media_type` or a `url` source. At least one +text part must contain non-whitespace text. + +**Image parts do not render from this projection.** The shared JS readers drop +them, and the state.db projection supplies no `attachments`. Decoding a +text-and-image row shows its text and keeps the base64 payload out of the DOM; +it does not display the image. Rendering images from state.db rows would need a +shared inline-image projection first, at which point image-only lists could be +accepted too. + +Widening the accepted schema requires teaching every shared content reader +through one extractor first; until then unsupported shapes must keep falling +back to the raw string. + +### Consequences for identity and bounded reads + +Decoding changes the runtime type of `content`, so every consumer that derives +an identity from it must agree on one representation: + +- Every key -- merge, dedup, content, visible and the fuzzy fallback -- derives + content identity through `_content_identity_for_key()`. Non-list values key + exactly as on master, `str(content or "")`. Non-empty lists get an + **out-of-band** tuple identity, so no message body can compare equal to one: + an in-band string marker would be forgeable by a scalar that contains it. + Two rich turns sharing visible text and timestamp stay distinct when their + images differ. +- Fuzzy duplicate matching is text-only. Structured identities match by exact + identity or not at all, so a rich row can never fuzzy-match a scalar. +- The merge key cache never writes a key component back into message content. + To avoid re-serialising large payloads for every key it instead memoises the + canonical serialisation per content object, scoped to one + `merge_session_messages_append_only()` call. +- The multimodal mirror bridge pairs one rich image-bearing row with one scalar + mirror only. `require_image_parts` and `require_scalar_mirror` are mutually + exclusive so rich-to-rich pairing cannot occur. +- Every read path that projects the `content` column applies the decoder, so + keys derived on one path cannot disagree with keys derived on another. There + are exactly three such call sites: + + | Call site | Role | + | --- | --- | + | `_project_state_db_message()` | canonical row projection, shared by the transcript read and the regeneration tail | + | `get_state_db_session_message_keys_before_timestamp()` | bounded prefix keys | + | `get_state_db_regeneration_tail_snapshot()` | regeneration prefix keys | + + If prefix keys stayed encoded while the projected tail was decoded, the + prefix/tail collision proof could miss a genuine repeated recovered turn and + `_bounded_tail_snapshot_if_safe` would reject the bounded path, reading the + entire transcript during regeneration. + + Two nearby paths deliberately need no decode. + `get_state_db_session_message_prefix_summary()` projects only timestamp + counts and never selects `content`. State-db sidecar reconstruction + (`_sync_sidecar_from_state_db_if_newer()`) sources its rows through + `get_state_db_session_messages()`, so it inherits the canonical decoded + projection rather than reading the column itself. + +When session state moves behind hermes-agent endpoints, this decode should move +with it: the agent should return structured content over the API and the WebUI +should stop depending on the sentinel format at all. + ## Replacement contract ### Existing endpoint candidates diff --git a/tests/test_merge_append_only_normalization_cache.py b/tests/test_merge_append_only_normalization_cache.py index 04746ad6..d95418e1 100644 --- a/tests/test_merge_append_only_normalization_cache.py +++ b/tests/test_merge_append_only_normalization_cache.py @@ -23,6 +23,33 @@ class _ExplodingList(list): raise RuntimeError("content __str__ must not run") +def _count_content_normalizations(monkeypatch): + """Count how often structured content is actually normalized. + + These tests originally counted ``__str__`` on the content list, because the + key builders stringified content with ``str()``. Structured content is now + serialized canonically (type-namespaced) instead, so ``str()`` never runs on + a list and the old counter reads zero. + + The property under test is unchanged -- expensive normalization of the same + content must happen a bounded number of times, not once per key -- so the + counter now observes the normalization helper itself and ignores the cheap + passthrough calls where content has already been reduced to a string. + """ + counts = {"count": 0} + original = models._canonical_structured_content + + def _wrapped(content): + counts["count"] += 1 + return original(content) + + # Observe the serialisation itself, not the identity lookups: every key + # derivation asks for the identity, but a list must be serialised only once + # per merge call. + monkeypatch.setattr(models, "_canonical_structured_content", _wrapped) + return counts + + def _install_key_wrappers(monkeypatch): call_counts: dict[str, dict[int, int]] = { "merge": defaultdict(int), @@ -66,6 +93,7 @@ def test_merge_append_only_caches_canonical_keys_and_preserves_identity(monkeypa sidecar_messages = [repeated_user, repeated_user, repeated_user, repeated_assistant] state_messages = [repeated_assistant, repeated_user] + normalizations = _count_content_normalizations(monkeypatch) call_counts = _install_key_wrappers(monkeypatch) merged = models.merge_session_messages_append_only(sidecar_messages, state_messages) @@ -77,7 +105,8 @@ def test_merge_append_only_caches_canonical_keys_and_preserves_identity(monkeypa ] assert merged == [repeated_user, repeated_user, repeated_user, repeated_assistant] - assert str_calls["count"] == 2 + assert normalizations["count"] == 2 + assert str_calls["count"] == 0 assert dict(call_counts["merge"]) == { id(repeated_user): 1, id(repeated_assistant): 1, @@ -203,6 +232,7 @@ def test_merge_append_only_recomputes_after_mutation(monkeypatch): "timestamp": 3000, } + normalizations = _count_content_normalizations(monkeypatch) call_counts = _install_key_wrappers(monkeypatch) first = models.merge_session_messages_append_only([], [state_message]) state_message["content"].append("second") @@ -211,7 +241,8 @@ def test_merge_append_only_recomputes_after_mutation(monkeypatch): assert first == [state_message] assert second == [state_message] assert first is not second - assert str_calls["count"] == 2 + assert normalizations["count"] == 2 + assert str_calls["count"] == 0 assert dict(call_counts["dedup"]) == { id(state_message): 2, } diff --git a/tests/test_state_db_content_sentinel_decode.py b/tests/test_state_db_content_sentinel_decode.py new file mode 100644 index 00000000..1326e58b --- /dev/null +++ b/tests/test_state_db_content_sentinel_decode.py @@ -0,0 +1,416 @@ +"""Regression tests for the state.db structured-content sentinel. + +hermes_state stores list/dict message content as a NUL-sentinel JSON string. +While the WebUI projector left it undecoded, an uploaded image's base64 data +URI reached the transcript as literal text -- one unbreakable ~65k-character +run -- and the browser spent minutes computing its min-content width. + +The decode is deliberately narrow: it must not widen ``content`` into any +shape the rest of the WebUI pipeline cannot already render. +""" +import json + +from api.models import ( + _content_identity_for_key, + _decode_state_db_content, + _project_state_db_message, + _session_message_dedup_key, + _session_message_merge_key, + _session_message_multimodal_mirror_key, + _session_message_visible_key, +) + +PREFIX = "\x00json:" +TEXT_AND_IMAGE = [ + {"type": "text", "text": "here is a screenshot"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}, +] + + +def _sentinel(payload_json: str) -> str: + return PREFIX + payload_json + + +# --- the fix itself ------------------------------------------------------- + +def test_supported_list_root_is_decoded(): + assert _decode_state_db_content(_sentinel(json.dumps(TEXT_AND_IMAGE))) == TEXT_AND_IMAGE + + +def test_projector_decodes_content(): + row = {"role": "user", "content": _sentinel(json.dumps(TEXT_AND_IMAGE)), + "timestamp": 1.0, "id": 1} + msg = _project_state_db_message(row, available=set(), id_col=False, optional=()) + assert msg["content"] == TEXT_AND_IMAGE + + +# --- finding #1: dict roots must NOT be widened --------------------------- + +def test_dict_root_falls_back_to_raw_string(): + """A dict root reaches _renderCacheKey(), which calls text.slice() on it + and throws, blanking the turn. It must stay a string.""" + raw = _sentinel(json.dumps({"type": "text", "text": "hi"})) + assert _decode_state_db_content(raw) == raw + + +def test_scalar_roots_fall_back_to_raw_string(): + for payload in ("42", '"just a string"', "true", "null"): + raw = _sentinel(payload) + assert _decode_state_db_content(raw) == raw + + +# --- finding #2: non-finite numbers break browser JSON.parse -------------- + +def test_non_finite_constants_fall_back_to_raw_string(): + for literal in ("NaN", "Infinity", "-Infinity"): + raw = _sentinel('[{"type": "text", "text": 1}, %s]' % literal) + assert _decode_state_db_content(raw) == raw + + +def test_overflowed_float_falls_back_to_raw_string(): + raw = _sentinel('[{"type": "text", "text": "x", "score": 1e400}]') + assert _decode_state_db_content(raw) == raw + + +def test_decoded_payload_is_always_browser_parseable(): + decoded = _decode_state_db_content(_sentinel(json.dumps(TEXT_AND_IMAGE))) + json.loads(json.dumps(decoded, allow_nan=False)) + + +# --- finding #3: only the schema the UI actually renders ------------------ + +def test_unsupported_part_shapes_fall_back_to_raw_string(): + unsupported = [ + [{"type": "input_text", "text": "dropped by the JS readers"}], + [{"type": "output_text", "text": "also dropped"}], + [{"type": "tool_use", "id": "t1"}], + ["a bare scalar part"], + [{"text": "no type key"}], + [{"type": "text", "text": {"not": "a string"}}], + [], + ] + for payload in unsupported: + raw = _sentinel(json.dumps(payload)) + assert _decode_state_db_content(raw) == raw, payload + + +def test_image_only_list_stays_raw_because_it_would_not_render(): + """msgContent() discards image parts and this projection supplies no + attachments, so an image-only row would decode to nothing visible and + _messageIsRenderable() would hide it. It must stay a raw string instead.""" + payload = [{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}}] + raw = _sentinel(json.dumps(payload)) + assert _decode_state_db_content(raw) == raw + + +# --- passthrough / malformed --------------------------------------------- + +def test_plain_values_pass_through_unchanged(): + for value in ("hello", "", None, 42, ["already", "a", "list"], "see json: below"): + assert _decode_state_db_content(value) == value + + +def test_malformed_sentinel_payload_returns_raw_string(): + raw = _sentinel("{not valid json") + assert _decode_state_db_content(raw) == raw + + +# --- finding #4: identities must be type-namespaced ---------------------- + +def test_structured_content_cannot_collide_with_its_own_repr(): + structured = {"role": "user", "content": TEXT_AND_IMAGE, "timestamp": 1.0} + scalar = {"role": "user", "content": str(TEXT_AND_IMAGE), "timestamp": 1.0} + assert _session_message_merge_key(structured) != _session_message_merge_key(scalar) + assert _session_message_dedup_key(structured) != _session_message_dedup_key(scalar) + + +def test_rich_turns_with_different_images_keep_distinct_identities(): + def turn(url): + return {"role": "user", "timestamp": 1.0, "content": [ + {"type": "text", "text": "same visible text"}, + {"type": "image_url", "image_url": {"url": url}}, + ]} + a, b = turn("data:image/png;base64,AAAA"), turn("data:image/png;base64,BBBB") + assert _session_message_merge_key(a) != _session_message_merge_key(b) + assert _session_message_dedup_key(a) != _session_message_dedup_key(b) + + +def test_scalar_content_identity_is_unchanged(): + """Existing scalar behaviour must not shift.""" + assert _content_identity_for_key("plain text") == "plain text" + assert _content_identity_for_key(None) == "" + assert _content_identity_for_key("") == "" + + +def test_mirror_bridge_never_pairs_rich_to_rich(): + rich = {"role": "user", "timestamp": 1.0, "content": TEXT_AND_IMAGE} + assert _session_message_multimodal_mirror_key(rich, require_image_parts=True) is not None + # the scalar side of the bridge must refuse a rich row + assert _session_message_multimodal_mirror_key(rich, require_scalar_mirror=True) is None + # and the two flags are mutually exclusive by construction + assert _session_message_multimodal_mirror_key( + rich, require_image_parts=True, require_scalar_mirror=True + ) is None + + +def test_mirror_bridge_still_accepts_a_scalar_mirror(): + scalar = {"role": "user", "timestamp": 1.0, "content": "[screenshot] here is a screenshot"} + assert _session_message_multimodal_mirror_key(scalar, require_scalar_mirror=True) is not None + + +# --- finding #5: prefix and tail keys share one representation ----------- + +def test_prefix_and_tail_keys_agree_for_a_sentinel_row(): + raw = _sentinel(json.dumps(TEXT_AND_IMAGE)) + row = {"role": "user", "content": raw, "timestamp": 5.0, "id": 7} + tail_msg = _project_state_db_message(row, available=set(), id_col=False, optional=()) + prefix_msg = { + "role": row["role"], + "content": _decode_state_db_content(row["content"]), + "tool_calls": None, + "api_content": None, + } + tail_key = _session_message_visible_key( + {"role": tail_msg.get("role"), "content": tail_msg.get("content"), + "tool_calls": None, "api_content": None}, + normalize_workspace_prefix=True, + ) + prefix_key = _session_message_visible_key(prefix_msg, normalize_workspace_prefix=True) + assert prefix_key == tail_key + + +# --- every read path that projects content must decode (behavioural) ------ +# +# Keys derived on one read path are compared against keys derived on another, +# so a read path that projected the column raw while another decoded it would +# silently reintroduce the prefix/tail mismatch. These exercise each path +# against a real sentinel-encoded row rather than inspecting source. + +def _make_state_db(path, sid, rows): + import sqlite3 + conn = sqlite3.connect(path) + conn.execute( + "CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, title TEXT, " + "model TEXT, started_at REAL, message_count INTEGER)" + ) + conn.execute( + "CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, " + "role TEXT, content TEXT, timestamp REAL, tool_call_id TEXT, tool_calls TEXT, " + "tool_name TEXT, active INTEGER DEFAULT 1, api_content TEXT)" + ) + conn.execute( + "INSERT INTO sessions (id, source, title, model, started_at, message_count) " + "VALUES (?, ?, ?, ?, ?, ?)", + (sid, "webui", "Sentinel", "test-model", 1000.0, len(rows)), + ) + for row in rows: + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp, active) " + "VALUES (?, ?, ?, ?, 1)", + (sid, row["role"], row["content"], row["timestamp"]), + ) + conn.commit() + conn.close() + + +def _sentinel_session(tmp_path, monkeypatch): + """A session whose first row carries sentinel-encoded multimodal content.""" + from api import models + + sid = "sentineltest" + db = tmp_path / "state.db" + _make_state_db(db, sid, [ + {"role": "user", "content": _sentinel(json.dumps(TEXT_AND_IMAGE)), "timestamp": 1000.0}, + {"role": "assistant", "content": "plain reply", "timestamp": 2000.0}, + ]) + monkeypatch.setattr(models, "_active_state_db_path", lambda: db, raising=False) + return models, sid + + +def test_transcript_read_decodes_sentinel_rows(tmp_path, monkeypatch): + """The canonical projection hands the frontend structured content.""" + models, sid = _sentinel_session(tmp_path, monkeypatch) + messages = models.get_state_db_session_messages(sid) + assert messages, "expected the fixture session to load" + assert messages[0]["content"] == TEXT_AND_IMAGE + # the base64 payload must not survive as literal transcript text + assert not isinstance(messages[0]["content"], str) + + +def test_regeneration_prefix_and_tail_keys_agree_for_a_sentinel_row(tmp_path, monkeypatch): + """The same row keyed as prefix and as tail must produce one identity. + + Decoding the projected tail while leaving prefix keys encoded would make + `_bounded_tail_snapshot_if_safe` reject the bounded path and re-read the + whole transcript, and could hide a genuine repeated recovered turn. + """ + models, sid = _sentinel_session(tmp_path, monkeypatch) + + # floor above the sentinel row -> it is part of the prefix proof + as_prefix = models.get_state_db_regeneration_tail_snapshot(sid, 1500.0) + # floor below it -> the same row is part of the bounded tail + as_tail = models.get_state_db_regeneration_tail_snapshot(sid, 500.0) + assert as_prefix is not None and as_tail is not None + + assert as_prefix["prefix_keys"], "sentinel row should sit in the prefix" + assert as_tail["tail_keys"], "sentinel row should sit in the tail" + assert as_prefix["prefix_keys"][0] == as_tail["tail_keys"][0] + + +def test_bounded_prefix_reader_agrees_with_the_projected_tail(tmp_path, monkeypatch): + """The standalone prefix-key reader shares the tail's representation.""" + models, sid = _sentinel_session(tmp_path, monkeypatch) + + prefix_keys = models.get_state_db_session_message_keys_before_timestamp(sid, 1500.0) + tail = models.get_state_db_regeneration_tail_snapshot(sid, 500.0) + assert prefix_keys, "expected a prefix key for the sentinel row" + assert tail is not None and tail["tail_keys"] + assert prefix_keys[0] == tail["tail_keys"][0] + + +def test_unsupported_sentinel_shape_survives_the_read_path_as_text(tmp_path, monkeypatch): + """A shape the UI cannot render stays a string end-to-end, not silently dropped.""" + from api import models + + sid = "unsupported" + db = tmp_path / "state.db" + raw = _sentinel(json.dumps({"type": "text", "text": "dict root"})) + _make_state_db(db, sid, [{"role": "user", "content": raw, "timestamp": 1000.0}]) + monkeypatch.setattr(models, "_active_state_db_path", lambda: db, raising=False) + + messages = models.get_state_db_session_messages(sid) + assert messages + assert messages[0]["content"] == raw + + +# --- re-gate finding 2: only decode what will actually render -------------- + +def test_whitespace_only_text_with_an_image_stays_raw(): + payload = [ + {"type": "text", "text": " \n\t "}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}}, + ] + raw = _sentinel(json.dumps(payload)) + assert _decode_state_db_content(raw) == raw + + +def test_malformed_image_parts_stay_raw(): + text = {"type": "text", "text": "caption"} + malformed = [ + {"type": "image_url", "payload": "invalid"}, + {"type": "image_url", "image_url": {"url": ""}}, + {"type": "image_url", "image_url": {"href": "x"}}, + {"type": "input_image"}, + {"type": "image", "source": {"type": "base64", "data": "AA"}}, # no media_type + {"type": "image", "source": {"type": "url"}}, + {"type": "image", "source": "not-a-dict"}, + ] + for bad in malformed: + raw = _sentinel(json.dumps([text, bad])) + assert _decode_state_db_content(raw) == raw, bad + + +def test_text_with_each_valid_image_payload_shape_decodes(): + text = {"type": "text", "text": "caption"} + valid = [ + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}}, + {"type": "image_url", "image_url": "https://example.test/a.png"}, + {"type": "input_image", "image_url": "data:image/png;base64,AA"}, + {"type": "input_image", "file_id": "file-123"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AA"}}, + {"type": "image", "source": {"type": "url", "url": "https://example.test/a.png"}}, + ] + for good in valid: + payload = [text, good] + assert _decode_state_db_content(_sentinel(json.dumps(payload))) == payload, good + + +def test_image_only_row_stays_visible_through_the_read_path(tmp_path, monkeypatch): + from api import models + + sid = "imageonly" + db = tmp_path / "state.db" + raw = _sentinel(json.dumps([{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}}])) + _make_state_db(db, sid, [{"role": "user", "content": raw, "timestamp": 1000.0}]) + monkeypatch.setattr(models, "_active_state_db_path", lambda: db, raising=False) + messages = models.get_state_db_session_messages(sid) + assert messages and messages[0]["content"] == raw + + +# --- re-gate finding 1: out-of-band identity, consistent across every key --- + +from api.models import ( # noqa: E402 + _matching_visible_duplicate, + _session_message_content_key, + merge_session_messages_append_only, +) + +# Exactly what the previous in-band tag produced for TEXT_AND_IMAGE. +_OLD_INBAND_TOKEN = "\x00list:" + json.dumps(TEXT_AND_IMAGE, sort_keys=True, default=str) + + +def test_structured_identity_is_not_a_string(): + """Out of band: no message body can equal it, whatever it contains.""" + assert not isinstance(_content_identity_for_key(TEXT_AND_IMAGE), str) + + +def test_scalar_imitating_a_structured_identity_collides_with_no_key(): + rich = {"role": "user", "content": TEXT_AND_IMAGE, "timestamp": 1.0} + forged = {"role": "user", "content": _OLD_INBAND_TOKEN, "timestamp": 1.0} + assert _session_message_merge_key(rich) != _session_message_merge_key(forged) + assert _session_message_dedup_key(rich) != _session_message_dedup_key(forged) + assert _session_message_content_key(rich) != _session_message_content_key(forged) + assert _session_message_visible_key(rich) != _session_message_visible_key(forged) + + +def test_merge_keeps_the_rich_row_when_a_scalar_imitates_its_identity(): + rich = {"role": "user", "content": TEXT_AND_IMAGE, "timestamp": 1000.0} + forged = {"role": "user", "content": _OLD_INBAND_TOKEN, "timestamp": 1000.0} + merged = merge_session_messages_append_only([forged], [rich]) + assert any(m.get("content") == TEXT_AND_IMAGE for m in merged), merged + + +def test_non_list_scalars_key_exactly_as_on_master(): + """No silent dedup change for ordinary non-string content.""" + assert _content_identity_for_key(42) == "42" + assert _content_identity_for_key(3.5) == "3.5" + assert _content_identity_for_key(True) == "True" + assert _content_identity_for_key({"a": 1}) == str({"a": 1}) + assert _content_identity_for_key([]) == "" + msg = {"role": "user", "content": 42, "timestamp": 1.0} + assert "42" in _session_message_merge_key(msg) + + +def test_structured_visible_key_never_fuzzy_matches_a_scalar(): + rich_key = _session_message_visible_key({"role": "user", "content": TEXT_AND_IMAGE}) + canonical = _content_identity_for_key(TEXT_AND_IMAGE)[1] + for text in (canonical, "prefix " + canonical + " suffix", str(TEXT_AND_IMAGE)): + scalar_key = _session_message_visible_key({"role": "user", "content": text}) + assert _matching_visible_duplicate(rich_key, {scalar_key}) is None + assert _matching_visible_duplicate(scalar_key, {rich_key}) is None + + +def test_merge_never_writes_an_identity_into_message_content(): + rich = {"role": "user", "content": TEXT_AND_IMAGE, "timestamp": 1000.0} + reply = {"role": "assistant", "content": "ok", "timestamp": 1001.0} + merged = merge_session_messages_append_only([rich], [rich, reply]) + for message in merged: + assert isinstance(message.get("content"), (str, list)) + assert not isinstance(message.get("content"), tuple) + assert any(m.get("content") == TEXT_AND_IMAGE for m in merged) + + +def test_structured_content_is_serialised_once_per_merge_call(monkeypatch): + from api import models + + calls = {"n": 0} + real = models._canonical_structured_content + + def counting(content): + calls["n"] += 1 + return real(content) + + monkeypatch.setattr(models, "_canonical_structured_content", counting) + rich = {"role": "user", "content": list(TEXT_AND_IMAGE), "timestamp": 1000.0} + merge_session_messages_append_only([rich, rich], [rich]) + assert calls["n"] == 1 From 27b7064c4c4a774af11fa785279dd66dd940a8c7 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 15 Sep 2026 17:16:53 -0700 Subject: [PATCH 058/446] Release: decode state.db content sentinel in the message projection (#7492) (#7590) Co-authored-by: n --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f465640..5643e9a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### Fixed +- **Structured (multimodal) message content stored in `state.db` renders as its real content instead of a placeholder, without dropping or misattributing turns.** When the session projection decodes the `state.db` content sentinel, list-valued content now gets an out-of-band identity — a `("structured_content", canonical)` tuple that no scalar string can compare equal to — shared across the merge, dedup, content, and visible reconciliation keys, so an in-band string can't collide with a rich row and an image-only row can no longer vanish from the projection. The expensive canonical serialisation is memoised per content object for the duration of a single `merge_session_messages_append_only()` call (scoped through a `ContextVar`, keyed by object identity with the object held alive so an id can't be reused for a different list mid-call), restoring one serialisation per list per call. Empty/falsy content keys exactly as before. Thanks @totalitarian. (#7492) + - **An empty composer no longer inflates to fit its own placeholder on the JavaScript sizing path.** The fallback resizer measured `scrollHeight` for an empty textarea, which reflects the *placeholder* — so a long busy or compression hint (which wraps to two or three lines) grew the empty composer to roughly 71px, and only after a resize that happened while empty, making the resting height depend on history rather than content. The inline height is now cleared when the value is empty so CSS `min-height` defines the resting size, matching the native path. (#6760) - **Delegated subagent sessions nest under the conversation that spawned them instead of landing at the top of the sidebar.** The sidebar forces a *cross-surface* child row to top level when its parent row belongs to another surface (a Telegram/messaging parent), so an independent continuation doesn't get stacked under a foreign thread. A delegated subagent is also technically cross-source relative to its WebUI parent, so it was swept up by that rule and surfaced as a bare top-level row with no indication of what spawned it. Delegated subagents are now exempt from that branch and attach to their visible parent, while independent cross-surface continuations still stay top level. The delegated role is resolved in strict precedence order from the first non-blank of `raw_source` → `source_tag` → `source` and additionally requires the backend's child-session flag, so a stale lower-priority field can't promote an independent row into a delegated one. Thanks @lowlandsheperd. (#7263) From fc6efc008afe4b484265721cca16e61e18699fec Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Wed, 16 Sep 2026 05:52:02 +0300 Subject: [PATCH 059/446] fix: preserve unread state across transcript resets --- api/models.py | 10 ++ api/session_ops.py | 2 + api/webui_session_db.py | 2 + .../webui-run-state-consistency-contract.md | 28 +++- static/sessions.js | 137 ++++++++++++++---- tests/_unread_store_helpers.py | 7 +- tests/test_465_session_branching.py | 2 +- .../test_issue5273_active_done_viewed_sync.py | 4 +- .../test_issue6611_regeneration_settlement.py | 10 ++ ...est_issue6911_intentional_shrink_backup.py | 30 ++++ ...t_issue856_background_completion_unread.py | 4 +- tests/test_session_unread_dot_on_visit.py | 7 +- tests/test_unread_cross_client_sync.py | 125 ++++++++++++++-- ...ebui_run_state_consistency_contract_rfc.py | 9 ++ 14 files changed, 319 insertions(+), 58 deletions(-) diff --git a/api/models.py b/api/models.py index 775124dc..3de149ba 100644 --- a/api/models.py +++ b/api/models.py @@ -1247,6 +1247,8 @@ class Session: truncation_boundary=None, clear_generation=None, intentional_shrink_generation=None, + transcript_generation: int=0, + transcript_generation_baseline: int=0, gateway_routing=None, gateway_routing_history=None, llm_title_generated: bool=False, manual_title: bool=False, @@ -1344,6 +1346,10 @@ class Session: self.truncation_boundary = truncation_boundary self.clear_generation = clear_generation self.intentional_shrink_generation = intentional_shrink_generation + self.transcript_generation = max(0, _parse_nonnegative_int(transcript_generation) or 0) + self.transcript_generation_baseline = max( + 0, _parse_nonnegative_int(transcript_generation_baseline) or 0 + ) self.gateway_routing = gateway_routing if isinstance(gateway_routing, dict) else None self.gateway_routing_history = gateway_routing_history if isinstance(gateway_routing_history, list) else [] self.llm_title_generated = bool(llm_title_generated) @@ -1430,6 +1436,8 @@ class Session: 'truncation_boundary', 'clear_generation', 'intentional_shrink_generation', + 'transcript_generation', + 'transcript_generation_baseline', 'gateway_routing', 'gateway_routing_history', 'llm_title_generated', 'manual_title', 'parent_session_id', 'worktree_path', 'worktree_branch', 'worktree_repo_root', 'worktree_created_at', @@ -1768,6 +1776,8 @@ class Session: 'created_at': self.created_at, 'updated_at': self.updated_at, 'last_message_at': last_message_at, + 'transcript_generation': self.transcript_generation, + 'transcript_generation_baseline': self.transcript_generation_baseline, 'pinned': self.pinned, 'archived': self.archived, 'project_id': self.project_id, diff --git a/api/session_ops.py b/api/session_ops.py index c03fc09f..846075c6 100644 --- a/api/session_ops.py +++ b/api/session_ops.py @@ -608,6 +608,8 @@ def _stamp_intentional_shrink_generation(session, old_message_count: int, new_me if new_message_count >= old_message_count: return False session.intentional_shrink_generation = uuid.uuid4().hex + session.transcript_generation = max(0, int(getattr(session, 'transcript_generation', 0) or 0)) + 1 + session.transcript_generation_baseline = max(0, int(new_message_count)) return True diff --git a/api/webui_session_db.py b/api/webui_session_db.py index fa699a06..68ce2c1f 100644 --- a/api/webui_session_db.py +++ b/api/webui_session_db.py @@ -57,6 +57,8 @@ _METADATA_FIELDS = frozenset( "compression_recovery_action", "truncation_watermark", "truncation_boundary", + "transcript_generation", + "transcript_generation_baseline", "gateway_routing", "gateway_routing_history", "llm_title_generated", diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index d7da1ec3..aae3d695 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -165,13 +165,21 @@ writes. | Store | Key | Semantics | |---|---|---| -| Viewed counts | `hermes-session-viewed-counts` | `sid -> N`, meaning "seen at least up to N messages" | +| Viewed counts | `hermes-session-viewed-counts` | `sid -> {message_count, transcript_generation}`, meaning "seen up to N messages in this transcript generation" | | Completion markers | `hermes-session-completion-unread` | `sid -> {message_count, completed_at, ...}` behind the visible dot | -- **Viewed counts merge by maximum.** The fact is monotonic per session and - additive across clients, so a save reads the store, merges its cache in with a - max, and writes only when the store does not already hold the merged result. - A deletion records its own key under +- **Viewed counts are generation-scoped.** Session mutation routes increment the + persisted `transcript_generation` whenever edit, regenerate, retry, undo, clear, + or truncate reduces the visible transcript, and record the retained count as + `transcript_generation_baseline`. A newer generation replaces an older one even + when its count is lower; counts are monotonic only within one generation and + merge by maximum there. A client first observing a newer generation acknowledges + only that retained baseline, so messages added after the shrink remain unread + even when the shrink and later growth arrive in one coalesced sidebar refresh. + Legacy numeric records are generation zero and migrate to the structured + representation on their next save. This prevents an old pre-truncate high-water + mark from masking messages added after a transcript reset. A deletion records + its own key under `hermes-session-viewed-counts:deleted:v1:`; merges drop any count whose session has a live deletion record, so a client that still caches the acknowledgement prunes it instead of writing it back. List membership cannot @@ -205,7 +213,15 @@ writes. pruned by age (7 days) and supersession only — never by session existence, because the sidebar list is filtered by profile, project, and source, so an absent row may simply be hidden. They are never part of the marker map - consumers read. + consumers read. A clear order is retained in module memory even when storage + quota prevents allocating its versioned key; the client still attempts the + smaller write that removes the marker from the existing marker map, so a user + can dismiss unread state under storage pressure. Logical clear order and + retention time are separate: records compare markers using their order stamp, + but the 7-day cap uses the wall-clock time at which the record was written, so + a future logical stamp cannot extend retention indefinitely. The in-memory + fallback lasts until reload, while successfully persisted records retain the + same 7-day policy. - **Cross-client repair, not cache invalidation.** The `storage` listener routes a changed unread key (including any per-session clear key) back through the same merge instead of only dropping the local cache. A client that still holds an diff --git a/static/sessions.js b/static/sessions.js index 30005822..8efd006d 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -372,6 +372,7 @@ const SESSION_PROFILE_COUNTS_KEY = 'hermes-session-profile-counts'; let _sessionProfileCounts = null; let _sessionViewedCounts = null; let _sessionCompletionUnread = null; +let _sessionCompletionUnreadClearedMemory = {}; let _sessionObservedStreaming = null; const _sessionStreamingById = new Map(); const _sessionListSnapshotById = new Map(); @@ -528,14 +529,52 @@ function _sessionListLoaded() { && _sessionListSnapshotById.size > 0; } -// A viewed count is "seen at least up to N messages": monotonic per session and -// additive across clients, so merging is a max that never rolls a count back and -// never drops another client's entry. A value that exists only in our cache is -// written back only for a session the list still shows (see -// _sessionExistsForUnreadState) so a session deleted in another client, and -// already pruned from disk by _clearSessionViewedCount, is not resurrected. It -// is always kept in the live view regardless: dropping it here would lose the -// acknowledgement for a session that still exists. +// Viewed acknowledgements are generation-scoped. Transcript mutation routes +// increment `transcript_generation` whenever the visible message list shrinks; +// counts are monotonic only within one generation. Legacy numeric entries belong +// to generation zero and are migrated on their next save. +function _sessionViewedCountRecord(value, transcriptGeneration = 0) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return { + message_count: Number(value.message_count) || 0, + transcript_generation: Math.max(0, Number(value.transcript_generation) || 0), + }; + } + return { + message_count: Number(value) || 0, + transcript_generation: Math.max(0, Number(transcriptGeneration) || 0), + }; +} + +function _sessionViewedCountValue(value) { + return _sessionViewedCountRecord(value).message_count; +} + +function _sessionViewedRecordWins(candidate, current) { + const ours = _sessionViewedCountRecord(candidate); + const theirs = _sessionViewedCountRecord(current); + if (ours.transcript_generation !== theirs.transcript_generation) { + return ours.transcript_generation > theirs.transcript_generation; + } + return ours.message_count > theirs.message_count; +} + +function _sessionTranscriptGenerationForUnread(sid, explicit = null) { + if (explicit !== null && explicit !== undefined && Number.isFinite(Number(explicit))) { + return Math.max(0, Number(explicit) || 0); + } + let active = null; + try { + active = (S && S.session && S.session.session_id === sid) ? S.session : null; + } catch (_) {} + const snapshot = (typeof _sessionListSnapshotById !== 'undefined' && _sessionListSnapshotById) + ? _sessionListSnapshotById.get(sid) : null; + const listed = (typeof _allSessions !== 'undefined' && Array.isArray(_allSessions)) + ? _allSessions.find((s) => s && s.session_id === sid) : null; + const session = active || snapshot || listed; + return Math.max(0, Number(session && session.transcript_generation) || 0); +} + function _mergeSessionViewedCounts(disk, cache) { const deleted = _readSessionViewedCountDeletions(); const gone = (sid) => Object.prototype.hasOwnProperty.call(deleted, sid); @@ -546,19 +585,20 @@ function _mergeSessionViewedCounts(disk, cache) { changed = true; continue; } - counts[sid] = Number(disk[sid]) || 0; + const record = _sessionViewedCountRecord(disk[sid]); + counts[sid] = record; + if (!disk[sid] || typeof disk[sid] !== 'object' || Array.isArray(disk[sid])) changed = true; } for (const sid of Object.keys(cache || {})) { if (gone(sid)) continue; - const ours = Number(cache[sid]) || 0; + const ours = _sessionViewedCountRecord(cache[sid]); if (Object.prototype.hasOwnProperty.call(counts, sid)) { - if (ours > counts[sid]) { + if (_sessionViewedRecordWins(ours, counts[sid])) { counts[sid] = ours; changed = true; } continue; } - if (!ours) continue; if (_sessionListLoaded() && !_sessionExistsForUnreadState(sid)) continue; counts[sid] = ours; changed = true; @@ -567,8 +607,8 @@ function _mergeSessionViewedCounts(disk, cache) { for (const sid of Object.keys(counts)) live[sid] = counts[sid]; for (const sid of Object.keys(cache || {})) { if (gone(sid)) continue; - const ours = Number(cache[sid]) || 0; - if (ours > (Object.prototype.hasOwnProperty.call(live, sid) ? live[sid] : 0)) { + const ours = _sessionViewedCountRecord(cache[sid]); + if (!Object.prototype.hasOwnProperty.call(live, sid) || _sessionViewedRecordWins(ours, live[sid])) { live[sid] = ours; } } @@ -640,21 +680,26 @@ function _saveSessionViewedCounts() { } } -function _setSessionViewedCount(sid, messageCount = 0) { +function _setSessionViewedCount(sid, messageCount = 0, transcriptGeneration = null) { if (!sid) return; const counts = _getSessionViewedCounts(); const next = Number.isFinite(messageCount) ? Number(messageCount) : 0; + const generation = _sessionTranscriptGenerationForUnread(sid, transcriptGeneration); + const incoming = _sessionViewedCountRecord(next, generation); const hadCount = Object.prototype.hasOwnProperty.call(counts, sid); - const previous = Number(counts[sid]) || 0; - // A viewed count records "seen at least up to N messages" — keep it monotonic. - // A transient lower count (e.g. a list snapshot mid-compaction) must not - // resurrect a cleared unread dot before the save below. - counts[sid] = Math.max(previous, next); + const previous = hadCount ? _sessionViewedCountRecord(counts[sid]) : null; + if (!previous || _sessionViewedRecordWins(incoming, previous)) { + counts[sid] = incoming; + } else { + counts[sid] = previous; + } _saveSessionViewedCounts(); - // Advancing the acknowledgement records a clear even if another client has - // only prepared (not persisted) its older completion marker. Repeated polling - // at the same count does not refresh the tombstone or emit storage events. - _clearSessionCompletionUnread(sid, !hadCount || next > previous); + // A generation change or count advance acknowledges completion state even if + // another client prepared an older marker before this save. + const advanced = !previous + || generation > previous.transcript_generation + || (generation === previous.transcript_generation && next > previous.message_count); + _clearSessionCompletionUnread(sid, advanced); } function _getSessionCompletionUnread() { @@ -712,11 +757,22 @@ function _readSessionCompletionUnreadCleared() { keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i)); } catch (_) {} const cleared = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY); + const now = Date.now(); + for (const [sid, memoryRecord] of Object.entries(_sessionCompletionUnreadClearedMemory || {})) { + const value = Number(memoryRecord && memoryRecord.order) || 0; + const recordedAt = Number(memoryRecord && memoryRecord.recorded_at) || 0; + if (recordedAt && now - recordedAt > SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS) { + delete _sessionCompletionUnreadClearedMemory[sid]; + continue; + } + if (value > (Number(cleared[sid]) || 0)) cleared[sid] = value; + } try { for (const key of keys) { const parsed = _parseSessionCompletionUnreadClearedKey(key); if (!parsed) continue; - const value = Math.max(parsed.version, Number(localStorage.getItem(key)) || 0); + const storedValue = Number(localStorage.getItem(key)) || 0; + const value = parsed.version || storedValue; if (value > (Number(cleared[parsed.sid]) || 0)) cleared[parsed.sid] = value; } } catch (_) { @@ -841,6 +897,12 @@ function _writeSessionCompletionUnreadCleared(sidOrSids, clearedAt) { (Array.isArray(sidOrSids) ? sidOrSids : [sidOrSids]).map((sid) => String(sid)) ); const at = Number(clearedAt) || 0; + for (const sid of justCleared) { + const previous = _sessionCompletionUnreadClearedMemory[sid]; + if (at > (Number(previous && previous.order) || 0)) { + _sessionCompletionUnreadClearedMemory[sid] = {order: at, recorded_at: Date.now()}; + } + } // One-way migration. The previous representation kept every clear fact in one // shared blob, so concurrent clears could replace one another and the blob @@ -855,14 +917,14 @@ function _writeSessionCompletionUnreadCleared(sidOrSids, clearedAt) { const stamp = Number(value) || 0; if (!stamp) continue; try { - localStorage.setItem(_sessionCompletionUnreadClearedKey(sid, stamp), String(stamp)); + localStorage.setItem(_sessionCompletionUnreadClearedKey(sid, stamp), String(Date.now())); } catch (_) { migrated = false; } } for (const sid of justCleared) { try { - localStorage.setItem(_sessionCompletionUnreadClearedKey(sid, at), String(at)); + localStorage.setItem(_sessionCompletionUnreadClearedKey(sid, at), String(Date.now())); } catch (_) {} } if (Object.keys(legacy).length && migrated) { @@ -878,8 +940,9 @@ function _writeSessionCompletionUnreadCleared(sidOrSids, clearedAt) { const parsed = _parseSessionCompletionUnreadClearedKey(key); if (!parsed) continue; try { - const version = Math.max(parsed.version, Number(localStorage.getItem(key)) || 0); - const expired = at - version > SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS; + const recordedAt = Number(localStorage.getItem(key)) || 0; + const version = parsed.version || recordedAt; + const expired = recordedAt > 0 && Date.now() - recordedAt > SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS; const superseded = version < (Number(cleared[parsed.sid]) || 0); // Records are pruned by age and supersession only. Session existence is // not a pruning signal: the sidebar list is filtered by profile, project, @@ -1123,12 +1186,22 @@ function _hasUnreadForSession(s) { if (!s || !s.session_id) return false; if (_hasSessionCompletionUnread(s.session_id)) return true; const counts = _getSessionViewedCounts(); + const generation = _sessionTranscriptGenerationForUnread(s.session_id, s.transcript_generation); if (!Object.prototype.hasOwnProperty.call(counts, s.session_id)) { - _setSessionViewedCount(s.session_id, Number(s.message_count || 0)); + _setSessionViewedCount(s.session_id, Number(s.message_count || 0), generation); return false; } - if (!Number.isFinite(s.message_count)) return false; - return s.message_count > Number(counts[s.session_id] || 0); + const viewed = _sessionViewedCountRecord(counts[s.session_id]); + if (generation > viewed.transcript_generation) { + const baseline = Math.max(0, Math.min( + Number(s.message_count || 0), + Number(s.transcript_generation_baseline) || 0 + )); + _setSessionViewedCount(s.session_id, baseline, generation); + return Number.isFinite(s.message_count) && s.message_count > baseline; + } + if (generation < viewed.transcript_generation || !Number.isFinite(s.message_count)) return false; + return s.message_count > viewed.message_count; } // Keep the sidebar polling snapshot current for a just-visited session so a diff --git a/tests/_unread_store_helpers.py b/tests/_unread_store_helpers.py index 93ba4ed6..0d187e64 100644 --- a/tests/_unread_store_helpers.py +++ b/tests/_unread_store_helpers.py @@ -19,6 +19,10 @@ _HELPER_NAMES = ( "_readStoredJsonMap", "_sessionExistsForUnreadState", "_sessionListLoaded", + "_sessionViewedCountRecord", + "_sessionViewedCountValue", + "_sessionViewedRecordWins", + "_sessionTranscriptGenerationForUnread", "_mergeSessionViewedCounts", "_sessionViewedCountDeletedKey", "_readSessionViewedCountDeletions", @@ -83,6 +87,7 @@ def _const_source(name: str) -> str: # Ready to interpolate into a node harness. BLOCK = "\n".join( - [_const_source(name) for name in _CONST_NAMES] + ["var _sessionCompletionUnreadClearedMemory = {};\n"] + + [_const_source(name) for name in _CONST_NAMES] + [_function_source(name) for name in _HELPER_NAMES if f"function {name}(" in SESSIONS_JS] ) diff --git a/tests/test_465_session_branching.py b/tests/test_465_session_branching.py index e792c0c4..2d8f1e8a 100644 --- a/tests/test_465_session_branching.py +++ b/tests/test_465_session_branching.py @@ -610,7 +610,7 @@ def test_session_compact_includes_parent(): # method name and first parameter without pinning formatting. compact_def_match = re.search(r"def compact\(\s*self", src) assert compact_def_match, "Could not find compact() method" - snippet = src[compact_def_match.start():compact_def_match.start() + 3000] + snippet = src[compact_def_match.start():compact_def_match.start() + 3500] assert "'parent_session_id'" in snippet, \ "compact() should include parent_session_id" diff --git a/tests/test_issue5273_active_done_viewed_sync.py b/tests/test_issue5273_active_done_viewed_sync.py index da5d8594..26f52552 100644 --- a/tests/test_issue5273_active_done_viewed_sync.py +++ b/tests/test_issue5273_active_done_viewed_sync.py @@ -161,7 +161,7 @@ def _run_done_compaction_harness( _markSessionCompletedInList(completedSession, activeSid); const cacheRow=_allSessions.find(s=>s&&s.session_id===completedSid); const unreadAfterCacheUpdate=_hasUnreadForSession(cacheRow); - const viewedCountAfterCacheUpdate=_getSessionViewedCounts()[completedSid] ?? null; + const viewedCountAfterCacheUpdate=_sessionViewedCountValue(_getSessionViewedCounts()[completedSid]); if ({str(is_session_viewed).lower()}) {{ _markSessionViewed(completedSid, completedMessageCount); }} @@ -172,7 +172,7 @@ def _run_done_compaction_harness( unreadAfterActiveBranchSync:_hasUnreadForSession(cacheRow), hasCompletionUnread:_hasSessionCompletionUnread(completedSid), completionUnreadMessageCount:_getSessionCompletionUnread()[completedSid]?.message_count ?? null, - viewedCount:_getSessionViewedCounts()[completedSid] ?? null, + viewedCount:_sessionViewedCountValue(_getSessionViewedCounts()[completedSid]), cacheRow, }})); """ diff --git a/tests/test_issue6611_regeneration_settlement.py b/tests/test_issue6611_regeneration_settlement.py index 77bb76fd..7ba52ec0 100644 --- a/tests/test_issue6611_regeneration_settlement.py +++ b/tests/test_issue6611_regeneration_settlement.py @@ -30,6 +30,16 @@ def test_recovered_prefix_survives_regeneration_truncation(): assert [row["content"] for row in session.messages] == ["old", "old answer", "latest"] +def test_regeneration_advances_transcript_generation_when_it_shrinks(): + session = _session() + session.transcript_generation = 7 + plan = plan_regeneration(session) + + assert apply_regeneration_plan(session, plan) + + assert session.transcript_generation == 8 + + def test_stale_revision_rejects_without_mutation(): session = _session() before = copy.deepcopy(session.__dict__) diff --git a/tests/test_issue6911_intentional_shrink_backup.py b/tests/test_issue6911_intentional_shrink_backup.py index 1cd8d1db..fc437a99 100644 --- a/tests/test_issue6911_intentional_shrink_backup.py +++ b/tests/test_issue6911_intentional_shrink_backup.py @@ -162,6 +162,36 @@ def test_generation_save_load_round_trip_is_in_metadata_prefix(monkeypatch, tmp_ assert metadata.intentional_shrink_generation == generation +def test_transcript_generation_round_trips_and_advances_only_on_shrink(monkeypatch, tmp_path): + models, _session_dir = _seed_session_dir(monkeypatch, tmp_path) + from api.session_ops import truncate_session_at_keep + from api.webui_session_db import WebUIJsonSessionDB + + session = models.Session( + session_id="issue6911_transcript_generation", + messages=_history(), + transcript_generation=4, + ) + session.save() + + truncate_session_at_keep(session, len(session.messages)) + assert session.transcript_generation == 4 + truncate_session_at_keep(session, 2) + assert session.transcript_generation == 5 + assert session.transcript_generation_baseline == 2 + session.save() + + loaded = models.Session.load(session.session_id) + metadata = models.Session.load_metadata_only(session.session_id) + listed = WebUIJsonSessionDB().list_sessions() + assert loaded.transcript_generation == 5 + assert loaded.transcript_generation_baseline == 2 + assert metadata.transcript_generation == 5 + assert metadata.transcript_generation_baseline == 2 + assert listed[0]["transcript_generation"] == 5 + assert listed[0]["transcript_generation_baseline"] == 2 + + def test_no_actual_message_shrink_keeps_generation(monkeypatch, tmp_path): models, _session_dir = _seed_session_dir(monkeypatch, tmp_path) from api.session_ops import truncate_session_at_keep diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index 0368a108..f11349ba 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -52,7 +52,7 @@ def test_background_completion_unread_uses_explicit_marker_not_message_delta(): has_unread_block = SESSIONS_JS[has_unread_idx:SESSIONS_JS.find("async function newSession", has_unread_idx)] marker_idx = has_unread_block.find("_hasSessionCompletionUnread(s.session_id)") - count_idx = has_unread_block.find("s.message_count > Number") + count_idx = has_unread_block.find("s.message_count > viewed.message_count") assert marker_idx != -1, "_hasUnreadForSession must check explicit completion unread marker" assert count_idx != -1, "_hasUnreadForSession must keep the existing message_count fallback" assert marker_idx < count_idx, ( @@ -561,6 +561,6 @@ def test_historical_sessions_are_not_marked_unread_on_list_render(): assert "_markSessionCompletionUnread" not in has_unread_block, ( "rendering old historical sessions must not create completion-unread markers" ) - assert "_setSessionViewedCount(s.session_id, Number(s.message_count || 0));" in has_unread_block, ( + assert "_setSessionViewedCount(s.session_id, Number(s.message_count || 0), generation);" in has_unread_block, ( "missing viewed-count baseline should still initialize as read for historical sessions" ) diff --git a/tests/test_session_unread_dot_on_visit.py b/tests/test_session_unread_dot_on_visit.py index 789062f9..b897f106 100644 --- a/tests/test_session_unread_dot_on_visit.py +++ b/tests/test_session_unread_dot_on_visit.py @@ -212,7 +212,7 @@ const before = Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread( _acknowledgeSessionVisit('open', 5, 10); const after = Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(), 'open'); const snap = _sessionListSnapshotById.get('open'); -console.log(JSON.stringify({{before, after, repaints, viewed: _getSessionViewedCounts()['open'], snap}})); +console.log(JSON.stringify({{before, after, repaints, viewed: _sessionViewedCountValue(_getSessionViewedCounts()['open']), snap}})); """ out = _run_node(script) assert out["before"] is True, "precondition: marker seeded" @@ -419,12 +419,13 @@ function _hasMarker() {{ _releaseApi({{ session: {{ session_id: 'open', message_count: 6, messages: [{{ role: 'assistant', content: 'x' }}] }} }}); await p; const markerAfter = _hasMarker(); - const viewed = _getSessionViewedCounts()['open']; + const viewedRecord = _getSessionViewedCounts()['open']; + const viewed = viewedRecord === undefined ? null : _sessionViewedCountValue(viewedRecord); console.log(JSON.stringify({{ apiIssued, markerBefore, markerAfter, - viewed: viewed === undefined ? null : viewed, + viewed, }})); }})(); """ diff --git a/tests/test_unread_cross_client_sync.py b/tests/test_unread_cross_client_sync.py index 1e40706d..ab7b38c4 100644 --- a/tests/test_unread_cross_client_sync.py +++ b/tests/test_unread_cross_client_sync.py @@ -104,6 +104,7 @@ _CLIENT_FNS = [ "_markSessionCompletionUnread", "_clearSessionCompletionUnread", "_hasSessionCompletionUnread", + "_hasUnreadForSession", ] # Helpers that carry the merge/tombstone rules; injected when present. @@ -111,6 +112,10 @@ _HELPER_FNS = [ "_readStoredJsonMap", "_sessionExistsForUnreadState", "_sessionListLoaded", + "_sessionViewedCountRecord", + "_sessionViewedCountValue", + "_sessionViewedRecordWins", + "_sessionTranscriptGenerationForUnread", "_mergeSessionViewedCounts", "_sessionViewedCountDeletedKey", "_readSessionViewedCountDeletions", @@ -161,6 +166,12 @@ let _allSessions = []; const _sessionListSnapshotById = new Map(); const listed = (...ids) => {{ _allSessions = ids.map((sid) => ({{session_id: sid}})); }}; const readDisk = (key) => JSON.parse(_store[key] || '{{}}'); +const readViewedDisk = () => Object.fromEntries( + Object.entries(readDisk(SESSION_VIEWED_COUNTS_KEY)).map(([sid, value]) => [ + sid, + value && typeof value === 'object' ? Number(value.message_count) || 0 : Number(value) || 0, + ]) +); const setDisk = (key, value) => {{ _store[key] = JSON.stringify(value); }}; // Aggregate either the legacy whole map or independent per-session records. const readAllClears = (client) => client && typeof client.clears === 'function' @@ -189,6 +200,7 @@ def _client_factory(with_listener: bool = False) -> str: function makeClient() {{ let _sessionViewedCounts = null; let _sessionCompletionUnread = null; + let _sessionCompletionUnreadClearedMemory = {{}}; {fns} {helpers} {handler} @@ -196,7 +208,7 @@ function makeClient() {{ return {{ viewed: (sid) => {{ const counts = _getSessionViewedCounts(); - return Object.prototype.hasOwnProperty.call(counts, sid) ? counts[sid] : null; + return Object.prototype.hasOwnProperty.call(counts, sid) ? _sessionViewedCountValue(counts[sid]) : null; }}, setViewed: _setSessionViewedCount, clearViewed: _clearSessionViewedCount, @@ -204,6 +216,12 @@ function makeClient() {{ clearUnread: _clearSessionCompletionUnread, writeClear: _writeSessionCompletionUnreadCleared, hasUnread: _hasSessionCompletionUnread, + hasUnreadFor: (sid, messageCount, transcriptGeneration = 0, transcriptGenerationBaseline = 0) => _hasUnreadForSession({{ + session_id: sid, + message_count: messageCount, + transcript_generation: transcriptGeneration, + transcript_generation_baseline: transcriptGenerationBaseline, + }}), clears: (typeof _readSessionCompletionUnreadCleared === 'function') ? _readSessionCompletionUnreadCleared : () => readDisk(SESSION_COMPLETION_UNREAD_CLEARED_KEY), @@ -222,7 +240,92 @@ def _script(body: str, with_listener: bool = False) -> str: return _HEADER + _client_factory(with_listener) + body -# ── Viewed counts: monotonic, repaired, never resurrected ──────────────────── +# ── Viewed counts: generation-scoped, repaired, never resurrected ───────────── + +def test_shrink_then_growth_is_unread_in_the_new_transcript_generation(): + """A pre-shrink high-water mark must not mask new messages after truncation.""" + out = _run_node(_script(""" +listed('X'); +const A = makeClient(); +A.setViewed('X', 10, 0); +A.setViewed('X', 2, 1); +console.log(JSON.stringify({ + afterShrink: A.viewed('X'), + unreadAtThree: A.hasUnreadFor('X', 3, 1), +})); +""")) + assert out["afterShrink"] == 2 + assert out["unreadAtThree"] is True + + +def test_first_observation_of_new_generation_keeps_post_shrink_growth_unread(): + """A coalesced shrink-plus-growth refresh must acknowledge only the shrink baseline.""" + out = _run_node(_script(""" +listed('X'); +const A = makeClient(); +A.setViewed('X', 10, 0); +console.log(JSON.stringify({ + unread: A.hasUnreadFor('X', 3, 1, 2), + viewed: A.viewed('X'), +})); +""")) + assert out == {"unread": True, "viewed": 2} + + +def test_zero_message_baseline_survives_persistence_and_marks_first_message_unread(): + """Presence of a zero baseline is meaningful; it is not a missing record.""" + out = _run_node(_script(""" +listed('X'); +const A = makeClient(); +A.setViewed('X', 0, 0); +console.log(JSON.stringify({ + diskHasX: Object.prototype.hasOwnProperty.call(readViewedDisk(), 'X'), + unreadAtOne: A.hasUnreadFor('X', 1, 0), +})); +""")) + assert out["diskHasX"] is True + assert out["unreadAtOne"] is True + + +def test_in_memory_clear_expiry_uses_recorded_time_not_future_logical_order(): + """A future logical stamp must not extend the seven-day memory lifetime.""" + out = _run_node(_script(""" +const A = makeClient(); +_now = 1000; +A.writeClear('X', 999999999999); +const initially = A.clears(); +_now += 8 * 24 * 60 * 60 * 1000; +A.writeClear('Y', 5); +const expired = A.clears(); +console.log(JSON.stringify({initially, expired})); +""")) + assert out["initially"]["X"] == 999999999999 + assert "X" not in out["expired"] + + +def test_unread_clear_survives_quota_rejecting_a_new_tombstone_key(): + """Clearing must still shrink the existing marker map when allocation fails.""" + out = _run_node(_script(""" +listed('X'); +setDisk(SESSION_COMPLETION_UNREAD_KEY, { + X: {message_count: 3, completed_at: 1000, unread_order: 1}, +}); +const A = makeClient(); +const before = A.hasUnread('X'); +const ordinarySetItem = localStorage.setItem; +localStorage.setItem = (key, value) => { + if (!Object.prototype.hasOwnProperty.call(_store, key)) throw new Error('QuotaExceededError'); + ordinarySetItem(key, value); +}; +A.clearUnread('X'); +console.log(JSON.stringify({ + before, + after: A.hasUnread('X'), + diskHasX: Object.prototype.hasOwnProperty.call(readDisk(SESSION_COMPLETION_UNREAD_KEY), 'X'), +})); +""")) + assert out == {"before": True, "after": False, "diskHasX": False} + def test_stale_client_cannot_roll_a_viewed_count_back(): """Sequential rollback: a client that saw an older list snapshot must not @@ -234,7 +337,7 @@ const B = makeClient(); A.setViewed('X', 417); B.setViewed('X', 131); console.log(JSON.stringify({ - disk: readDisk(SESSION_VIEWED_COUNTS_KEY), + disk: readViewedDisk(), bView: B.viewed('X'), })); """)) @@ -252,7 +355,7 @@ const A = makeClient(); A.setViewed('D', 5); A.clearViewed('D'); console.log(JSON.stringify({ - diskHasD: Object.prototype.hasOwnProperty.call(readDisk(SESSION_VIEWED_COUNTS_KEY), 'D'), + diskHasD: Object.prototype.hasOwnProperty.call(readViewedDisk(), 'D'), })); """)) assert out["diskHasD"] is False, "clearing a viewed count must remove it from disk" @@ -275,7 +378,7 @@ const bCached = B.viewed('E'); B.clearViewed('E'); console.log(JSON.stringify({ bCached: bCached, - disk: readDisk(SESSION_VIEWED_COUNTS_KEY), + disk: readViewedDisk(), })); """)) assert out["bCached"] is None, "precondition: B's cache must not hold E" @@ -302,7 +405,7 @@ const held = A.viewed('X'); A.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY }); console.log(JSON.stringify({ held: held, - disk: readDisk(SESSION_VIEWED_COUNTS_KEY), + disk: readViewedDisk(), })); """)) assert out["held"] == 417, ( @@ -329,10 +432,10 @@ B.viewed('Y'); // so A's write (built from the store it read) drops B's entry. _onRead = () => { B.setViewed('Y', 999); }; A.setViewed('X', 417); -const clobbered = readDisk(SESSION_VIEWED_COUNTS_KEY); +const clobbered = readViewedDisk(); // B still holds its own acknowledgement, and now processes A's storage event. B.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY }); -const disk = readDisk(SESSION_VIEWED_COUNTS_KEY); +const disk = readViewedDisk(); const writesAfterRepair = storageWrites; B.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY }); console.log(JSON.stringify({ @@ -372,7 +475,7 @@ listed('T'); B.setViewed('T', 1); B.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY }); console.log(JSON.stringify({ - disk: readDisk(SESSION_VIEWED_COUNTS_KEY), + disk: readViewedDisk(), tView: B.viewed('T'), })); """)) @@ -399,7 +502,7 @@ A.clearViewed('S'); B.handleStorage({ key: SESSION_VIEWED_COUNTS_KEY }); B.setViewed('T', 1); console.log(JSON.stringify({ - disk: readDisk(SESSION_VIEWED_COUNTS_KEY), + disk: readViewedDisk(), deletions: readViewedDeletions().length, bView: B.viewed('S'), })); @@ -777,7 +880,7 @@ const disk = readDisk(SESSION_COMPLETION_UNREAD_KEY); console.log(JSON.stringify({ handlers: _storageHandlers.length, xView: A.viewed('X'), - diskX: readDisk(SESSION_VIEWED_COUNTS_KEY).X, + diskX: readViewedDisk().X, stable: repairStable, qStale: qStale, qView: A.hasUnread('Q'), diff --git a/tests/test_webui_run_state_consistency_contract_rfc.py b/tests/test_webui_run_state_consistency_contract_rfc.py index b8c38a10..dedee864 100644 --- a/tests/test_webui_run_state_consistency_contract_rfc.py +++ b/tests/test_webui_run_state_consistency_contract_rfc.py @@ -46,6 +46,15 @@ def test_contract_documents_the_client_side_unread_store_keys(): ) +def test_contract_documents_generation_scoped_viewed_counts(): + text = RFC.read_text(encoding="utf-8") + source = _sessions_js() + + assert "transcript_generation" in source + assert "transcript_generation" in text + assert "monotonic only within" in text + + def test_documented_retention_window_matches_the_implemented_cap(): source = _sessions_js() match = re.search( From fb2e8b0088a404d201c8fba8be7d8e98749569d2 Mon Sep 17 00:00:00 2001 From: b3nw Date: Tue, 15 Sep 2026 20:05:40 -0700 Subject: [PATCH 060/446] fix(custom-providers): route @custom: models to their own base_url (#7319) Route @custom: models to their owning custom provider's base_url so a model picked from a second custom provider group resolves correctly instead of 400ing. Co-authored-by: b3nw --- api/config.py | 1571 ++++- api/routes.py | 410 +- api/streaming.py | 494 +- tests/test_agent_max_turns_parity.py | 166 +- tests/test_issue1013_handoff_dock.py | 19 + ...ue1806_named_custom_provider_resolution.py | 5121 +++++++++++++++++ ...st_issue1897_profile_switch_agent_cache.py | 188 +- tests/test_sprint42.py | 13 +- tests/test_streaming_max_tokens_quota.py | 178 +- 9 files changed, 7863 insertions(+), 297 deletions(-) diff --git a/api/config.py b/api/config.py index 24530ef7..0eb26136 100644 --- a/api/config.py +++ b/api/config.py @@ -3061,7 +3061,39 @@ def resolve_model_provider(model_id: str, *, explicitly_picked: bool = False) -> and provider_hint.lower() in _custom_endpoint_slugs_for_base_url(config_base_url) ): return _finalize(bare_model, config_provider, config_base_url) - return _finalize(bare_model, provider_hint, _get_provider_base_url(provider_hint)) + # _get_provider_base_url() only reads `providers:` and the ACTIVE + # `model.base_url`, so a named custom provider registered solely in + # `custom_providers:` resolved to None there. Prefer that entry's own + # endpoint so a non-active @custom: hint routes to its own URL + # instead of falling back to the default endpoint (HTTP 400 "Invalid + # model format or no credentials for provider: "). + # + # Match via _unique_custom_provider_entry on the module-level `cfg` list: + # it is pure and lock-safe, so it stays callable when resolve_model_provider() + # is invoked while the caller already holds the non-reentrant _cfg_lock — + # a get_config()-based resolver would self-deadlock there. It also does NOT + # guess: an unmatched slug (e.g. a host:port-derived one absent from + # custom_providers) keeps the prior None so no stale endpoint is persisted + # for it (#4728). A colliding slug still fails closed via the raise. + # + # When an exact custom_providers[] entry exists, that row is authoritative + # for its slug: use its stripped base_url directly (including None when + # blank). It must NOT fall through to _get_provider_base_url(), which + # could pair a same-slug keyed `providers:` endpoint with this entry's + # credentials (violating same-entry endpoint/key parity). Only fall back + # to _get_provider_base_url() when no exact custom_providers[] row exists. + if provider_hint.startswith("custom:"): + entry = _unique_custom_provider_entry( + cfg.get('custom_providers', []), + _custom_provider_slug_key(provider_hint), + ) + if entry is not None: + base_url = str(entry.get('base_url') or '').strip() or None + else: + base_url = _get_provider_base_url(provider_hint) + else: + base_url = _get_provider_base_url(provider_hint) + return _finalize(bare_model, provider_hint, base_url) if "/" in model_id: prefix, bare = model_id.split("/", 1) @@ -3251,41 +3283,482 @@ def resolve_model_provider(model_id: str, *, explicitly_picked: bool = False) -> return _finalize(model_id, config_provider, config_base_url) -def resolve_custom_provider_connection(provider_id: str) -> tuple[str | None, str | None]: - """Return (api_key, base_url) for a named ``custom:*`` provider. +def _custom_record_base_url(record: object) -> str | None: + """Return a custom record's OWN endpoint, or None when it declares none.""" + if not isinstance(record, dict): + return None + return str(record.get("base_url") or "").strip() or None - Supports ``custom_providers[].api_key`` as either a literal key or - ``${ENV_VAR}``, and ``custom_providers[].key_env`` as an env-var hint. - Returns ``(None, None)`` when no named custom provider matches. + +def _resolve_custom_record_key( + raw_api_key: object, + raw_key_env: object, + provider_hint: object = None, +) -> str | None: + """Static credential declared by ONE custom record. + + Accepts a literal key, a ``${ENV_VAR}`` reference or a ``key_env`` env-var + hint, then falls back to the ``CUSTOM__API_KEY`` convention. Reading + every form from the SAME record is what keeps an endpoint and a credential + from being resolved out of two different authorities. """ - pid = str(provider_id or "").strip().lower() - if not pid.startswith("custom:"): - return None, None + api_key = None + if raw_api_key is not None: + key_text = str(raw_api_key).strip() + if key_text.startswith("${") and key_text.endswith("}") and len(key_text) > 3: + api_key = _thread_local_env_value(key_text[2:-1]).strip() or None + elif key_text: + api_key = key_text + if not api_key: + key_env = str(raw_key_env or "").strip() + if key_env: + api_key = _thread_local_env_value(key_env).strip() or None + if not api_key and provider_hint: + api_key = _lookup_custom_api_key_env(provider_hint) + return api_key - slug = _custom_provider_slug_key(pid) - if not slug: - return None, None - # Read the live config snapshot to avoid stale module-level cache edge - # cases after profile switches or runtime config edits. - cfg_data = get_config() +# Explicit outcomes of a named ``custom:`` selection. +# +# The status travels with the resolved bundle so a caller can tell "a record +# OWNS this route" from "nothing owns this route" instead of inferring it from +# an empty URL/key pair. That difference is load-bearing: an unowned named route +# must fail closed — no ambient endpoint, no ambient credential, no ambient +# pool/transport, no rewrite to the generic ``custom`` provider and no keyless +# placeholder — rather than borrow whatever the ambient runtime resolved. +CUSTOM_SELECTION_EXACT = "exact" # exact custom_providers[] row +CUSTOM_SELECTION_KEYED = "keyed" # identity-owned providers:/model: record +CUSTOM_SELECTION_RESOLVER = "resolver" # injected connection_resolver reported a pair +CUSTOM_SELECTION_MISSING = "missing" # no authority owns this slug +CUSTOM_SELECTION_MALFORMED = "malformed" # ``custom:`` with no slug behind it +# ``ambiguous`` is never RETURNED: a slug collision raises +# AmbiguousCustomProviderError out of _unique_custom_provider_entry, so the +# actionable rename message reaches the user (routes turn it into a 400) instead +# of being silently degraded into a keyless or ambient-authority send. +CUSTOM_SELECTION_AMBIGUOUS = "ambiguous" +# Statuses for which NO authority owns the route. +CUSTOM_SELECTION_UNOWNED = (CUSTOM_SELECTION_MISSING, CUSTOM_SELECTION_MALFORMED) - def _resolve_key(raw_api_key, raw_key_env, provider_hint=None) -> str | None: - api_key = None - if raw_api_key is not None: - key_text = str(raw_api_key).strip() - if key_text.startswith("${") and key_text.endswith("}") and len(key_text) > 3: - api_key = _thread_local_env_value(key_text[2:-1]).strip() or None - elif key_text: - api_key = key_text - if not api_key: - key_env = str(raw_key_env or "").strip() - if key_env: - api_key = _thread_local_env_value(key_env).strip() or None - if not api_key and provider_hint: - api_key = _lookup_custom_api_key_env(provider_hint) - return api_key +# ── Terminal routing verdicts ──────────────────────────────────────────────── +# +# Hermes Agent does NOT read an incomplete connection pair as a refusal. Its +# ``agent/agent_init.py:_init_openai_client()`` honours the explicit endpoint and +# credential the constructor was handed only when BOTH are truthy: +# +# if api_key and base_url: +# client_kwargs = _explicit_client_kwargs(...) +# else: +# client_kwargs = _routed_client_kwargs(...) +# +# and ``_routed_client_kwargs()`` resolves a provider AGAIN — through the +# centralized router, then the init-time fallback chain. So a bundle that "fails +# closed" by clearing its endpoint and/or its credential is not terminal at the +# constructor boundary; clearing those fields is precisely the signal to route +# somewhere else, which is how an unroutable custom slug ends up talking to the +# ambient or fallback provider after all. +# +# A route that cannot be resolved therefore has to be represented as an explicit +# verdict rather than as an ordinary constructor-ready dict with a hole in it. +# :data:`CUSTOM_ROUTE_ERROR_FIELD` carries that verdict on every merged bundle +# (``None`` when the route is fine), and every consumer that builds an AIAgent — +# or writes the agent cache, or hands the bundle to an auxiliary client — must +# stop on it and emit a controlled failure instead. +CUSTOM_ROUTE_UNOWNED = "unowned_custom_provider" +CUSTOM_ROUTE_NO_CREDENTIAL = "custom_provider_credential_unresolved" +CUSTOM_ROUTE_NO_ENDPOINT = "custom_provider_endpoint_unresolved" + +# Key the verdict travels under on a merged bundle. ``None`` == routable. +CUSTOM_ROUTE_ERROR_FIELD = "route_error" + + +class CustomProviderRouteError(ValueError): + """Raised when a named ``custom:`` route must not reach a provider client. + + The route resolved to no usable ``(api_key, base_url)`` pair, and passing + that pair to AIAgent would re-enter provider routing rather than fail (see + the :data:`CUSTOM_ROUTE_ERROR_FIELD` note above). Raising is how the refusal + stays terminal across the constructor boundary. + + Subclasses ``ValueError`` for the same reason + :class:`AmbiguousCustomProviderError` does: existing ``except ValueError`` / + broad-``except`` handlers in the non-streaming routes already turn it into a + controlled 400 / deterministic fallback instead of a traceback. + """ + + def __init__(self, message: str, *, reason: str, provider: str | None = None, hint: str = ""): + super().__init__(message) + # Forwarded verbatim by HTTP handlers, exactly like the ambiguous-slug + # error's ``.message``. + self.message = message + self.reason = reason + self.provider = provider + self.hint = hint + + +def _custom_route_verdict(reason: str, provider: str | None) -> dict: + """Build the terminal verdict recorded on an unroutable bundle.""" + name = str(provider or "").strip() or "custom" + if reason == CUSTOM_ROUTE_UNOWNED: + message = ( + f"Custom provider '{name}' is not configured: no custom_providers[] row, " + "keyed providers[] record or model: authority owns that name." + ) + hint = ( + "Add the provider under Settings -> Providers (or config.yaml " + "custom_providers[]), or pick a configured provider, then send again." + ) + elif reason == CUSTOM_ROUTE_NO_CREDENTIAL: + message = ( + f"Custom provider '{name}' declares a credential source that produced no " + "API key, so the route has no usable connection." + ) + hint = ( + "Check the provider's api_key / key_env / key_cmd / credential_pool " + "setting and the environment variable it names, then send again." + ) + else: + message = ( + f"Custom provider '{name}' resolved no endpoint, so the route has no " + "usable connection." + ) + hint = ( + "Set a base_url for the provider under Settings -> Providers (or " + "config.yaml custom_providers[]), then send again." + ) + return {"reason": reason, "provider": name, "message": message, "hint": hint} + + +def custom_provider_route_error(bundle: object) -> dict | None: + """Return ``bundle``'s terminal verdict, or ``None`` when it is routable. + + The verdict dict carries ``reason`` (one of :data:`CUSTOM_ROUTE_UNOWNED`, + :data:`CUSTOM_ROUTE_NO_CREDENTIAL`, :data:`CUSTOM_ROUTE_NO_ENDPOINT`), + ``provider``, a user-facing ``message`` and a ``hint``. + """ + if not isinstance(bundle, dict): + return None + verdict = bundle.get(CUSTOM_ROUTE_ERROR_FIELD) + return verdict if isinstance(verdict, dict) else None + + +def raise_for_custom_provider_route(bundle: dict) -> dict: + """Return ``bundle`` when routable; raise :class:`CustomProviderRouteError` otherwise. + + The single chokepoint every AIAgent-constructing consumer goes through, so + "this route is unresolvable" cannot degrade into "resolve it some other way" + at the constructor. + """ + verdict = custom_provider_route_error(bundle) + if verdict is None: + return bundle + raise CustomProviderRouteError( + verdict["message"], + reason=verdict["reason"], + provider=verdict["provider"], + hint=verdict["hint"], + ) + + +# Identity fields a record can use to NAME the custom provider it belongs to. +# ``name`` is the friendly name a ``custom_providers[]`` entry carries, so a +# ``providers:`` record spelled the same way is claiming the same identity. A +# ``model:`` block's ``name`` is the MODEL's name and never claims a provider, +# which is why that call site passes ``allow_name=False``. +_CUSTOM_RECORD_IDENTITY_FIELDS = ("provider_key", "provider", "custom_provider", "name") + + +def _custom_record_claims_slug(record: object, slug: str, *, allow_name: bool = True) -> bool: + """True when ``record`` names ``slug`` as its OWN identity. + + A generic record — ``providers['custom']`` or a ``model:`` block whose + provider is the bare string ``custom`` — belongs to no slug by itself, so it + is an authority for ``custom:`` only when it names that slug. Without + this test any unknown route adopts whichever generic record happens to be + configured, which is a credential handed to an endpoint the user never named. + """ + if not isinstance(record, dict) or not slug: + return False + for field in _CUSTOM_RECORD_IDENTITY_FIELDS: + if field == "name" and not allow_name: + continue + value = record.get(field) + if not isinstance(value, str) or not value.strip(): + continue + if _custom_provider_slug_key(value) == slug: + return True + return False + + +def _custom_record_owns_connection(record: dict, pid: str) -> bool: + """True when ``record`` declares ANY field the connection bundle carries. + + The bundle is more than a URL/key pair: ``key_cmd`` mints a per-request + bearer, ``credential_pool`` supplies a rotating one, and ``api_mode`` and the + ACP command/args decide the wire protocol and the transport process. A record + keyed to this slug that declares ONLY those still owns the route — reporting + it ``missing`` would fail the route closed and then clear the very fields it + declares (see :func:`merge_custom_provider_runtime_bundle`). + """ + base_url = _custom_record_base_url(record) + if base_url: + return True + # DECLARATION, not resolution: a ``key_env`` naming an unset variable or a + # pool that is momentarily empty still names this slug's credential source. + # Judging by "did a static key resolve?" hands the route to the ambient + # authority instead of surfacing the misconfiguration. + if _custom_record_declares_credential(record, base_url, None): + return True + if _resolve_custom_record_key(record.get("api_key"), record.get("key_env"), pid): + return True + if _custom_record_api_mode(record): + return True + if _custom_record_acp_transport(record): + return True + return False + + +# ── raw ``providers:`` records (the standard Hermes v12 config shape) ──── +# +# The installed Agent's authoritative matcher for a named custom route is +# ``hermes_cli.runtime_provider_custom._match_new_style_provider()``. It scans +# the ENABLED entries of the RAW ``providers:`` mapping and takes the first +# whose alias set contains the requested name, where the alias set is minted by +# ``hermes_cli.providers.custom_provider_aliases()`` from BOTH the entry's +# display ``name`` and its config KEY. So the standard v12 shape +# +# model: +# provider: custom:omni +# providers: +# omni: +# base_url: https://omni.example/v1 +# key_env: OMNI_GATE_KEY +# +# routes through ``providers['omni']``: that record names the identity by its +# own config key, exactly the way ``providers['custom:']`` does. Looking +# only at the ``custom:``-prefixed spellings made every send on that config fail +# closed as ``unowned_custom_provider`` while the Agent resolved it fine. +# +# The alias/enabled rules are MIRRORED here rather than imported for the same +# reason :data:`_API_MODE_ALIASES` is: selection has to keep working when the +# installed runtime module is unavailable or replaced by a test double. + + +def _agent_custom_provider_slug(value: object) -> str: + """``custom:`` identity the Agent mints for ``value``. + + Mirror of ``hermes_cli.providers.custom_provider_slug()``: lowercase, spaces + to dashes, prefixed unless it already carries one. Deliberately NOT + :func:`_custom_provider_slug_key`'s normalization — this one reproduces the + Agent's alias vocabulary, and both are consulted by + :func:`_custom_record_names_identity`. + """ + identity = str(value or "").strip().lower().replace(" ", "-") + if not identity: + return "" + return identity if identity.startswith("custom:") else f"custom:{identity}" + + +def _custom_provider_alias_set(display_name: object, provider_key: object) -> frozenset[str]: + """Every identity the Agent accepts for ONE custom-provider record. + + Mirror of ``hermes_cli.providers.custom_provider_aliases()``, including its + legacy ``custom:custom:`` spelling, so a record the Agent would route + to is not reported unowned here. + """ + aliases: set[str] = set() + for value in (display_name, provider_key): + raw = str(value or "").strip().lower() + if not raw: + continue + normalized = raw.replace(" ", "-") + aliases.update({raw, normalized, _agent_custom_provider_slug(normalized)}) + if normalized.startswith("custom:"): + suffix = normalized.split(":", 1)[1] + if suffix: + aliases.update({suffix, f"custom:{normalized}"}) + aliases.discard("") + return frozenset(aliases) + + +def _custom_record_names_identity(display_name: object, provider_key: object, pid: str, slug: str) -> bool: + """True when a record keyed ``provider_key`` / named ``display_name`` IS ``pid``. + + Two vocabularies, because two producers mint these ids. The Agent's alias set + is what decides a CLI send, so honouring it is what keeps the two in + agreement. :func:`_custom_provider_slug_key` is what the WebUI itself mints + from a friendly name (``Local (127.0.0.1:11434)`` -> ``local-127.0.0.1-11434``), + so a record the WebUI wrote is recognised by its own rules too. Both are + identity tests on the record's OWN name/key — neither widens selection to a + record that names some other provider. + """ + if pid in _custom_provider_alias_set(display_name, provider_key): + return True + return any( + _custom_provider_slug_key(value) == slug + for value in (display_name, provider_key) + if str(value or "").strip() + ) + + +# ``enabled:`` words YAML may hand us as strings, matching +# hermes_cli.config_providers._FALSE_WORDS. +_PROVIDER_DISABLED_WORDS = frozenset({"false", "0", "no", "off"}) + + +def _raw_provider_record_enabled(record: object) -> bool: + """Mirror of ``hermes_cli.config_providers.is_provider_enabled()``. + + Default True; only an explicit falsey ``enabled`` hides the entry. A disabled + entry is invisible to the Agent's resolver, so it must not own a WebUI route + either — the route fails closed instead of quietly using a row the user + switched off. + """ + if not isinstance(record, dict): + return False + flag = record.get("enabled", True) + if isinstance(flag, bool): + return flag + if isinstance(flag, str): + return flag.strip().lower() not in _PROVIDER_DISABLED_WORDS + return bool(flag) + + +# ``_entry_url``'s precedence in hermes_cli.runtime_provider_custom. +_RAW_PROVIDER_URL_FIELDS = ("api", "url", "base_url") + + +def _normalized_raw_provider_record(record: dict, ep_name: str) -> dict: + """Return ``record`` respelled in the field names this module's readers use. + + ONE record in, ONE record out: every value is copied from ``record`` and + nothing is sourced from anywhere else, so the endpoint, the credential, the + wire protocol, the pool, the ACP transport, the capabilities and the request + fields the caller then lifts all still belong to that single authority. Only + the SPELLINGS differ between the raw ``providers:`` shape and the + ``custom_providers[]`` shape the field readers were written against: + + ``api`` / ``url`` -> ``base_url`` (the Agent's ``_entry_url`` precedence) + ``api_key_env`` -> ``key_env`` + ```` -> ``provider_key``, when the record names none itself + + ``transport`` -> ``api_mode`` and ``command`` / ``args`` -> ``acp_*`` need no + rewrite: :func:`_custom_record_api_mode` and + :func:`_custom_record_acp_transport` already accept both spellings in place. + + Respelling a COPY rather than the live record keeps the config snapshot + unmutated, so a second resolution of the same slug sees the same input. + """ + normalized = dict(record) + for field in _RAW_PROVIDER_URL_FIELDS: + value = record.get(field) + if isinstance(value, str) and value.strip(): + normalized["base_url"] = value.strip() + break + if not str(normalized.get("key_env") or "").strip(): + api_key_env = record.get("api_key_env") + if isinstance(api_key_env, str) and api_key_env.strip(): + normalized["key_env"] = api_key_env.strip() + if not str(normalized.get("provider_key") or "").strip() and str(ep_name or "").strip(): + # The record's OWN config key is its identity. Stamping it keeps the + # credential-pool lookup and the ``key_cmd`` token process labelled with + # this provider instead of the generic ``custom``. + normalized["provider_key"] = str(ep_name).strip() + return normalized + + +def _unique_raw_provider_record(providers_cfg: object, pid: str, slug: str) -> dict | None: + """The ONE enabled raw ``providers:`` record that owns ``pid``, else None. + + Skips the two keys that already have dedicated, higher-precedence candidates + in :func:`_select_custom_provider_record` — ``providers['custom:']`` + and the generic ``providers['custom']`` — so one record can never be counted + as two authorities, nor collide with itself. + + Fails closed on a genuine collision: when two DISTINCT raw records both name + this identity and both declare connection fields, an endpoint and a + credential could be lifted from different rows, which is the same + split-authority pairing :func:`_unique_custom_provider_entry` raises on. A + record that names the identity but declares nothing is not a competing + authority, so it neither wins nor blocks — selection simply falls through to + the next candidate, and to the terminal verdict when there is none. + """ + if not isinstance(providers_cfg, dict): + return None + owning: list[tuple[str, dict]] = [] + for ep_name, record in providers_cfg.items(): + key = str(ep_name or "").strip() + if not key or key.lower() in {pid, "custom"}: + continue + if not isinstance(record, dict) or not record: + continue + if not _raw_provider_record_enabled(record): + continue + if not _custom_record_names_identity(record.get("name") or key, key, pid, slug): + continue + normalized = _normalized_raw_provider_record(record, key) + if _custom_record_owns_connection(normalized, pid): + owning.append((key, normalized)) + if len(owning) >= 2: + keys = [key for key, _record in owning] + raise AmbiguousCustomProviderError( + f"Custom provider records {keys!r} under providers: all name the provider " + f"slug {slug!r}; an endpoint and API key could be resolved from different " + f"records. Rename one so each custom provider has a unique slug." + ) + return owning[0][1] if owning else None + + +def _select_custom_provider_record( + pid: str, + slug: str, + cfg_data: dict, +) -> tuple[dict | None, str, bool, str]: + """Return ``(record, source, is_exact, status)`` — the ONE authority for ``pid``. + + Selection is IDENTITY-OWNED: a candidate qualifies only by naming this slug + (or by being the bare-``custom`` / explicitly-matching ``model:`` authority), + never by being the only row around. Selection order is the WebUI routing + contract: + + 1. the exact ``custom_providers[]`` row whose name normalizes to ``slug`` + (authoritative for its slug even against a same-slug keyed record, and + even when its ``base_url`` is blank — see #1806); + 2. otherwise the keyed ``providers['custom:']`` record, then the raw + ``providers:`` record that names this identity by its own config key + or display name (``providers: {omni: ...}`` for ``custom:omni`` — the + standard Hermes v12 shape, matched with the installed Agent's own alias + rules; see :func:`_unique_raw_provider_record`), then a record that NAMES + this slug (the generic ``providers['custom']`` entry whose own + name/provider_key normalizes to it, or a ``model:`` block whose provider + is ``custom:``), and last the generic ``providers['custom']`` entry + when ``model.provider`` names this slug — the active-provider shape the + WebUI itself writes. Each is taken as a COMPLETE record rather than + field-by-field, and each is eligible as soon as it declares ANY field the + connection bundle carries, not just a static key or a base_url (see + :func:`_custom_record_owns_connection`). + + What is deliberately NOT eligible is the generic ``providers['custom']`` + record or a bare-``custom`` ``model:`` block that names no slug at all. Those + are catch-alls, and while they matched every lookup, ``custom:ghost`` took + whichever one was configured and inherited its endpoint, credential, pool, + ``api_mode`` and ACP transport — the same wrong-authority pairing as the + sole-row fallback below, sourced from ``providers:``/``model:`` instead. + + There is deliberately NO "the list holds exactly one row, so use it" + fallback. That rule resolved ``custom:ghost`` to the endpoint AND credential + of a sole unrelated row named ``omni`` — the same wrong-authority pairing the + exact-row rule exists to prevent, and a credential leak to an endpoint the + user never named. An unknown slug owns nothing, so it reports ``missing`` + and the caller fails closed. This matches the point-of-return rule + ``resolve_model_provider`` already applies (#4728: no unique entry -> no + base_url, never a guess). + + ``source`` is ``custom_providers`` / ``providers`` / ``model`` (or ``""`` + when nothing matched) and names the authority that owns every field the + caller then lifts off ``record``. ``status`` is one of the + ``CUSTOM_SELECTION_*`` values (``ambiguous`` raises instead of returning). + """ custom_providers = cfg_data.get("custom_providers", []) if not isinstance(custom_providers, list): custom_providers = [] @@ -3295,47 +3768,1010 @@ def resolve_custom_provider_connection(provider_id: str) -> tuple[str | None, st # and credential are always resolved from the SAME entry. matched_entry = _unique_custom_provider_entry(custom_providers, slug) if matched_entry is not None: - base_url = str(matched_entry.get("base_url") or "").strip() or None - api_key = _resolve_key(matched_entry.get("api_key"), matched_entry.get("key_env"), pid) - return api_key, base_url + return matched_entry, "custom_providers", True, CUSTOM_SELECTION_EXACT - # If exactly one custom provider is configured, use it as a pragmatic - # fallback for mismatched slugs (e.g. punctuation differences). - if len(custom_providers) == 1 and isinstance(custom_providers[0], dict): - entry = custom_providers[0] - return ( - _resolve_key(entry.get("api_key"), entry.get("key_env"), pid), - str(entry.get("base_url") or "").strip() or None, - ) - - # Fallbacks for setups that don't use custom_providers names directly. + # Fallbacks for setups that don't use custom_providers names directly. Every + # one of them is keyed on this provider's OWN identity. providers_cfg = cfg_data.get("providers", {}) provider_specific = providers_cfg.get(pid, {}) if isinstance(providers_cfg, dict) else {} provider_custom = providers_cfg.get("custom", {}) if isinstance(providers_cfg, dict) else {} + if ( + isinstance(provider_specific, dict) + and provider_specific + and not _raw_provider_record_enabled(provider_specific) + ): + # ``providers['custom:']`` names THIS slug by its own exact key, so + # switching it off is a statement about this route and not merely about + # one candidate among several. Falling through to the generic ``custom`` + # record or the ``model:`` block would honour the disable by routing the + # turn somewhere ELSE — the ambient provider's endpoint and credential + # under the name the user just took offline. The slug's own authority + # said no, so the route is terminal here. + return None, "", False, CUSTOM_SELECTION_MISSING + model_cfg = cfg_data.get("model", {}) model_provider = str(model_cfg.get("provider") or "").strip().lower() if isinstance(model_cfg, dict) else "" - fallback_base = None - for candidate in (provider_specific, provider_custom, model_cfg): - if isinstance(candidate, dict): - _base = str(candidate.get("base_url") or "").strip() - if _base: - fallback_base = _base - break + # The generic ``providers['custom']`` record and a ``model:`` block whose + # provider is the bare string ``custom`` are catch-alls: they name no slug of + # their own. While they were eligible for EVERY ``custom:`` lookup, + # ``custom:ghost`` selected whichever one happened to be configured and + # inherited its endpoint, credential, pool, api_mode and ACP transport — the + # user's prompt and a credential sent to a provider they never named. + # Candidates are ordered by how specifically they name THIS identity. + named_by_model = model_provider in {pid, slug} + # A disabled record is invisible to the Agent's resolver, so it is not an + # authority here either — at EVERY rung, not just the raw-``providers:`` scan + # that already filtered for it. + has_generic_custom = ( + isinstance(provider_custom, dict) + and bool(provider_custom) + and _raw_provider_record_enabled(provider_custom) + ) + # The generic record is keyed ``custom`` in ``providers:``, so it names the + # literal ``custom:custom`` route by its own key; for any other slug it has + # to say so itself. + generic_claims_slug = has_generic_custom and ( + slug == "custom" or _custom_record_claims_slug(provider_custom, slug) + ) - fallback_key = None - if isinstance(provider_specific, dict): - fallback_key = _resolve_key(provider_specific.get("api_key"), provider_specific.get("key_env"), pid) - if not fallback_key and isinstance(provider_custom, dict): - fallback_key = _resolve_key(provider_custom.get("api_key"), provider_custom.get("key_env"), pid) - if not fallback_key and isinstance(model_cfg, dict) and model_provider in {"custom", pid, slug}: - fallback_key = _resolve_key(model_cfg.get("api_key"), model_cfg.get("key_env"), pid) + def _candidates(): + """Identity-owned candidates, most specific first — evaluated LAZILY. - if fallback_key or fallback_base: - return fallback_key, fallback_base or None + Laziness is load-bearing for the raw-``providers:`` scan alone: that one + RAISES on an alias collision, and a config whose route is already decided + by the exact ``providers['custom:']`` key must not be failed closed + by a collision further down a list it never reaches. + """ + if isinstance(provider_specific, dict) and provider_specific: + # Keyed by this provider's own id: ``providers['custom:']``. + yield provider_specific, "providers" + # The standard v12 shape: a raw ``providers:`` record that names + # this identity by its own config key or display name (``providers: + # {omni: ...}`` for ``custom:omni``), matched with the installed Agent's + # alias rules. This is identity ownership, not an ambient-field escape — + # a record that names some OTHER provider never appears here, and an + # unknown slug still matches nothing and falls through to the terminal + # verdict below. + raw_record = _unique_raw_provider_record(providers_cfg, pid, slug) + if raw_record is not None: + yield raw_record, "providers" + if generic_claims_slug: + yield provider_custom, "providers" + if isinstance(model_cfg, dict) and model_cfg and _raw_provider_record_enabled(model_cfg) and ( + named_by_model + or (model_provider == "custom" and _custom_record_claims_slug(model_cfg, slug, allow_name=False)) + ): + yield model_cfg, "model" + if has_generic_custom and named_by_model and not generic_claims_slug: + # Last: the active-provider shape the WebUI itself writes, where the + # generic record holds the configuration of whichever custom provider + # ``model.provider`` names — here, THIS slug. That is a provenance + # tie rather than a declaration, so anything naming the slug outright + # (above) outranks it. + yield provider_custom, "providers" - return None, None + for cand, source in _candidates(): + # A record that names this slug is its authority as soon as it declares + # ANY connection field — a static key and a base_url are not the only + # things a record can own. One that declares only ``key_cmd``, a + # credential pool, an ``api_mode`` or an ACP transport is still THIS + # slug's record; reporting it missing would clear those very fields as + # foreign in merge_custom_provider_runtime_bundle. + # + # The ``enabled`` re-check is the single chokepoint every candidate has + # to pass: each branch above gates on it too, and one selection path + # added later without that gate is exactly how a switched-off row's + # endpoint and secret got back onto a live route. + if _raw_provider_record_enabled(cand) and _custom_record_owns_connection(cand, pid): + return cand, source, False, CUSTOM_SELECTION_KEYED + + return None, "", False, CUSTOM_SELECTION_MISSING + + +def resolve_custom_provider_connection( + provider_id: str, + *, + return_provenance: bool = False, +) -> tuple[str | None, str | None] | tuple[str | None, str | None, bool]: + """Return (api_key, base_url) for a named ``custom:*`` provider. + + Supports ``custom_providers[].api_key`` as either a literal key or + ``${ENV_VAR}``, and ``custom_providers[].key_env`` as an env-var hint. + Returns ``(None, None)`` when no named custom provider matches. + If ``return_provenance=True``, returns ``(api_key, base_url, is_exact)`` + indicating whether the connection was resolved from an exact matching + ``custom_providers[]`` entry. + + This is the URL/key VIEW of the authoritative record. Agent-construction + paths want :func:`resolve_custom_provider_bundle` instead: the record also + owns ``api_mode``, ``key_cmd``, pool credentials and ACP transport fields, + and a caller that takes only two of them still builds a mixed-authority + agent. + """ + pid = str(provider_id or "").strip().lower() + if not pid.startswith("custom:"): + if return_provenance: + return None, None, False + return None, None + + slug = _custom_provider_slug_key(pid) + if not slug: + if return_provenance: + return None, None, False + return None, None + + # Read the live config snapshot to avoid stale module-level cache edge + # cases after profile switches or runtime config edits. + record, _source, is_exact, _status = _select_custom_provider_record(pid, slug, get_config()) + if record is None: + # Nothing owns this slug. Returning ``(None, None)`` is the whole point: + # an unknown named route must not inherit an unrelated row's endpoint or + # credential (see _select_custom_provider_record). + if return_provenance: + return None, None, False + return None, None + + base_url = _custom_record_base_url(record) + api_key = _resolve_custom_record_key(record.get("api_key"), record.get("key_env"), pid) + if return_provenance: + return api_key, base_url, is_exact + return api_key, base_url + + +# Local OpenAI-compatible servers frequently run without authentication, so a +# missing key must not fail before the first request: hand the SDK a harmless +# placeholder and let the endpoint accept it or return its own auth error. It is +# applied ONLY after the authoritative record has been resolved in full and +# reported itself genuinely keyless (no api_key, no key_env, no key_cmd, no +# host-gated env key, no credential pool) — substituting it while any of those +# could still mint a credential is what turned a working ``key_cmd`` endpoint +# into a 401. +KEYLESS_CUSTOM_API_KEY = "dummy-key" + +# The constructor-routing fields AIAgent takes beside provider/base_url/api_key. +# They travel with the connection: a bundle that replaces the endpoint and the +# credential but leaves these behind builds an agent whose wire protocol +# (api_mode), transport (ACP subprocess) or credential source (pool) still +# points at the previous authority. +CUSTOM_CONNECTION_SIDE_FIELDS = ("api_mode", "acp_command", "acp_args", "credential_pool") + +# Alias spellings accepted for a record's ``api_mode`` / ``transport``, mirroring +# hermes_cli.config_providers._canonical_api_mode. Resolved locally rather than +# imported so a record's own transport survives even when the installed runtime +# module is unavailable (or replaced by a test double). +_API_MODE_ALIASES = { + "chat-completions": "chat_completions", + "chatcompletions": "chat_completions", + "openai": "chat_completions", + "responses": "codex_responses", + "openai_responses": "codex_responses", + "openai-responses": "codex_responses", + "anthropic": "anthropic_messages", + "anthropic-messages": "anthropic_messages", + "messages": "anthropic_messages", + "bedrock": "bedrock_converse", + "bedrock-converse": "bedrock_converse", +} +_VALID_API_MODES = { + "chat_completions", + "codex_responses", + "anthropic_messages", + "bedrock_converse", + "codex_app_server", +} + + +def _custom_record_api_mode(record: dict) -> str | None: + """Return the wire protocol a custom record declares for ITSELF, else None. + + ``transport:`` is the v12-migration spelling of ``api_mode:``; hand-edited + configs still use either. An unrecognized value returns None so the runtime + keeps its own host/provider detection instead of being handed nonsense. + """ + for field in ("api_mode", "transport"): + raw = record.get(field) + if not isinstance(raw, str): + continue + cleaned = raw.strip() + if not cleaned: + continue + canonical = _API_MODE_ALIASES.get(cleaned.lower(), cleaned).lower() + if canonical in _VALID_API_MODES: + return canonical + return None + + +def _custom_record_acp_transport(record: dict) -> dict: + """Return the ACP subprocess transport a custom record declares for ITSELF.""" + owned: dict = {} + command = record.get("acp_command") or record.get("command") + if isinstance(command, str) and command.strip(): + owned["acp_command"] = command.strip() + args = record.get("acp_args") + if args is None: + args = record.get("args") + if isinstance(args, (list, tuple)) and len(args) > 0: + owned["acp_args"] = list(args) + return owned + + +def _custom_record_pool_runtime(base_url: str | None, record: dict) -> dict | None: + """Runtime dict from the credential pool that owns ``base_url``, else None. + + Delegates to the installed runtime's own pool lookup so pool ownership, + ordering and the loopback placeholder behave exactly as they do for a CLI + send. Best-effort: builds that do not expose the helper (or a stubbed + runtime module) simply report no pool. + """ + if not base_url: + return None + try: + import hermes_cli.runtime_provider as _runtime_provider + + resolve_pool = getattr(_runtime_provider, "_try_resolve_from_custom_pool", None) + if resolve_pool is None: + return None + return resolve_pool( + base_url, + "custom", + _custom_record_api_mode(record), + provider_name=str(record.get("provider_key") or record.get("name") or "") or None, + ) + except Exception: + return None + + +def _host_gated_env_key(base_url: str | None) -> str | None: + """Env credential the installed runtime would accept for ``base_url``, else None. + + Host-GATED on purpose (GHSA-76xc-57q6-vm5m): the helper only yields + OPENAI/OPENROUTER/````_API_KEY when the endpoint's host is the + authoritative one, so this cannot leak a cloud key to an unrelated custom + endpoint. Consulted here only so "is this endpoint genuinely keyless?" has + the same answer in WebUI as it does at runtime. + """ + if not base_url: + return None + try: + import hermes_cli.runtime_provider as _runtime_provider + + candidates = getattr(_runtime_provider, "_host_gated_env_key_candidates", None) + if candidates is None: + return None + for candidate in candidates(base_url, ollama=False): + cleaned = str(candidate or "").strip() + if cleaned: + return cleaned + except Exception: + return None + return None + + +def _custom_record_key_cmd_provider(base_url: str | None, record: dict, pid: str): + """Per-request token provider for a record's ``key_cmd``, else None. + + ``key_cmd`` names a command that PRINTS a short-lived bearer; both wire + clients accept a callable api_key and mint per request. It is a real + credential source, so a record that declares one is NOT keyless and must + never be handed :data:`KEYLESS_CUSTOM_API_KEY`. + """ + key_cmd = str(record.get("key_cmd") or "").strip() + if not key_cmd: + return None + try: + from agent.command_token_source import build_command_token_provider + + return build_command_token_provider( + key_cmd, str(record.get("name") or record.get("provider_key") or pid or "custom") + ) + except Exception: + logger.debug("key_cmd token provider unavailable for %s", pid, exc_info=True) + return None + + +def _custom_record_declares_credential( + record: dict, + base_url: str | None, + pool_runtime: dict | None, +) -> bool: + """True when the record DECLARES any credential source, resolved or not. + + "Keyless" is a positive claim that an endpoint wants no authentication, and + it is the only gate on :data:`KEYLESS_CUSTOM_API_KEY`. Deriving it from + "``api_key`` came back empty" conflates two opposite situations: an + unauthenticated local server, and an authenticated endpoint whose declared + credential did not resolve (an ``${ENV}`` that is unset, a ``key_env`` + naming a missing variable, a configured pool that yielded nothing, an + unbuildable ``key_cmd``). The second must NOT be handed the placeholder — + that turns a missing-credential misconfiguration into an opaque 401 from the + endpoint and hides the real cause. + + So the question asked here is declaration, not resolution: the RAW + ``api_key`` (literal or ``${ENV}``), ``key_env``, ``key_cmd``, a configured + ``credential_pool``, a pool that actually resolved for this endpoint, and a + host-gated env credential the runtime would accept for it. + """ + for field in ("api_key", "key_env", "key_cmd"): + if str(record.get(field) or "").strip(): + return True + # A configured pool is a declaration even when empty or exhausted: the user + # pointed this endpoint at a credential source. + if record.get("credential_pool") is not None: + return True + if pool_runtime: + return True + if _host_gated_env_key(base_url): + return True + return False + + +def _unowned_custom_provider_bundle(pid: str, slug: str, status: str) -> dict: + """Bundle for a named ``custom:*`` route that NO authority owns. + + Every connection field is empty and ``keyless`` is False, so the merge below + cannot substitute :data:`KEYLESS_CUSTOM_API_KEY`: "nobody owns this route" is + not a claim that the route is unauthenticated. + """ + return { + "provider_id": pid, + "slug": slug, + "source": "", + "status": status, + "is_exact": False, + "record": None, + "base_url": None, + "api_key": None, + # Nothing owns the route, so nothing positively owns an endpoint for it + # either. Stated rather than inferred: the merge must never read an + # endpoint that reached it from somewhere else as this route's own. + "endpoint_owned": False, + "keyless": False, + "owned": {}, + } + + +def resolve_custom_provider_bundle( + provider_id: str, + *, + connection_resolver=None, +) -> dict | None: + """Return the COMPLETE connection bundle a named ``custom:*`` record owns. + + ``None`` only when ``provider_id`` is not a named custom provider at all. + When it IS one but nothing owns it, the result is an explicit unowned bundle + (``status`` ``missing``/``malformed``, every connection field empty and + ``keyless`` False) rather than ``None``, so the merge below can fail the + route closed instead of silently leaving the ambient runtime's endpoint, + credential and pool in place. Otherwise a dict with: + + ``base_url`` + the record's own endpoint (``None`` when it declares none). + ``endpoint_owned`` + True only when the SELECTED record supplied that endpoint itself. It is + the positive half of the provenance the merge needs: an endpoint already + sitting in a caller's bundle, an endpoint that merely compares equal, and + an absent runtime dict are all silence, and silence must never be read as + "this endpoint belongs to the selected record". False here means the + route has no endpoint of its own, whatever else is in flight. + ``api_key`` + the record's own credential, resolved through the SAME ladder the + runtime uses for a named custom provider: pool credential, then literal + / ``${ENV}`` / ``key_env`` / ``CUSTOM__API_KEY``, then a host-gated + env key — with ``key_cmd`` overriding the static forms because it mints a + fresh bearer per request. May be a callable (``key_cmd`` token provider). + ``keyless`` + True only when the record DECLARES no credential source at all, i.e. the + endpoint is genuinely unauthenticated. A declared-but-unresolved + credential (unset ``${ENV}``, missing ``key_env`` variable, empty pool, + unbuildable ``key_cmd``) leaves this False — see + :func:`_custom_record_declares_credential`. This is the ONLY gate on + :data:`KEYLESS_CUSTOM_API_KEY`. + ``owned`` + the subset of :data:`CUSTOM_CONNECTION_SIDE_FIELDS` this record supplies + itself. Callers keep these and must not overwrite them with the ambient + runtime's values; fields ABSENT here are simply unowned, not proven + foreign (see :func:`merge_custom_provider_runtime_bundle`). + ``source`` / ``is_exact`` / ``record`` / ``status`` + the provenance of the selection, so a caller can tell an exact + ``custom_providers[]`` row from a keyed fallback — and either of them + from a route nothing owns (``status`` in + :data:`CUSTOM_SELECTION_UNOWNED`). + + ``connection_resolver`` lets a caller inject its own module-bound reference + to :func:`resolve_custom_provider_connection`, so monkeypatching that name in + the caller's namespace still takes effect. An injected resolver can only + report a URL/key pair — there is no record behind it — so the bundle it + yields owns no side fields and the caller keeps the runtime's. + """ + pid = str(provider_id or "").strip().lower() + if not pid.startswith("custom:"): + return None + slug = _custom_provider_slug_key(pid) + if not slug: + # ``custom:`` with nothing behind it names no provider, so no record can + # own it. Report it explicitly rather than as "not a custom route": the + # caller asked to route somewhere and must fail closed, not inherit the + # ambient connection. + return _unowned_custom_provider_bundle(pid, slug, CUSTOM_SELECTION_MALFORMED) + + if connection_resolver is not None and connection_resolver is not resolve_custom_provider_connection: + # Tolerate resolvers that predate/omit the provenance kwarg (older builds + # and test doubles that patch in a plain two-value resolver). + try: + conn = connection_resolver(pid, return_provenance=True) + except TypeError: + conn = connection_resolver(pid) + if len(conn) == 3: + api_key, base_url, is_exact = conn + else: + api_key, base_url = conn + is_exact = False + if not (is_exact or api_key or base_url): + # The injected resolver is the whole authority on this path, and it + # reported nothing for the slug — so nothing owns the route. + return _unowned_custom_provider_bundle(pid, slug, CUSTOM_SELECTION_MISSING) + return { + "provider_id": pid, + "slug": slug, + "source": "resolver", + "status": CUSTOM_SELECTION_RESOLVER, + "is_exact": bool(is_exact), + "record": None, + "base_url": base_url, + "api_key": api_key, + "endpoint_owned": bool(base_url), + # A resolver reports a URL/key pair and nothing else; with no record + # behind it there is no declaration to inspect, so an absent key is + # the only keyless signal available here. + "keyless": not api_key, + "owned": {}, + } + + record, source, is_exact, status = _select_custom_provider_record(pid, slug, get_config()) + if record is None: + return _unowned_custom_provider_bundle(pid, slug, status) + + base_url = _custom_record_base_url(record) + owned: dict = {} + + api_mode = _custom_record_api_mode(record) + if api_mode: + owned["api_mode"] = api_mode + owned.update(_custom_record_acp_transport(record)) + + # Pool first, exactly like the runtime's named-custom path: a pooled + # endpoint's credential AND its pool object come from the same lookup. + api_key = None + pool_runtime = _custom_record_pool_runtime(base_url, record) + if pool_runtime: + api_key = pool_runtime.get("api_key") or None + if pool_runtime.get("credential_pool") is not None: + owned["credential_pool"] = pool_runtime.get("credential_pool") + + if not api_key: + api_key = _resolve_custom_record_key(record.get("api_key"), record.get("key_env"), pid) + if not api_key: + api_key = _host_gated_env_key(base_url) + + # ``key_cmd`` beats a static api_key / key_env (short-lived bearers go stale + # mid-session), but never displaces a pooled credential, which the pool + # itself already rotates. + if not pool_runtime: + token_provider = _custom_record_key_cmd_provider(base_url, record, pid) + if token_provider is not None: + api_key = token_provider + + if "credential_pool" not in owned and record.get("credential_pool") is not None: + owned["credential_pool"] = record.get("credential_pool") + + return { + "provider_id": pid, + "slug": slug, + "source": source, + "status": status, + "is_exact": is_exact, + "record": record, + "base_url": base_url, + "api_key": api_key, + # POSITIVE endpoint provenance, resolved from the selected record and + # from nothing else. The merge below pairs this record's credential with + # an endpoint only while this is True. + "endpoint_owned": bool(base_url), + "keyless": not api_key + and not _custom_record_declares_credential(record, base_url, pool_runtime), + "owned": owned, + } + + +def _connection_identity(runtime_provider: dict, resolved_provider: str | None) -> str | None: + """Name the provider the already-resolved connection fields belong to. + + The runtime provider dict names itself, so it wins when present. With no + runtime dict the caller resolved the connection for ``resolved_provider`` + (every such call site passes ``requested=resolved_provider``), so that is + the identity behind the fields. + """ + rt_provider = str((runtime_provider or {}).get("provider") or "").strip() + if rt_provider: + return rt_provider.lower() + return str(resolved_provider or "").strip().lower() or None + + +def _custom_bundle_endpoint_matches(bundle: dict, runtime_provider: dict) -> bool: + """True when the runtime resolved the SAME endpoint the record owns. + + This is the provenance test that decides whether the ambient runtime's side + fields are same-authority (keep) or foreign (clear). A record that declares + no endpoint of its own cannot prove the runtime is foreign, so its runtime + fields stand. + + Endpoint equality alone is NOT provenance. Two providers are free to share a + base_url — a gateway fronting several accounts, a local proxy, the same host + reached with different keys — so a URL match between a record and the ambient + runtime says only that both point at one host, never that one authority + resolved both. When the runtime dict NAMES itself and that name is some other + provider, the identities settle it directly and the shared URL proves + nothing. + """ + if not isinstance(runtime_provider, dict) or not runtime_provider: + return False + rt_provider = str(runtime_provider.get("provider") or "").strip().lower() + if rt_provider: + slug = str(bundle.get("slug") or "").strip().lower() + owned_names = { + str(bundle.get("provider_id") or "").strip().lower(), + slug, + f"custom:{slug}" if slug else "", + # The generic ``custom`` runtime is the shape the WebUI writes for + # whichever custom provider is active, so it is not a competing name. + "custom", + } + owned_names.discard("") + if rt_provider not in owned_names: + return False + record_base_url = bundle.get("base_url") + rt_base_url = runtime_provider.get("base_url") + if not record_base_url and not rt_base_url: + return True + if not record_base_url or not rt_base_url: + return False + return _normalize_base_url_for_match(record_base_url) == _normalize_base_url_for_match( + rt_base_url + ) + + +def _custom_runtime_endpoint_is_record_owned(bundle: dict, runtime_provider: dict) -> bool: + """True when the runtime dict positively originated from the selected RECORD. + + Endpoint equality alone is NOT selected-record provenance: two distinct + records (e.g. an exact list row and a same-slug keyed record) can share a + normalized URL while carrying different credentials. A tie requires either + an explicit source-record identity or matching record credentials; + silence, missing runtime dicts, and distinct records sharing an endpoint + never establish that the runtime spelling belongs to the selected record. + """ + if not bundle.get("base_url"): + return False + if not isinstance(runtime_provider, dict) or not runtime_provider.get("base_url"): + return False + # Credential conflict strictly rejects a tie before checking identity metadata + bundle_key = bundle.get("api_key") + rt_key = runtime_provider.get("api_key") + if bundle_key and rt_key and bundle_key != rt_key: + return False + rec = bundle.get("record") + rt_rec = runtime_provider.get("record") or runtime_provider.get("source_record") + if rt_rec is not None: + return rt_rec == rec and _custom_bundle_endpoint_matches(bundle, runtime_provider) + rec_id = bundle.get("record_id") or (rec.get("id") if isinstance(rec, dict) else None) + rt_id = runtime_provider.get("record_id") + if rt_id is not None: + return rt_id == rec_id and _custom_bundle_endpoint_matches(bundle, runtime_provider) + if bundle_key and rt_key and bundle_key == rt_key: + return _custom_bundle_endpoint_matches(bundle, runtime_provider) + return False + + +def merge_custom_provider_runtime_bundle( + resolved_provider: str | None, + resolved_api_key: str | None, + resolved_base_url: str | None, + runtime_provider: dict | None = None, + *, + lookup_provider: str | None = None, + connection_resolver=None, +) -> dict: + """Return the COMPLETE constructor-routing bundle for one send attempt. + + Keys: ``provider``, ``base_url``, ``api_key``, every field in + :data:`CUSTOM_CONNECTION_SIDE_FIELDS`, and + :data:`CUSTOM_ROUTE_ERROR_FIELD`. Callers must apply the WHOLE dict — that is + the point: the connection and the transport/protocol/pool fields are one + authority, and the agent-cache signature must be derived from the same dict + so a bundle change always mints a new agent. + + :data:`CUSTOM_ROUTE_ERROR_FIELD` is the bundle's TERMINAL verdict, and it is + not optional to check. A named ``custom:`` route that resolves no complete + ``(api_key, base_url)`` pair is not constructor-ready: Hermes Agent reads the + missing field as permission to resolve another provider, so every consumer + that builds an AIAgent (or writes the agent cache, or feeds an auxiliary + client) must run the bundle through :func:`raise_for_custom_provider_route` + first and fail the turn with a controlled provider / missing-credential + error. See the :data:`CUSTOM_ROUTE_ERROR_FIELD` commentary for the exact + constructor branch this defends. + + Every consumer that builds an AIAgent for a ``custom:`` route goes + through here so the endpoint, the credential and the routing fields all come + from ONE record. The fill-only pattern this replaces + (``if not api_key: api_key = ...``) mixed authorities whenever the runtime + provider had already supplied a truthy value from a same-slug keyed + ``providers:`` record: resolution deterministically produced the + ``custom_providers[]`` row's URL while keeping the keyed row's API key. + + Ownership rules, per side field: + + * the selected record declares it -> the record's value wins (an exact list + row's ``api_mode: anthropic_messages`` is not "ambient noise" to be + cleared, and a keyed record keeps the pool/transport it owns); + * the record declares no endpoint of its own, or the runtime resolved the + SAME endpoint -> the runtime's value is same-authority and is kept; + * otherwise the runtime resolved a DIFFERENT authority -> the field is + cleared, because passing it through is what let a custom HTTP endpoint + inherit Anthropic credential pooling and a Claude ACP subprocess. + + The ENDPOINT itself is decided by positive provenance only. A selected + config record supplies the endpoint its credential is sent to, and the one + exception is a runtime dict positively tied to that same record (same + identity, same normalized URL), whose spelling is the one it can reach. + Everything else is silence: the ``resolved_base_url`` the caller arrived + with, a URL that merely compares equal, and a missing runtime dict say + nothing about the record selected for this slug. So a record that declares + no usable ``base_url`` does not borrow one — the route is terminal with + :data:`CUSTOM_ROUTE_NO_ENDPOINT` and the bundle keeps neither the incoming + endpoint nor this record's credential, because pairing them is precisely how + a non-active provider's secret reached the ACTIVE provider's URL. + + The CREDENTIAL of an exact ``custom_providers[]`` row is exempt from the + same-endpoint rule above: it is resolved from that row's own ladder and is + never inherited from the runtime, because a same-slug keyed record may + declare the identical ``base_url`` and endpoint equality would then be + enough to hand the row somebody else's key. + + :data:`KEYLESS_CUSTOM_API_KEY` is substituted only once the selected record + reports that it DECLARES no credential source at all. A record that declares + one which produced nothing here (an unset ``${ENV}``, a ``key_env`` naming a + missing variable, an empty pool, an unbuildable ``key_cmd``) is sent without + a key rather than with a placeholder that guarantees a 401 and hides the + real cause. + + A named ``custom:`` route that NO authority owns fails closed: the + returned bundle keeps the named provider but carries no endpoint, no + credential, no pool/transport and no placeholder key, so an unknown slug can + never be routed through the ambient provider's connection. This holds even + when the ambient runtime LABELS itself with that same slug — a provider + string is self-assigned, not proof that the connection belongs to the slug. + """ + return _custom_provider_runtime_bundle_with_provenance( + resolved_provider, + resolved_api_key, + resolved_base_url, + runtime_provider, + lookup_provider=lookup_provider, + connection_resolver=connection_resolver, + )[0] + + +def _custom_provider_runtime_bundle_with_provenance( + resolved_provider: str | None, + resolved_api_key: str | None, + resolved_base_url: str | None, + runtime_provider: dict | None = None, + *, + lookup_provider: str | None = None, + connection_resolver=None, +) -> tuple[dict, dict | None]: + """:func:`merge_custom_provider_runtime_bundle` plus the record it selected. + + Returns ``(bundle, custom)`` where ``custom`` is the + :func:`resolve_custom_provider_bundle` result the merge applied (``None`` + when the route is not a named custom provider, or no record matched). + Callers that also need the provenance take it from here rather than + re-resolving: a second resolution re-reads the config and can mint a second + ``key_cmd`` token provider or select a different pool entry. + """ + _rt = runtime_provider if isinstance(runtime_provider, dict) else {} + bundle = { + "provider": resolved_provider, + "base_url": resolved_base_url, + "api_key": resolved_api_key, + "api_mode": _rt.get("api_mode"), + "acp_command": _rt.get("acp_command", _rt.get("command")), + "acp_args": _rt.get("acp_args", _rt.get("args")), + "credential_pool": _rt.get("credential_pool"), + # Routable until a named custom route proves otherwise. Non-custom + # routes legitimately leave the endpoint and/or credential to the + # runtime provider, so only the named-``custom:`` branches below can + # record a verdict here. + CUSTOM_ROUTE_ERROR_FIELD: None, + } + + lookup = lookup_provider or resolved_provider + if not (isinstance(lookup, str) and lookup.startswith("custom:")): + return bundle, None + + custom = resolve_custom_provider_bundle(lookup, connection_resolver=connection_resolver) + if custom is None: + return bundle, None + + if custom["status"] in CUSTOM_SELECTION_UNOWNED: + # NO authority owns this slug: not an exact ``custom_providers[]`` row, + # not a keyed ``providers:`` record, not a ``model:`` authority. Whatever + # is already in ``bundle`` was resolved by someone else and reached us as + # ambient state, so the whole bundle fails closed. + # + # A provider LABEL is not proof of ownership. The runtime dict's + # ``provider: "custom:ghost"`` is a self-assigned string on a connection + # this process authenticated for some other reason, and + # :func:`_connection_identity` falls back to ``resolved_provider`` — the + # very slug being looked up — so a caller that passes no runtime dict at + # all would match itself. Trusting either would let any unknown slug + # claim the ambient endpoint, credential, pool and transport simply by + # naming itself. Ownership is decided by config records, and there are + # none here. + # + # So: no endpoint and no credential; no ambient pool/transport/ + # wire-protocol carried alongside them; the provider stays the NAMED + # slug, because rewriting it to the generic ``custom`` would present an + # unresolvable route as a resolved one; and no KEYLESS_CUSTOM_API_KEY — + # ``keyless`` is False on an unowned bundle precisely so the placeholder + # cannot claim that an endpoint we never found is unauthenticated. + logger.warning( + "custom provider %s matches no custom_providers[] row, keyed " + "providers[] record or model: authority, and the resolved connection " + "belongs to %s; refusing to route it through that provider's " + "endpoint and credential", + lookup, + _connection_identity(_rt, resolved_provider) or "an unnamed provider", + ) + bundle["base_url"] = None + bundle["api_key"] = None + for field in CUSTOM_CONNECTION_SIDE_FIELDS: + bundle[field] = None + # Clearing the fields is NOT what makes this terminal — the constructor + # reads an empty pair as "route me somewhere else". Record the verdict so + # consumers stop before AIAgent instead. + bundle[CUSTOM_ROUTE_ERROR_FIELD] = _custom_route_verdict( + CUSTOM_ROUTE_UNOWNED, lookup + ) + return bundle, custom + + same_authority = _custom_bundle_endpoint_matches(custom, _rt) + # The positive tie: the runtime dict resolved the very endpoint the selected + # record declares. Never True on absence of evidence, so it is the only + # signal allowed to keep an endpoint this record did not supply itself. + endpoint_tied = _custom_runtime_endpoint_is_record_owned(custom, _rt) + + if custom["record"] is not None and not custom["endpoint_owned"]: + # A CONFIG RECORD owns this slug — an exact ``custom_providers[]`` row, a + # keyed ``providers['custom:']``, a raw ``providers:`` row (the + # standard v12 shape) or a ``model:`` authority — and it declares no + # usable endpoint. So this route HAS no endpoint: the one sitting in + # ``bundle`` was resolved for whoever the process was already talking to + # (the ACTIVE provider) and reached us as ambient state. + # + # Keeping it would pair THIS record's credential with THAT provider's + # URL — the user's prompt and a non-active provider's secret delivered to + # an endpoint neither the record nor the user named. The installed + # Agent's ``_match_new_style_provider()`` skips an endpoint-less raw + # record for exactly this reason. + # + # Nothing here may stand in for the missing endpoint: not the caller's + # ``resolved_base_url``, not a URL that merely compares equal (a gateway + # fronting two accounts is one host and two authorities), and not an + # absent runtime dict. Absence of contrary evidence is not provenance. + # So the route fails closed on its OWN name — the terminal + # ``custom_provider_endpoint_unresolved``, naming the setting to fix — + # rather than degrading into a send through the active provider. + bundle["base_url"] = None + bundle["api_key"] = None + for field in CUSTOM_CONNECTION_SIDE_FIELDS: + # The record keeps what it declares for ITSELF; every other side + # field belongs to the ambient authority whose endpoint was just + # refused, so it goes with it. Leaving those behind would hand the + # refused provider's credential pool and ACP transport to a route + # that is about to be reported unresolvable. + bundle[field] = custom["owned"].get(field) + bundle[CUSTOM_ROUTE_ERROR_FIELD] = _custom_route_verdict( + CUSTOM_ROUTE_NO_ENDPOINT, custom["provider_id"] + ) + logger.warning( + "custom provider %s resolved no endpoint of its own; refusing to pair " + "its credential with the connection resolved for %s", + custom["provider_id"], + _connection_identity(_rt, resolved_provider) or "another provider", + ) + return bundle, custom + + if custom["is_exact"]: + # An exact ``custom_providers[]`` row is authoritative for its slug: BOTH + # the endpoint (including None when the row's base_url is blank) and the + # credential are replaced, never merged — the sole exception being the + # runtime's spelling of the row's OWN endpoint, handled below. The + # credential comes from the + # ROW's own ladder (pool, api_key/``${ENV}``/``key_env``/ + # ``CUSTOM__API_KEY``, ``key_cmd``, host-gated env) and from + # nowhere else — including when ``same_authority`` holds. + # + # Endpoint equality is NOT record provenance. A same-slug keyed + # ``providers["custom:"]`` record is free to declare the very same + # ``base_url``, and the ambient runtime dict carrying that URL is then + # indistinguishable by URL from one the row itself resolved. Accepting + # ``_rt["api_key"]`` on that evidence hands the keyed row's credential to + # the exact row, which is precisely the split-authority merge this + # function exists to stop: the row's URL with the keyed row's key. + # + # So a row whose declared credential produced nothing here keeps + # ``api_key`` None and falls through to the terminal-route verdict below, + # naming the setting to fix, rather than silently borrowing a credential + # the row never declared. A row that declares NO credential at all is + # reported ``keyless`` by the resolution above and gets + # :data:`KEYLESS_CUSTOM_API_KEY` there — that is the row's own statement + # that the endpoint is unauthenticated, not an inherited key either. + bundle["api_key"] = custom["api_key"] or None + # Same endpoint rule as the keyed branch below: the row's endpoint wins + # unless the runtime dict is POSITIVELY tied to it — same identity, same + # normalized URL — in which case the runtime's spelling of the ROW's own + # endpoint is kept, because that is the form it can actually reach. This + # is not a merge: an endpoint the row did not declare can never survive + # here, since ``endpoint_tied`` is False whenever the row supplied none. + bundle["base_url"] = _rt.get("base_url") if endpoint_tied else custom["base_url"] + elif custom["record"] is not None: + # No exact row, but a CONFIG RECORD was selected: a keyed + # ``providers['custom:']``, a raw ``providers:`` row (the + # standard v12 shape) or a ``model:`` authority, picked as ONE complete + # record. The endpoint and the credential must therefore come from it + # together, decided by that record's OWN endpoint provenance and by + # nothing that happened to be in flight when it was selected. A record + # that declares NO endpoint never reaches here — the terminal branch + # above already refused it rather than let it borrow one. + # + # The record DECLARES an endpoint, so that endpoint and the credential + # sent to it are one authority's pair and the record's credential is the + # only one that may accompany it — including when the ambient runtime + # reports the SAME URL. A shared base_url is not shared provenance: a + # gateway fronting two accounts hands both providers one host and two + # different keys, and taking ``_rt["api_key"]`` there sends the ACTIVE + # provider's secret to the non-active record's endpoint. A record whose + # declared credential source resolved nothing keeps ``api_key`` None and + # falls through to the terminal verdict naming the setting to fix, rather + # than borrowing the ambient key across that host. + bundle["api_key"] = custom["api_key"] or None + # The record's endpoint wins unless the runtime dict is POSITIVELY tied + # to it — same identity, same normalized URL — in which case the + # runtime's spelling is kept, because a normalized form of the same + # endpoint is the one it can actually reach. Any other endpoint already + # in the bundle was resolved before this record was selected: absent + # provenance is not a tie, so it is displaced rather than left beside the + # credential this record just supplied. + bundle["base_url"] = _rt.get("base_url") if endpoint_tied else custom["base_url"] + else: + # No record behind the selection: an injected ``connection_resolver`` + # reported a bare URL/key pair and IS the whole authority on this path. + # There is no record to take endpoint provenance from, so this keeps the + # historical fill-only shape — the caller's already-resolved endpoint + # stands and the resolver fills only what it left empty (#2271). + if custom["base_url"]: + bundle["api_key"] = custom["api_key"] or None + elif same_authority and _rt.get("api_key"): + # The resolver reported NO endpoint, so the runtime's URL is filling + # a hole rather than being displaced — and the ambient endpoint's own + # credential is the coherent partner for it. + bundle["api_key"] = _rt.get("api_key") + elif custom["api_key"]: + bundle["api_key"] = custom["api_key"] + if custom["base_url"] and _rt.get("base_url") and not same_authority: + bundle["base_url"] = custom["base_url"] + elif not bundle["base_url"] and custom["base_url"]: + bundle["base_url"] = custom["base_url"] + + for field in CUSTOM_CONNECTION_SIDE_FIELDS: + if field in custom["owned"]: + # The selected record declares this field for ITSELF, so it wins over + # the ambient runtime even when the two disagree: an exact list row's + # ``api_mode: anthropic_messages`` is the row's wire protocol, not + # ambient noise, and a keyed record keeps the pool/ACP transport it + # declares. + bundle[field] = custom["owned"][field] + elif not same_authority: + # The record owns an endpoint the runtime did NOT resolve, so this + # value provably came from a different authority. Clearing it is what + # stops a custom HTTP endpoint inheriting Anthropic credential pooling + # and a Claude ACP subprocess. + bundle[field] = None + # else: the runtime resolved the SAME endpoint the record owns, so its + # value is same-authority and the seed above already kept it. + + if bundle["base_url"]: + # Route through the generic custom OpenAI-compatible client once the + # named provider has supplied the concrete endpoint. Keeping the provider + # as custom: would make Agent init synthesize invalid env-var hints + # like CUSTOM:SOMETHING-8000_API_KEY on keyless setups. + bundle["provider"] = "custom" + if not bundle["api_key"]: + if custom["keyless"]: + # Only now, with the record's full credential ladder exhausted + # (pool, api_key, key_env, CUSTOM__API_KEY, key_cmd, + # host-gated env) and the runtime offering nothing either, is the + # endpoint provably keyless. + bundle["api_key"] = KEYLESS_CUSTOM_API_KEY + else: + # The record DECLARES a credential source that did not yield one + # here (e.g. a ``key_cmd`` whose token provider could not be + # built). Substituting the placeholder would turn that into a + # silent 401 against an endpoint that does require auth; leave the + # credential unset so the failure names its real cause. + logger.warning( + "custom provider %s declares a credential source that produced " + "no key; sending without one rather than the keyless placeholder", + custom["provider_id"], + ) + + if not (bundle["api_key"] and bundle["base_url"]): + # A record OWNS this route, but the merge could not produce the complete + # explicit pair AIAgent needs to honour it. Leaving the hole would hand + # the send straight back to ``_routed_client_kwargs()``, which re-resolves + # the provider and can reach the ambient endpoint, a keyed row this record + # deliberately displaced, or the init-time fallback chain. Name the real + # cause instead and let the caller fail the turn. + bundle[CUSTOM_ROUTE_ERROR_FIELD] = _custom_route_verdict( + CUSTOM_ROUTE_NO_CREDENTIAL if bundle["base_url"] else CUSTOM_ROUTE_NO_ENDPOINT, + custom["provider_id"], + ) + logger.warning( + "custom provider %s resolved no routable connection (%s); refusing to " + "build an agent that would re-enter provider routing", + custom["provider_id"], + bundle[CUSTOM_ROUTE_ERROR_FIELD]["reason"], + ) + + return bundle, custom + + +def apply_custom_provider_connection_authority( + resolved_provider: str | None, + resolved_api_key: str | None, + resolved_base_url: str | None, + *, + lookup_provider: str | None = None, + connection_resolver=None, + runtime_provider: dict | None = None, +) -> tuple[str | None, str | None, str | None, bool]: + """Connection-only VIEW of :func:`merge_custom_provider_runtime_bundle`. + + Returns ``(provider, api_key, base_url, custom_owned)``, where + ``custom_owned`` reports whether a config-owned custom record supplied the + connection — False for a named route nothing owns, whose key and base_url + come back ``None`` rather than as the ambient runtime's. Kept for callers + that genuinely construct nothing else (capability/vision lookups); anything + that builds an AIAgent must take the whole bundle instead and honour its + :data:`CUSTOM_ROUTE_ERROR_FIELD` verdict, because the three connection fields + alone are neither a complete constructor contract nor a terminal refusal. + This view deliberately does NOT raise: a capability lookup asking "can this + route do vision" is not a constructor boundary and must not turn into a + failed turn. + """ + bundle, custom = _custom_provider_runtime_bundle_with_provenance( + resolved_provider, + resolved_api_key, + resolved_base_url, + runtime_provider, + lookup_provider=lookup_provider, + connection_resolver=connection_resolver, + ) + custom_owned = custom is not None and custom["status"] not in CUSTOM_SELECTION_UNOWNED + return bundle["provider"], bundle["api_key"], bundle["base_url"], custom_owned # Subprocess ACP transports (Cursor/Copilot CLI). Model IDs often contain '/' @@ -5158,10 +6594,17 @@ def set_auxiliary_model(task: str, provider: str, model: str, advanced: dict | N # (overlapping-id misroute, sibling of the resolve_model_provider # fix). For a named custom: selection, look up that # provider's OWN custom_providers[] entry directly. Note we do - # NOT route through model_with_provider_context here: the - # @custom::model form resolves base_url to None (the - # @provider path doesn't carry a custom entry's base_url), which - # would drop the base_url entirely. Fall back to the bare resolve + # NOT route through model_with_provider_context here: its + # @custom::model form re-resolves against the AMBIENT + # module-level `cfg`, while this path must read the in-lock + # `config_data` snapshot it is about to write. Those two can + # disagree whenever the cache is stale or the profile path + # changed, and ambient/global resolution while holding the + # non-reentrant _cfg_lock is exactly what the direct lookup below + # exists to avoid. (That qualified form DOES resolve a custom + # entry's own base_url as of the non-active #1806 fix -- the + # reason to stay direct here is the lock/snapshot, not a URL the + # qualified path cannot produce.) Fall back to the bare resolve # only for the unnamed `custom` case, which has no own entry. resolved_base_url = None if provider.startswith("custom:"): diff --git a/api/routes.py b/api/routes.py index 701a51d7..85b404ae 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2970,6 +2970,128 @@ from api.request_diagnostics import RequestDiagnostics from api.system_health import build_system_health_payload +# ── Non-streaming custom-provider connection authority ─────────────────────── +# +# Several WebUI consumers build their own AIAgent outside the streaming path +# (POST /api/chat, manual compression, update summary, git commit message, +# handoff summary). They resolve a model/provider, ask the Hermes runtime +# provider for a connection, then had to apply the named ``custom:`` +# record's authority by hand. +# +# ``apply_custom_provider_connection_authority`` returns only the THREE +# connection fields, and three fields are not a complete constructor contract: +# AIAgent also takes ``api_mode`` (wire protocol), ``credential_pool`` +# (credential source) and ``acp_command``/``acp_args`` (subprocess transport). +# A consumer that replaced the endpoint and credential while leaving those to +# default — or, worse, to the ambient runtime — built a mixed-authority agent: +# an exact row's ``api_mode: anthropic_messages`` or its own pool never reached +# the constructor at all. These helpers carry the WHOLE bundle instead, exactly +# as ``api/streaming.py`` does for the streaming path. + +# The constructor-routing fields that travel with provider/base_url/api_key as +# one authority. Mirrors ``api.streaming._RUNTIME_BUNDLE_FIELDS`` and +# ``api.config.CUSTOM_CONNECTION_SIDE_FIELDS``. +_AGENT_BUNDLE_SIDE_FIELDS = ("api_mode", "acp_command", "acp_args", "credential_pool") + + +def _resolve_agent_connection_bundle( + resolved_provider, + resolved_api_key, + resolved_base_url, + runtime_provider=None, + *, + lookup_provider=None, +): + """Return the COMPLETE constructor-routing bundle for a non-streaming send. + + Keys: ``provider``, ``base_url``, ``api_key`` plus every field in + :data:`_AGENT_BUNDLE_SIDE_FIELDS`. Pass the whole dict to the constructor + via :func:`_agent_bundle_kwargs` — the endpoint/credential and the + transport/protocol/pool fields are ONE authority. + + ``runtime_provider`` is the dict ``resolve_runtime_provider`` returned. It + matters: the merge seeds the side fields from it and then decides, by + endpoint provenance, whether they are same-authority (keep) or the ambient + provider's (clear). Omitting it silently drops that signal. + + ``lookup_provider`` preserves the pre-canonicalization ``custom:`` + identity, since the merge rewrites a resolved bundle's provider to the + generic ``custom``. + + Raises :class:`api.config.CustomProviderRouteError` when the merge returns a + TERMINAL route verdict — a named ``custom:`` that resolved no complete + ``(api_key, base_url)`` pair. This is the single chokepoint for every + non-streaming and auxiliary consumer precisely because an incomplete bundle + is NOT a refusal at the constructor: AIAgent's ``_init_openai_client()`` + only honours an explicit pair when BOTH fields are truthy and otherwise + calls ``_routed_client_kwargs()``, which re-resolves a provider and can + reach the ambient endpoint or the init-time fallback chain. Returning the + bundle with a hole in it would therefore route the send somewhere the user + never asked for; raising here keeps the refusal terminal for all five call + sites (POST /api/chat, manual compression, update summary, git commit + message, handoff summary) without each having to remember to check. + + The exception subclasses ``ValueError``, so the existing ``except + ValueError`` / broad-``except`` handlers at those call sites already turn it + into a controlled 400 or a deterministic non-LLM fallback. + """ + return api_config.raise_for_custom_provider_route( + api_config.merge_custom_provider_runtime_bundle( + resolved_provider, + resolved_api_key, + resolved_base_url, + runtime_provider, + lookup_provider=lookup_provider or resolved_provider, + ) + ) + + +def _agent_bundle_kwargs(agent_cls, bundle): + """Return the bundle's side-field kwargs supported by ``agent_cls``. + + ``api_mode``/``acp_command``/``acp_args``/``credential_pool`` were added to + AIAgent over several releases, so gate each on the constructor signature the + way the streaming path does rather than raising TypeError against an older + hermes-agent build. Values come from the BUNDLE, never from the runtime + provider dict: a custom-provider override clears these, and reading them off + the runtime would re-introduce the authority the merge just replaced. + """ + import inspect as _inspect + + try: + params = set(_inspect.signature(agent_cls.__init__).parameters) + except (TypeError, ValueError): + return {} + return { + field: bundle[field] + for field in _AGENT_BUNDLE_SIDE_FIELDS + if field in params + } + + +def _auxiliary_main_runtime(bundle, model): + """Return the ``main_runtime`` an auxiliary client must receive for a bundle. + + When the auxiliary client answers, AIAgent is never built, so this dict is + the ONLY place the resolved authority reaches the wire. It therefore carries + the same whole bundle :func:`_agent_bundle_kwargs` hands the constructor — + endpoint and credential plus every field in + :data:`_AGENT_BUNDLE_SIDE_FIELDS`. Sending only provider/model/base_url/ + api_key silently downgraded an exact row's ``api_mode`` + (``anthropic_messages`` fell back to chat completions) and dropped the + credential pool/ACP transport that belong to the same record. + """ + runtime = { + "provider": bundle["provider"], + "model": model, + "base_url": bundle["base_url"], + "api_key": bundle["api_key"], + } + for field in _AGENT_BUNDLE_SIDE_FIELDS: + runtime[field] = bundle[field] + return runtime + + def _kanban_unknown_endpoint(handler, parsed, method: str) -> bool: """Return a Kanban-specific 404 for stale clients/obsolete endpoint shapes.""" return bad( @@ -14907,6 +15029,96 @@ def _resolve_new_session_workspace(body, visible_prev_session_id, profile=None): ) return str(workspace) + +def _llm_update_summary(system_prompt: str, user_prompt: str, active_profile: str | None = None) -> str: + from api import profiles as profiles_api + + profile = active_profile or profiles_api.get_active_profile_name() or "default" + + with profiles_api.profile_env_for_background_worker( + profile, + "update summary", + logger_override=logger, + ): + from api.config import ( + get_effective_default_model, + resolve_model_provider, + ) + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + _main_model, _main_provider, _main_base_url = resolve_model_provider(get_effective_default_model()) + _main_api_key = None + _rt = None + try: + from api.oauth import resolve_runtime_provider_with_anthropic_env_lock + from hermes_cli.runtime_provider import resolve_runtime_provider + + _rt = resolve_runtime_provider_with_anthropic_env_lock( + resolve_runtime_provider, + requested=_main_provider, + ) + _main_api_key = _rt.get("api_key") + if not _main_provider: + _main_provider = _rt.get("provider") + if not _main_base_url: + _main_base_url = _rt.get("base_url") + except Exception as _e: + logger.debug("update summary runtime provider resolution failed: %s", _e) + # Atomic custom-provider authority (see the /api/chat note): the record + # that supplies the endpoint must also supply the credential — and the + # wire protocol, credential pool and ACP transport that go with it. + _bundle = _resolve_agent_connection_bundle( + _main_provider, _main_api_key, _main_base_url, _rt + ) + _main_provider = _bundle["provider"] + _main_api_key = _bundle["api_key"] + _main_base_url = _bundle["base_url"] + + main_runtime = _auxiliary_main_runtime(_bundle, _main_model) + + ensure_agent_runtime_current() + try: + from agent.auxiliary_client import get_text_auxiliary_client + + aux_client, aux_model = get_text_auxiliary_client( + "compression", + main_runtime=main_runtime, + ) + if aux_client is not None and aux_model: + response = aux_client.chat.completions.create( + model=aux_model, + messages=messages, + ) + return str(response.choices[0].message.content or "").strip() + except Exception as _e: + logger.debug("update summary auxiliary model failed; falling back to main model: %s", _e) + + AIAgent = require_ai_agent_class() + + agent = AIAgent( + model=_main_model, + provider=_main_provider, + base_url=_main_base_url, + api_key=_main_api_key, + platform="webui", + quiet_mode=True, + enabled_toolsets=[], + session_id=f"updates-summary-{uuid.uuid4().hex[:8]}", + **_agent_bundle_kwargs(AIAgent, _bundle), + ) + result = agent.run_conversation( + user_message=user_prompt, + system_message=system_prompt, + conversation_history=[], + task_id=f"updates-summary-{uuid.uuid4().hex[:8]}", + ) + return str(result.get("final_response") or "").strip() + + def handle_post(handler, parsed) -> bool: """Handle all POST routes. Returns True if handled, False for 404.""" diag = RequestDiagnostics.maybe_start("POST", parsed.path, logger=logger, print_fn=getattr(handler, '_safe_webui_print', None)) @@ -17369,99 +17581,6 @@ def handle_post(handler, parsed) -> bool: updates = body.get("updates") if isinstance(body, dict) else {} target = body.get("target") if isinstance(body, dict) else None - def _llm_update_summary(system_prompt: str, user_prompt: str) -> str: - from api import profiles as profiles_api - - active_profile = profiles_api.get_active_profile_name() or "default" - - with profiles_api.profile_env_for_background_worker( - active_profile, - "update summary", - logger_override=logger, - ): - from api.config import ( - get_effective_default_model, - resolve_model_provider, - resolve_custom_provider_connection, - ) - - messages = [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ] - - _main_model, _main_provider, _main_base_url = resolve_model_provider(get_effective_default_model()) - _main_api_key = None - try: - from api.oauth import resolve_runtime_provider_with_anthropic_env_lock - from hermes_cli.runtime_provider import resolve_runtime_provider - - _rt = resolve_runtime_provider_with_anthropic_env_lock( - resolve_runtime_provider, - requested=_main_provider, - ) - _main_api_key = _rt.get("api_key") - if not _main_provider: - _main_provider = _rt.get("provider") - if not _main_base_url: - _main_base_url = _rt.get("base_url") - except Exception as _e: - logger.debug("update summary runtime provider resolution failed: %s", _e) - if isinstance(_main_provider, str) and _main_provider.startswith("custom:"): - _cp_key, _cp_base = resolve_custom_provider_connection(_main_provider) - if not _main_api_key and _cp_key: - _main_api_key = _cp_key - if not _main_base_url and _cp_base: - _main_base_url = _cp_base - - main_runtime = { - "provider": _main_provider, - "model": _main_model, - "base_url": _main_base_url, - "api_key": _main_api_key, - } - - ensure_agent_runtime_current() - try: - from agent.auxiliary_client import get_text_auxiliary_client - - # Update summaries are a short text-compression/summarization task. - # Reuse the documented auxiliary.compression slot instead of - # inventing a WebUI-only auxiliary task name that users cannot - # discover in the Hermes Agent setup/config UI. - aux_client, aux_model = get_text_auxiliary_client( - "compression", - main_runtime=main_runtime, - ) - if aux_client is not None and aux_model: - response = aux_client.chat.completions.create( - model=aux_model, - messages=messages, - ) - return str(response.choices[0].message.content or "").strip() - except Exception as _e: - logger.debug("update summary auxiliary model failed; falling back to main model: %s", _e) - - AIAgent = require_ai_agent_class() - - agent = AIAgent( - model=_main_model, - provider=_main_provider, - base_url=_main_base_url, - api_key=_main_api_key, - platform="webui", - quiet_mode=True, - enabled_toolsets=[], - session_id=f"updates-summary-{uuid.uuid4().hex[:8]}", - ) - result = agent.run_conversation( - user_message=user_prompt, - system_message=system_prompt, - conversation_history=[], - task_id=f"updates-summary-{uuid.uuid4().hex[:8]}", - ) - return str(result.get("final_response") or "").strip() - return j(handler, summarize_update_payload(updates, llm_callback=_llm_update_summary, target=target)) # ── CLI session import (POST) ── @@ -24525,16 +24644,14 @@ def _handle_chat_sync(handler, body): AIAgent = require_ai_agent_class() with CHAT_LOCK: - from api.config import ( - resolve_model_provider, - resolve_custom_provider_connection, - ) + from api.config import resolve_model_provider _model, _provider, _base_url = resolve_model_provider( model_with_provider_context(s.model, getattr(s, "model_provider", None)) ) # Resolve API key via Hermes runtime provider (matches gateway behaviour) _api_key = None + _rt = None try: from api.oauth import resolve_runtime_provider_with_anthropic_env_lock from hermes_cli.runtime_provider import resolve_runtime_provider @@ -24554,12 +24671,35 @@ def _handle_chat_sync(handler, body): f"[webui] WARNING: resolve_runtime_provider failed: {_e}", flush=True, ) - if isinstance(_provider, str) and _provider.startswith("custom:"): - _cp_key, _cp_base = resolve_custom_provider_connection(_provider) - if not _api_key and _cp_key: - _api_key = _cp_key - if not _base_url and _cp_base: - _base_url = _cp_base + # Apply the named custom provider's OWN record atomically, as one + # COMPLETE bundle. The fill-only form this replaced kept a truthy + # runtime value, so a slug present in BOTH custom_providers[] and + # providers: sent the list row's URL with the keyed row's API key; + # the connection-only view that followed still truncated the record's + # api_mode / credential_pool / ACP transport before the constructor. + try: + _bundle = _resolve_agent_connection_bundle( + _provider, _api_key, _base_url, _rt + ) + except api_config.CustomProviderRouteError as _route_err: + # The named route resolved no usable connection. Constructing + # AIAgent with the incomplete pair would send this turn through + # ``_routed_client_kwargs()`` to whatever provider init resolves + # next, so answer with the actionable cause instead. 400, not + # 500: it is a user-fixable provider misconfiguration, exactly + # like the ambiguous-slug collision. + logger.warning( + "Chat blocked by unroutable custom provider: %s", _route_err.message + ) + return j(handler, { + "error": _route_err.message, + "type": "custom_provider_unroutable", + "reason": _route_err.reason, + "hint": _route_err.hint, + }, status=400) + _provider = _bundle["provider"] + _api_key = _bundle["api_key"] + _base_url = _bundle["base_url"] agent = AIAgent( model=_model, provider=_provider, @@ -24571,6 +24711,7 @@ def _handle_chat_sync(handler, body): quiet_mode=True, enabled_toolsets=_resolve_cli_toolsets(), session_id=s.session_id, + **_agent_bundle_kwargs(AIAgent, _bundle), ) from api.streaming import ( _WEBUI_PROGRESS_PROMPT, @@ -25138,7 +25279,6 @@ def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None) from api.config import ( get_effective_default_model, model_with_provider_context, - resolve_custom_provider_connection, resolve_model_provider, ) @@ -25151,6 +25291,7 @@ def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None) ) _main_model, _main_provider, _main_base_url = resolve_model_provider(model_for_resolution) _main_api_key = None + _rt = None try: from api.oauth import resolve_runtime_provider_with_anthropic_env_lock from hermes_cli.runtime_provider import resolve_runtime_provider @@ -25166,23 +25307,21 @@ def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None) _main_base_url = _rt.get("base_url") except Exception as _e: logger.debug("git commit message runtime provider resolution failed: %s", _e) - if isinstance(_main_provider, str) and _main_provider.startswith("custom:"): - _cp_key, _cp_base = resolve_custom_provider_connection(_main_provider) - if not _main_api_key and _cp_key: - _main_api_key = _cp_key - if not _main_base_url and _cp_base: - _main_base_url = _cp_base + # Atomic custom-provider authority (see the /api/chat note): the record + # that supplies the endpoint must also supply the credential — and the + # wire protocol, credential pool and ACP transport that go with it. + _bundle = _resolve_agent_connection_bundle( + _main_provider, _main_api_key, _main_base_url, _rt + ) + _main_provider = _bundle["provider"] + _main_api_key = _bundle["api_key"] + _main_base_url = _bundle["base_url"] messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ] - main_runtime = { - "provider": _main_provider, - "model": _main_model, - "base_url": _main_base_url, - "api_key": _main_api_key, - } + main_runtime = _auxiliary_main_runtime(_bundle, _main_model) ensure_agent_runtime_current() try: from agent.auxiliary_client import get_text_auxiliary_client @@ -25211,6 +25350,7 @@ def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None) quiet_mode=True, enabled_toolsets=[], session_id=f"git-commit-message-{uuid.uuid4().hex[:8]}", + **_agent_bundle_kwargs(AIAgent, _bundle), ) result = agent.run_conversation( user_message=user_prompt, @@ -27230,6 +27370,7 @@ def _handle_session_compress(handler, body): ) resolved_api_key = None + _rt = None try: _rt = resolve_runtime_provider_with_anthropic_env_lock( _runtime_provider.resolve_runtime_provider, @@ -27243,12 +27384,16 @@ def _handle_session_compress(handler, body): except Exception as _e: logger.warning("resolve_runtime_provider failed for compression: %s", _e) - if isinstance(resolved_provider, str) and resolved_provider.startswith("custom:"): - _cp_key, _cp_base = _cfg.resolve_custom_provider_connection(resolved_provider) - if not resolved_api_key and _cp_key: - resolved_api_key = _cp_key - if not resolved_base_url and _cp_base: - resolved_base_url = _cp_base + # Atomic custom-provider authority: the deterministic list-row URL must + # not be paired with a keyed/runtime API key (see the /api/chat note), + # and the record's own api_mode / credential_pool / ACP transport must + # reach the constructor with it rather than being truncated away. + _bundle = _resolve_agent_connection_bundle( + resolved_provider, resolved_api_key, resolved_base_url, _rt + ) + resolved_provider = _bundle["provider"] + resolved_api_key = _bundle["api_key"] + resolved_base_url = _bundle["base_url"] if not resolved_api_key: return bad(handler, "No provider configured -- cannot compress.") @@ -27277,6 +27422,7 @@ def _handle_session_compress(handler, body): quiet_mode=True, enabled_toolsets=_resolve_cli_toolsets(), session_id=sid, + **_agent_bundle_kwargs(AIAgent, _bundle), ) compressed = agent.context_compressor.compress( original_messages, @@ -27905,7 +28051,7 @@ def _handle_handoff_summary(handler, body): # of the resolve_model_provider fix). model_with_provider_context # encodes it as @custom:A:model so the resolver honors the session's # endpoint; base_url is backfilled from that provider's own custom - # entry by the resolve_custom_provider_connection block below. + # entry by the custom-provider authority block below. session_model_provider = getattr(s_obj, "model_provider", None) except Exception: pass @@ -27916,6 +28062,7 @@ def _handle_handoff_summary(handler, body): resolved_model, resolved_provider, resolved_base_url = _cfg.resolve_model_provider(model_for_resolution) resolved_api_key = None + _rt = None try: _rt = resolve_runtime_provider_with_anthropic_env_lock( _runtime_provider.resolve_runtime_provider, @@ -27929,12 +28076,16 @@ def _handle_handoff_summary(handler, body): except Exception as _e: logger.warning("resolve_runtime_provider failed for handoff summary: %s", _e) - if isinstance(resolved_provider, str) and resolved_provider.startswith("custom:"): - _cp_key, _cp_base = _cfg.resolve_custom_provider_connection(resolved_provider) - if not resolved_api_key and _cp_key: - resolved_api_key = _cp_key - if not resolved_base_url and _cp_base: - resolved_base_url = _cp_base + # Atomic custom-provider authority (see the /api/chat note): the session's + # own custom_providers[] row owns BOTH the endpoint and the credential, + # plus the api_mode / credential_pool / ACP transport that travel with + # them as one constructor bundle. + _bundle = _resolve_agent_connection_bundle( + resolved_provider, resolved_api_key, resolved_base_url, _rt + ) + resolved_provider = _bundle["provider"] + resolved_api_key = _bundle["api_key"] + resolved_base_url = _bundle["base_url"] if not resolved_api_key: summary_text = _fallback_handoff_summary(msgs) @@ -27965,6 +28116,7 @@ def _handle_handoff_summary(handler, body): quiet_mode=True, enabled_toolsets=[], session_id=sid, + **_agent_bundle_kwargs(AIAgent, _bundle), ) summary_system_prompt = ( diff --git a/api/streaming.py b/api/streaming.py index ba2223fd..beab649b 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -45,6 +45,13 @@ from api.config import ( SESSION_AGENT_LOCKS, SESSION_AGENT_LOCKS_LOCK, resolve_model_provider, resolve_custom_provider_connection, + apply_custom_provider_connection_authority, + merge_custom_provider_runtime_bundle, + CustomProviderRouteError, + CUSTOM_ROUTE_NO_CREDENTIAL, + CUSTOM_ROUTE_NO_ENDPOINT, + custom_provider_route_error, + raise_for_custom_provider_route, model_with_provider_context, warm_models_catalog_provenance_if_cold, load_settings, @@ -271,7 +278,6 @@ def _cancel_event_payload( # (api/profiles.py:715). _ENV_LOCK = threading.Lock() -_KEYLESS_CUSTOM_API_KEY = "dummy-key" _STREAM_WRITEBACK_DIAG_DEFAULT_THRESHOLD_MS = 250.0 _STREAMING_CRON_PROFILE_HOME: contextvars.ContextVar[str | None] = contextvars.ContextVar( @@ -570,50 +576,121 @@ def _apply_profile_home_context_to_streaming_model( return model, provider_context, False -def _resolve_custom_provider_runtime_overrides( +def _resolve_custom_provider_connection_authority( resolved_provider: str | None, resolved_api_key: str | None, resolved_base_url: str | None, profile_name: str | None = None, -) -> tuple[str | None, str | None, str | None]: - """Return provider/key/base_url overrides for ``custom:*`` endpoints. + custom_provider_lookup: str | None = None, +) -> tuple[str | None, str | None, str | None, bool]: + """Return ``(provider, api_key, base_url, custom_owned)`` for ``custom:*``. - Hermes Agent treats named custom providers as routing hints around an - OpenAI-compatible base URL. Local OpenAI-compatible servers often run - without authentication, so a missing key should not fail before the first - request; pass a harmless placeholder to the SDK and let the endpoint accept - it or return its own auth error. + Thin profile-scoped wrapper around + :func:`api.config.apply_custom_provider_connection_authority`, which holds the + atomic-replacement rules (exact ``custom_providers[]`` row owns BOTH the + endpoint and the credential; keyed/``model:`` fallbacks only fill gaps, as one + same-record bundle). - ``profile_name`` binds the SESSION's own profile while - ``resolve_custom_provider_connection`` reads the endpoint AND the credential. - That helper resolves both from ONE ``get_config()``/env snapshot, so binding - the profile here keeps them from splitting across profiles: on a detached - worker thread (which doesn't inherit the request-profile TLS/env) an unbound - call would read the DEFAULT profile and pair a named profile's endpoint with - the default profile's API key (finding #3). No-op for the default/root - profile; when ``profile_name`` is None the ambient scope (if any) is used. + ``profile_name`` binds the SESSION's own profile while the connection is + resolved. That lookup reads the endpoint AND the credential from ONE + ``get_config()``/env snapshot, so binding the profile here keeps them from + splitting across profiles: on a detached worker thread (which doesn't inherit + the request-profile TLS/env) an unbound call would read the DEFAULT profile + and pair a named profile's endpoint with the default profile's API key + (finding #3). No-op for the default/root profile; when ``profile_name`` is + None the ambient scope (if any) is used. + + ``custom_provider_lookup`` carries the session's pre-canonicalization + identity, so a provider already rewritten to plain ``"custom"`` by an earlier + resolve still selects its own named record on a retry. + + ``custom_owned`` is True when a config-owned custom record supplied the + connection; see :func:`_resolve_runtime_connection_bundle` for why callers + must then clear the runtime-owned side fields. """ - if not (isinstance(resolved_provider, str) and resolved_provider.startswith("custom:")): - return resolved_provider, resolved_api_key, resolved_base_url + lookup_provider = custom_provider_lookup or resolved_provider + if not (isinstance(lookup_provider, str) and lookup_provider.startswith("custom:")): + return resolved_provider, resolved_api_key, resolved_base_url, False from api import profiles as _profiles_api with _profiles_api.profile_scope_for_detached_worker( profile_name, "custom provider connection", logger_override=logger ): - _cp_key, _cp_base = resolve_custom_provider_connection(resolved_provider) - if not resolved_api_key and _cp_key: - resolved_api_key = _cp_key - if not resolved_base_url and _cp_base: - resolved_base_url = _cp_base - if resolved_base_url: - # Route through the generic custom OpenAI-compatible client once the - # named provider has supplied the concrete endpoint. Keeping the - # provider as custom: would make Agent init synthesize invalid - # env-var hints like CUSTOM:SOMETHING-8000_API_KEY on keyless setups. - resolved_provider = "custom" - if not resolved_api_key: - resolved_api_key = _KEYLESS_CUSTOM_API_KEY - return resolved_provider, resolved_api_key, resolved_base_url + return apply_custom_provider_connection_authority( + resolved_provider, + resolved_api_key, + resolved_base_url, + lookup_provider=lookup_provider, + # Pass THIS module's binding so tests (and any future shim) that patch + # ``streaming.resolve_custom_provider_connection`` still take effect. + connection_resolver=resolve_custom_provider_connection, + ) + + +def _resolve_custom_provider_runtime_overrides( + resolved_provider: str | None, + resolved_api_key: str | None, + resolved_base_url: str | None, + profile_name: str | None = None, + custom_provider_lookup: str | None = None, +) -> tuple[str | None, str | None, str | None]: + """Return provider/key/base_url overrides for ``custom:*`` endpoints. + + Legacy three-value view of + :func:`_resolve_custom_provider_connection_authority`. Prefer + :func:`_resolve_runtime_connection_bundle` in agent-construction paths: the + three fields alone are NOT a complete constructor bundle, so a caller that + keeps the ambient runtime's ``credential_pool`` / ``api_mode`` / ACP + command+args beside them still sends a mixed-authority agent to the custom + endpoint. + """ + return _resolve_custom_provider_connection_authority( + resolved_provider, + resolved_api_key, + resolved_base_url, + profile_name=profile_name, + custom_provider_lookup=custom_provider_lookup, + )[:3] + + +# The constructor-routing fields AIAgent takes from the resolved runtime +# provider. They travel together: a bundle that replaces the endpoint/credential +# but leaves these behind builds an agent whose transport (ACP subprocess), +# wire protocol (api_mode) or credential source (pool) still points at the +# previous authority. +_RUNTIME_BUNDLE_FIELDS = ('api_mode', 'acp_command', 'acp_args', 'credential_pool') + + +def _resolve_runtime_connection_bundle( + resolved_provider: str | None, + resolved_api_key: str | None, + resolved_base_url: str | None, + runtime_provider: dict | None, + profile_name: str | None = None, + custom_provider_lookup: str | None = None, +) -> dict: + """Return the COMPLETE constructor-routing bundle for one send attempt. + + Keys: ``provider``, ``base_url``, ``api_key`` plus every field in + :data:`_RUNTIME_BUNDLE_FIELDS`. Callers must apply the whole dict — that is + the point: the endpoint/credential and the transport/protocol/pool fields + are one authority, and the agent-cache signature must be derived from the + same dict so a bundle change always mints a new agent. + """ + lookup_provider = custom_provider_lookup or resolved_provider + from api import profiles as _profiles_api + + with _profiles_api.profile_scope_for_detached_worker( + profile_name, "custom provider connection", logger_override=logger + ): + return merge_custom_provider_runtime_bundle( + resolved_provider, + resolved_api_key, + resolved_base_url, + runtime_provider, + lookup_provider=lookup_provider, + connection_resolver=resolve_custom_provider_connection, + ) def _same_base_url_endpoint(url_a: str, url_b: str) -> bool: @@ -645,6 +722,7 @@ def _runtime_preferred_base_url( runtime_provider: dict | None, resolved_provider: str | None, configured_base_url: str | None, + session_requested_provider: str | None = None, ) -> str | None: """Prefer the runtime-normalized base_url, but never override an explicit configured endpoint that points somewhere genuinely different. @@ -670,7 +748,8 @@ def _runtime_preferred_base_url( return runtime_base_url provider_id = str( - resolved_provider + session_requested_provider + or resolved_provider or (runtime_provider or {}).get("provider") or "" ).strip().lower() @@ -1429,6 +1508,41 @@ def _result_reports_compression_snapshot_stale(result) -> bool: ) +def _custom_provider_route_classification(error) -> dict: + """WebUI apperror classification for a terminal custom-provider route. + + ``error`` is either a :class:`CustomProviderRouteError` or the verdict dict + :func:`custom_provider_route_error` read off a bundle — the retry paths hold + the verdict without ever raising, so both shapes classify identically. + """ + if isinstance(error, dict): + reason = error.get('reason') + hint = error.get('hint') or '' + message = error.get('message') or '' + else: + reason = getattr(error, 'reason', None) + hint = getattr(error, 'hint', '') or '' + message = getattr(error, 'message', None) or str(error) + if reason == CUSTOM_ROUTE_NO_CREDENTIAL: + label = 'Provider credential unavailable' + elif reason == CUSTOM_ROUTE_NO_ENDPOINT: + label = 'Provider endpoint unavailable' + else: + label = 'Provider not configured' + return { + 'label': label, + 'type': 'provider_unroutable', + # The STRUCTURED verdict (``custom_provider_endpoint_unresolved`` / + # ``custom_provider_no_credential``) travels beside the prose so the + # emitted apperror can carry it too. ``type`` alone says only "this route + # is unroutable"; which of the two settings to fix lives here, and the + # non-streaming /api/chat refusal already answers it as ``reason``. + 'reason': reason, + 'hint': hint, + 'message': message, + } + + def _classify_provider_error( err_str: str, exc=None, @@ -1451,6 +1565,12 @@ def _classify_provider_error( err_str = str(_probe_text or err_str or '') _err_lower = err_str.lower() _exc_name = type(exc).__name__ if exc is not None else '' + if isinstance(exc, CustomProviderRouteError): + # An unroutable named ``custom:`` route. Classified BEFORE any text + # matching so it can never be read as a 401 — the auth branch would run + # credential self-heal, which re-resolves a provider and is exactly the + # re-routing this verdict exists to prevent. + return _custom_provider_route_classification(exc) _result_is_compression_snapshot_stale = ( _result_reports_compression_snapshot_stale(result) ) @@ -8745,6 +8865,54 @@ def _agent_cache_api_key_sig(resolved_api_key, credential_pool) -> str: return _hashlib.sha256((resolved_api_key or '').encode()).hexdigest()[:16] +def _compute_agent_cache_signature( + resolved_model: str | None, + resolved_api_key: str | None, + resolved_base_url: str | None, + resolved_provider: str | None, + runtime_bundle: dict | None, + max_iterations_cfg=None, + max_tokens_cfg=None, + fallback_resolved=None, + toolsets=None, + reasoning_config=None, + main_request_overrides=None, + _main_request_overrides=None, + prefill_context=None, + profile_home: str | None = None, + safe_profile_runtime_env: dict | None = None, +) -> str: + """Return the SHA256 signature identifying an agent's constructor routing state.""" + import hashlib as _hashlib + import json as _json + _bundle = runtime_bundle if isinstance(runtime_bundle, dict) else {} + _credential_pool = _bundle.get('credential_pool') + _env = safe_profile_runtime_env if isinstance(safe_profile_runtime_env, dict) else {} + _main_request_overrides = main_request_overrides if main_request_overrides is not None else _main_request_overrides + _sig_blob = _json.dumps([ + resolved_model or '', + _agent_cache_api_key_sig(resolved_api_key, _credential_pool), + resolved_base_url or '', + resolved_provider or '', + _bundle.get('api_mode') or '', + _bundle.get('acp_command') or '', + _bundle.get('acp_args') or [], + bool(_credential_pool), + max_iterations_cfg or '', + max_tokens_cfg or '', + fallback_resolved or {}, + sorted(toolsets) if toolsets else [], + reasoning_config or {}, + _main_request_overrides or {}, + _public_prefill_context_status(prefill_context), + profile_home or '', + _env.get('TERMINAL_ENV', '') or '', + _env.get('TERMINAL_SSH_HOST', '') or '', + _env.get('TERMINAL_SSH_USER', '') or '', + ], sort_keys=True) + return _hashlib.sha256(_sig_blob.encode()).hexdigest()[:16] + + def _lifecycle_commit_session_memory(session_id: str, *, agent=None, wait: bool = False) -> bool: from api.session_lifecycle import commit_session_memory @@ -10348,6 +10516,9 @@ def _run_agent_streaming( # Resolve API key via Hermes runtime provider (matches gateway behaviour). # Pass the resolved provider so non-default providers get their own credentials. resolved_api_key = None + # Default to an empty runtime dict so the constructor-routing + # bundle below stays buildable when resolution raises. + _rt = {} try: from api.oauth import resolve_runtime_provider_with_anthropic_env_lock from hermes_cli.runtime_provider import resolve_runtime_provider @@ -10371,11 +10542,38 @@ def _run_agent_streaming( # Preserve the pre-canonicalization identity so image routing can # still select the exact custom_providers entry after the rewrite # to "custom" below. + # + # Resolve ONE complete constructor-routing bundle here and let + # every downstream field (including the agent-cache signature) + # read from it. Resolving only provider/key/base_url left the + # runtime-owned credential_pool / api_mode / ACP command+args + # beside a replaced custom endpoint, so the agent was built with + # a mixed authority: the list row's URL and key, but the ambient + # provider's transport, wire protocol and credential source. _session_requested_provider = resolved_provider - resolved_provider, resolved_api_key, resolved_base_url = _resolve_custom_provider_runtime_overrides( - resolved_provider, resolved_api_key, resolved_base_url, + _runtime_bundle = _resolve_runtime_connection_bundle( + resolved_provider, resolved_api_key, resolved_base_url, _rt, profile_name=_resolved_profile_name, + custom_provider_lookup=_session_requested_provider, ) + # Stop HERE on a terminal route verdict — before the agent + # kwargs, before _AIAgent(), before the agent-cache write. A + # named custom: that resolved no complete + # (api_key, base_url) pair is not "fail closed" at the + # constructor: AIAgent honours an explicit pair only when BOTH + # are truthy and otherwise calls _routed_client_kwargs(), which + # re-resolves a provider and can land on the ambient endpoint or + # the init-time fallback chain. Raising sends this turn to the + # outer handler, which classifies it via + # _custom_provider_route_classification() and emits a controlled + # provider / missing-credential error — and, because we never + # reach SESSION_AGENT_CACHE, the cache is never poisoned with an + # agent built on an unroutable bundle that later turns would + # silently reuse. + raise_for_custom_provider_route(_runtime_bundle) + resolved_provider = _runtime_bundle['provider'] + resolved_api_key = _runtime_bundle['api_key'] + resolved_base_url = _runtime_bundle['base_url'] # Read per-profile config at call time (not module-level snapshot). # The streaming worker is a detached thread that does NOT inherit the @@ -10582,15 +10780,18 @@ def _run_agent_streaming( _agent_kwargs['max_tokens'] = _max_tokens_cfg if 'request_overrides' in _agent_params and _main_request_overrides: _agent_kwargs['request_overrides'] = _main_request_overrides - # Params added in newer hermes-agent — skip if not supported + # Params added in newer hermes-agent — skip if not supported. + # Read from the resolved bundle, NOT from _rt: a custom-provider + # override clears these, and taking them straight off the runtime + # provider would re-introduce the authority it replaced. if 'api_mode' in _agent_params: - _agent_kwargs['api_mode'] = _rt.get('api_mode') + _agent_kwargs['api_mode'] = _runtime_bundle['api_mode'] if 'acp_command' in _agent_params: - _agent_kwargs['acp_command'] = _rt.get('command') + _agent_kwargs['acp_command'] = _runtime_bundle['acp_command'] if 'acp_args' in _agent_params: - _agent_kwargs['acp_args'] = _rt.get('args') + _agent_kwargs['acp_args'] = _runtime_bundle['acp_args'] if 'credential_pool' in _agent_params: - _agent_kwargs['credential_pool'] = _rt.get('credential_pool') + _agent_kwargs['credential_pool'] = _runtime_bundle['credential_pool'] # Pin Honcho memory sessions to the stable WebUI session ID. # Without this, 'per-session' Honcho strategy creates a new Honcho # session on every streaming request because HonchoSessionManager is @@ -10605,41 +10806,26 @@ def _run_agent_streaming( agent = _AIAgent(**_agent_kwargs) logger.debug('[webui] Created ephemeral agent for session %s', session_id) else: - import hashlib as _hashlib - import json as _json from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK - _credential_pool = _rt.get('credential_pool') - _sig_blob = _json.dumps([ - resolved_model or '', - _agent_cache_api_key_sig(resolved_api_key, _credential_pool), - resolved_base_url or '', - resolved_provider or '', - _rt.get('api_mode') or '', - _rt.get('command') or '', - _rt.get('args') or [], - bool(_credential_pool), - _max_iterations_cfg or '', - _max_tokens_cfg or '', - _fallback_resolved or {}, - sorted(_toolsets) if _toolsets else [], - _reasoning_config or {}, - _main_request_overrides or {}, - _public_prefill_context_status(_prefill_context), - # #1897: profile_home is part of the agent's identity because - # AIAgent caches `_cached_system_prompt` from `load_soul_md()` - # at construction time, sourced from HERMES_HOME. Same-session - # profile switches keep `session_id` stable, so without this - # field the cached agent silently retains the previous - # profile's SOUL.md (and any other profile-scoped context). - _profile_home or '', - # Terminal backend identity: sessions switching between - # remote (SSH) and local backends must not reuse a cached - # agent that carries stale terminal env vars (#5937). - _safe_profile_runtime_env.get('TERMINAL_ENV', '') or '', - _safe_profile_runtime_env.get('TERMINAL_SSH_HOST', '') or '', - _safe_profile_runtime_env.get('TERMINAL_SSH_USER', '') or '', - ], sort_keys=True) - _agent_sig = _hashlib.sha256(_sig_blob.encode()).hexdigest()[:16] + # Signature fields come from the SAME bundle the constructor + # received, so a cleared/overridden field always mints a new + # agent instead of reusing one built on the prior authority. + _agent_sig = _compute_agent_cache_signature( + resolved_model, + resolved_api_key, + resolved_base_url, + resolved_provider, + _runtime_bundle, + max_iterations_cfg=_max_iterations_cfg, + max_tokens_cfg=_max_tokens_cfg, + fallback_resolved=_fallback_resolved, + toolsets=_toolsets, + reasoning_config=_reasoning_config, + main_request_overrides=_main_request_overrides, + prefill_context=_prefill_context, + profile_home=_profile_home, + safe_profile_runtime_env=_safe_profile_runtime_env, + ) agent = None _identity_mismatch_entry = None @@ -11497,7 +11683,8 @@ def _run_agent_streaming( if not resolved_provider: resolved_provider = _heal_rt.get('provider') resolved_base_url = _runtime_preferred_base_url( - _heal_rt, resolved_provider, configured_base_url + _heal_rt, resolved_provider, configured_base_url, + session_requested_provider=_session_requested_provider, ) # Preserve the session's original pre-canonicalization # provider identity (captured at first resolve) so a @@ -11506,21 +11693,64 @@ def _run_agent_streaming( # (e.g. the provider was first discovered on this heal). if not _session_requested_provider: _session_requested_provider = resolved_provider - resolved_provider, resolved_api_key, resolved_base_url = _resolve_custom_provider_runtime_overrides( - resolved_provider, resolved_api_key, resolved_base_url, + # Rebuild the COMPLETE bundle, not just the three + # connection fields: the self-heal re-resolve can + # return a different runtime authority, and a + # custom-provider override must clear its + # credential_pool / api_mode / ACP fields here too. + _runtime_bundle = _resolve_runtime_connection_bundle( + resolved_provider, resolved_api_key, resolved_base_url, _heal_rt, profile_name=_resolved_profile_name, + custom_provider_lookup=_session_requested_provider, ) + # The re-resolve can turn a previously routable named + # route terminal (the record's key_cmd stopped + # minting, the pool drained, the row was edited + # mid-turn). Abandon the retry BEFORE _AIAgent and + # before the SESSION_AGENT_CACHE write below: healing + # a 401 by handing the constructor an incomplete pair + # is exactly the re-routing this verdict exists to + # stop, and caching that agent would carry it into + # every later turn. Raising reaches the outer handler, + # which emits the controlled provider / + # missing-credential error instead of the 401. + raise_for_custom_provider_route(_runtime_bundle) + resolved_provider = _runtime_bundle['provider'] + resolved_api_key = _runtime_bundle['api_key'] + resolved_base_url = _runtime_bundle['base_url'] # Rebuild agent kwargs and create a fresh agent _agent_kwargs['api_key'] = resolved_api_key _agent_kwargs['base_url'] = resolved_base_url _agent_kwargs['model'] = resolved_model _agent_kwargs['provider'] = resolved_provider _replace_session_db_in_kwargs(_agent_kwargs, _state_db_path) + if 'api_mode' in _agent_params: + _agent_kwargs['api_mode'] = _runtime_bundle['api_mode'] + if 'acp_command' in _agent_params: + _agent_kwargs['acp_command'] = _runtime_bundle['acp_command'] + if 'acp_args' in _agent_params: + _agent_kwargs['acp_args'] = _runtime_bundle['acp_args'] if 'credential_pool' in _agent_params: - _agent_kwargs['credential_pool'] = _heal_rt.get('credential_pool') + _agent_kwargs['credential_pool'] = _runtime_bundle['credential_pool'] agent = _AIAgent(**_agent_kwargs) with STREAMS_LOCK: AGENT_INSTANCES[stream_id] = agent + _agent_sig = _compute_agent_cache_signature( + resolved_model, + resolved_api_key, + resolved_base_url, + resolved_provider, + _runtime_bundle, + max_iterations_cfg=_max_iterations_cfg, + max_tokens_cfg=_max_tokens_cfg, + fallback_resolved=_fallback_resolved, + toolsets=_toolsets, + reasoning_config=_reasoning_config, + main_request_overrides=_main_request_overrides, + prefill_context=_prefill_context, + profile_home=_profile_home, + safe_profile_runtime_env=_safe_profile_runtime_env, + ) from api.config import SESSION_AGENT_CACHE as _SAC, SESSION_AGENT_CACHE_LOCK as _SAC_L with _SAC_L: _SAC[session_id] = (agent, _agent_sig) @@ -12747,9 +12977,24 @@ def _run_agent_streaming( _exc_is_compression_snapshot_stale = ( _classification['type'] == 'compression_snapshot_stale' ) + _exc_is_provider_unroutable = _classification['type'] == 'provider_unroutable' + + # The structured terminal reason for an unroutable named custom route, + # stamped onto the emitted payload below. Kept separate from _exc_type + # because every unroutable route shares one type and differs only here. + _exc_route_reason = None # The user hint still points to Settings / `hermes model` from _classify_provider_error(). - if _exc_is_quota: + if _exc_is_provider_unroutable: + # Checked FIRST so the terminal route verdict can never be flattened + # into the generic 'Error' tail of this chain: its hint names the + # exact provider setting to fix, which is the only actionable thing + # the user gets on an unroutable custom:. + _exc_label, _exc_type, _exc_hint = ( + _classification['label'], _classification['type'], _classification['hint'], + ) + _exc_route_reason = _classification.get('reason') + elif _exc_is_quota: _exc_label, _exc_type, _exc_hint = ( _classification['label'], _classification['type'], _classification['hint'], ) @@ -12763,6 +13008,15 @@ def _run_agent_streaming( ) elif _exc_is_auth: _heal_stale_classification = None + # Set when the self-heal re-resolve produces a TERMINAL route + # verdict. Unlike the two in-flight sites, this branch runs INSIDE + # the outer exception handler, so raising here would escape the + # generator with no error event at all. Record the verdict instead + # and let the abandoned heal fall through to the emission below, + # which prefers this classification over the generic + # "Authentication error" — the 401 is a symptom, the unroutable + # named provider is the cause the user can actually fix. + _heal_route_classification = None if not _self_healed: # ── Credential self-heal on 401 (#1401) ── # Bind the session's profile so the self-heal re-resolve AND the @@ -12786,7 +13040,8 @@ def _run_agent_streaming( if not resolved_provider: resolved_provider = _heal_rt.get('provider') resolved_base_url = _runtime_preferred_base_url( - _heal_rt, resolved_provider, configured_base_url + _heal_rt, resolved_provider, configured_base_url, + session_requested_provider=_session_requested_provider, ) # Preserve the session's original pre-canonicalization provider # identity (captured at first resolve) so a named custom:slug @@ -12794,10 +13049,41 @@ def _run_agent_streaming( # Only initialize when empty. if not _session_requested_provider: _session_requested_provider = resolved_provider - resolved_provider, resolved_api_key, resolved_base_url = _resolve_custom_provider_runtime_overrides( - resolved_provider, resolved_api_key, resolved_base_url, + # Rebuild the COMPLETE bundle (see the returned-error retry + # above): replacing only provider/key/base_url would leave + # the pre-heal credential_pool / api_mode / ACP fields on a + # custom endpoint that owns none of them. + _runtime_bundle = _resolve_runtime_connection_bundle( + resolved_provider, resolved_api_key, resolved_base_url, _heal_rt, profile_name=_resolved_profile_name, + custom_provider_lookup=_session_requested_provider, ) + _heal_route_verdict = custom_provider_route_error(_runtime_bundle) + if _heal_route_verdict is not None: + # The refreshed credential did not produce a routable + # named connection. Abandon the heal BEFORE _AIAgent and + # before the SESSION_AGENT_CACHE write below — building + # here would hand the constructor an incomplete pair and + # let _routed_client_kwargs() pick a provider of its own, + # and caching that agent would keep doing so on every + # later turn in this session. + _heal_route_classification = ( + _custom_provider_route_classification(_heal_route_verdict) + ) + logger.warning( + '[webui] self-heal (except path): abandoning retry — %s', + _heal_route_classification['message'], + ) + # Clearing _heal_rt closes the retry block below. We must + # NOT raise from inside the outer exception handler: that + # would escape the generator and the client would get no + # error event at all. + _heal_rt = None + + if _heal_rt is not None: + resolved_provider = _runtime_bundle['provider'] + resolved_api_key = _runtime_bundle['api_key'] + resolved_base_url = _runtime_bundle['base_url'] # Build a fresh agent with the new credentials _heal_kwargs = dict(_agent_kwargs) if '_agent_kwargs' in dir() else {} _heal_kwargs['api_key'] = resolved_api_key @@ -12805,11 +13091,33 @@ def _run_agent_streaming( _heal_kwargs['model'] = resolved_model _heal_kwargs['provider'] = resolved_provider _replace_session_db_in_kwargs(_heal_kwargs, _state_db_path) + if 'api_mode' in _agent_params: + _heal_kwargs['api_mode'] = _runtime_bundle['api_mode'] + if 'acp_command' in _agent_params: + _heal_kwargs['acp_command'] = _runtime_bundle['acp_command'] + if 'acp_args' in _agent_params: + _heal_kwargs['acp_args'] = _runtime_bundle['acp_args'] if 'credential_pool' in _agent_params: - _heal_kwargs['credential_pool'] = _heal_rt.get('credential_pool') + _heal_kwargs['credential_pool'] = _runtime_bundle['credential_pool'] _heal_agent = _AIAgent(**_heal_kwargs) with STREAMS_LOCK: AGENT_INSTANCES[stream_id] = _heal_agent + _agent_sig = _compute_agent_cache_signature( + resolved_model, + resolved_api_key, + resolved_base_url, + resolved_provider, + _runtime_bundle, + max_iterations_cfg=_max_iterations_cfg, + max_tokens_cfg=_max_tokens_cfg, + fallback_resolved=_fallback_resolved, + toolsets=_toolsets, + reasoning_config=_reasoning_config, + main_request_overrides=_main_request_overrides, + prefill_context=_prefill_context, + profile_home=_profile_home, + safe_profile_runtime_env=_safe_profile_runtime_env, + ) from api.config import SESSION_AGENT_CACHE as _SAC2, SESSION_AGENT_CACHE_LOCK as _SAC2_L with _SAC2_L: _SAC2[session_id] = (_heal_agent, _agent_sig) @@ -12925,7 +13233,19 @@ def _run_agent_streaming( except Exception as _retry_exc2: logger.warning('[webui] self-heal (except path): retry failed: %s', _retry_exc2) # Fall through to emit the original error - if _heal_stale_classification is not None: + if _heal_route_classification is not None: + # The heal was abandoned because the named route is unroutable. + # Report THAT, not the 401 that triggered the heal: telling the + # user to check credentials when the provider resolves no + # endpoint (or declares a credential source that yields nothing) + # sends them to the wrong setting. + _exc_label = _heal_route_classification['label'] + _exc_type = _heal_route_classification['type'] + _exc_hint = _heal_route_classification['hint'] + _exc_route_reason = _heal_route_classification.get('reason') + if _heal_route_classification['message']: + err_str = _heal_route_classification['message'] + elif _heal_stale_classification is not None: _exc_label = _heal_stale_classification['label'] _exc_type = _heal_stale_classification['type'] _exc_hint = _heal_stale_classification['hint'] @@ -12963,6 +13283,10 @@ def _run_agent_streaming( 'The conversation changed while context compression was being prepared.' ) _error_payload = _provider_error_payload(err_str, _exc_type, _exc_hint) + if _exc_route_reason: + # Clients (and regressions) branch on the exact verdict rather than + # on the human-readable message, which is free to be reworded. + _error_payload['reason'] = _exc_route_reason if s is not None: if _checkpoint_stop is not None: _checkpoint_stop.set() diff --git a/tests/test_agent_max_turns_parity.py b/tests/test_agent_max_turns_parity.py index 888e34ed..4665f74f 100644 --- a/tests/test_agent_max_turns_parity.py +++ b/tests/test_agent_max_turns_parity.py @@ -13,6 +13,153 @@ REPO = Path(__file__).resolve().parent.parent STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8") +def _compute_agent_cache_signature_source() -> str: + """Return the source of the `_compute_agent_cache_signature()` helper. + + The cache-signature blob used to be inlined in the streaming send path; it + now lives in this helper so the initial send and both self-heal retry paths + derive the signature from the same final runtime bundle. + """ + start = STREAMING_PY.index("def _compute_agent_cache_signature(") + end = STREAMING_PY.index("\ndef ", start) + return STREAMING_PY[start:end] + + +def _signature_blob() -> str: + """Return the `_json.dumps([...])` field list the signature hashes.""" + helper = _compute_agent_cache_signature_source() + blob_start = helper.index("_sig_blob = _json.dumps") + blob_end = helper.index("], sort_keys=True)", blob_start) + return helper[blob_start:blob_end] + + +def _production_signature_calls() -> list[tuple[int, str]]: + """Return `(offset, source)` for every production signature call site. + + Paren-balanced so the whole multi-line keyword-argument list is captured, + and every live call site is returned so a retry path cannot silently drop a + field that the initial send still passes. Commented-out occurrences are + skipped -- a disabled retry call must read as missing, not as present. + """ + marker = "_agent_sig = _compute_agent_cache_signature(" + calls: list[tuple[int, str]] = [] + pos = STREAMING_PY.find(marker) + while pos != -1: + line_start = STREAMING_PY.rfind("\n", 0, pos) + 1 + if STREAMING_PY[line_start:pos].strip(): + # Commented-out (or otherwise non-statement) occurrence: it no + # longer runs, so it must not count as a live call site. + pos = STREAMING_PY.find(marker, pos + 1) + continue + depth = 0 + for idx in range(pos + len(marker) - 1, len(STREAMING_PY)): + char = STREAMING_PY[idx] + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + calls.append((pos, STREAMING_PY[pos:idx + 1])) + break + else: + raise AssertionError("unterminated _compute_agent_cache_signature( call") + pos = STREAMING_PY.find(marker, pos + 1) + return calls + + +# Lifecycle anchors for the streaming send path. Pinning one signature call to +# each region keeps the oracle honest: a retry call that silently disappears +# fails the region check instead of hiding behind the calls that remain. +INITIAL_SEND_MARKER = "# ── Agent cache: reuse across messages in the same session ──" +RETURNED_ERROR_SELF_HEAL_MARKER = ( + "logger.info('[webui] self-heal: retrying stream after credential refresh')" +) +RAISED_EXCEPTION_SELF_HEAL_MARKER = ( + "logger.info('[webui] self-heal (except path): " + "retrying stream after credential refresh')" +) + +# Each region ends at the cache write that stores the signature it just +# computed. Bounding the region at the write -- rather than at the next +# lifecycle anchor or at EOF -- is what proves the signature is recomputed +# *before* it is cached: a call moved below its own write lands outside the +# region and fails, instead of passing while the write consumes a stale +# `_agent_sig`. +INITIAL_SEND_CACHE_WRITE_MARKER = ( + "SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)" +) +RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER = ( + "_SAC[session_id] = (agent, _agent_sig)" +) +RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER = ( + "_SAC2[session_id] = (_heal_agent, _agent_sig)" +) + + +def _marker_offset(marker: str) -> int: + """Return the single offset of a lifecycle marker in streaming.py.""" + offset = STREAMING_PY.index(marker) + assert STREAMING_PY.find(marker, offset + 1) == -1, ( + "lifecycle marker is no longer unique in streaming.py:\n" + marker + ) + return offset + + +def _signature_calls_by_region( + calls: list[tuple[int, str]], +) -> dict[str, tuple[int, str]]: + """Map each lifecycle region to the single signature call inside it. + + The regions are the initial send, the returned-error self-heal retry and + the raised-exception self-heal retry; each runs from its lifecycle anchor + to the cache write that consumes `_agent_sig`. Requiring exactly one call + inside those bounds means neither a dropped retry call (masked by its + surviving siblings) nor a call recomputed after its own cache write (which + would leave the write storing a stale signature) can read as correct. + """ + initial = _marker_offset(INITIAL_SEND_MARKER) + initial_cache_write = _marker_offset(INITIAL_SEND_CACHE_WRITE_MARKER) + returned_error = _marker_offset(RETURNED_ERROR_SELF_HEAL_MARKER) + returned_error_cache_write = _marker_offset( + RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER + ) + raised_exception = _marker_offset(RAISED_EXCEPTION_SELF_HEAL_MARKER) + raised_exception_cache_write = _marker_offset( + RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER + ) + assert ( + initial + < initial_cache_write + < returned_error + < returned_error_cache_write + < raised_exception + < raised_exception_cache_write + ), ( + "streaming.py lifecycle regions are out of order; these anchors no " + "longer describe the send path" + ) + + bounds = { + "initial send": (initial, initial_cache_write), + "returned-error self-heal": (returned_error, returned_error_cache_write), + "raised-exception self-heal": ( + raised_exception, + raised_exception_cache_write, + ), + } + by_region: dict[str, tuple[int, str]] = {} + for region, (start, end) in bounds.items(): + found = [(offset, call) for offset, call in calls if start < offset < end] + assert len(found) == 1, ( + "expected exactly one _compute_agent_cache_signature() call in the " + f"{region} region of streaming.py -- between its lifecycle anchor " + f"and the cache write that stores `_agent_sig` -- found " + f"{len(found)}" + ) + by_region[region] = found[0] + return by_region + + def test_streaming_agent_reads_agent_max_turns_from_config(): assert "_agent_cfg_for_iterations" in STREAMING_PY assert "_agent_cfg_for_iterations.get('max_turns')" in STREAMING_PY @@ -25,6 +172,19 @@ def test_streaming_agent_passes_max_iterations_to_aiagent(): def test_streaming_agent_cache_signature_includes_max_iterations(): - sig_start = STREAMING_PY.index("_sig_blob = _json.dumps") - sig_block = STREAMING_PY[sig_start:STREAMING_PY.index("], sort_keys=True)", sig_start)] - assert "_max_iterations_cfg or ''" in sig_block + assert "max_iterations_cfg or ''" in _signature_blob(), ( + "_compute_agent_cache_signature() must hash max_iterations_cfg, or a " + "max_turns change reuses the cached agent built on the old budget." + ) + + calls = _production_signature_calls() + assert len(calls) == 3, ( + "expected exactly three _compute_agent_cache_signature() call sites " + "(initial send plus both self-heal retries), found " + f"{len(calls)}" + ) + for region, (_offset, call) in _signature_calls_by_region(calls).items(): + assert "max_iterations_cfg=_max_iterations_cfg" in call, ( + f"the {region} signature call must pass the resolved " + "max_iterations budget:\n" + call + ) diff --git a/tests/test_issue1013_handoff_dock.py b/tests/test_issue1013_handoff_dock.py index f8ca81a0..07af6646 100644 --- a/tests/test_issue1013_handoff_dock.py +++ b/tests/test_issue1013_handoff_dock.py @@ -620,6 +620,25 @@ def test_handoff_summary_codex_output_cap_matches_provider_compatibility( import api.models as models import api.routes as routes + if provider.startswith("custom:"): + # A named ``custom:`` route is identity-owned: the endpoint comes + # from the record that names the slug, and a route no record owns fails + # closed (#1806). ``resolve_model_provider`` only ever returns this + # provider/base_url pair BECAUSE such a row exists, so the fixture must + # carry it — otherwise the handler is handed a connection with no + # authority behind it and falls back before reaching the Codex path. + monkeypatch.setitem( + cfg.cfg, + "custom_providers", + [ + { + "name": provider.split(":", 1)[1], + "base_url": base_url, + "api_key": "handoff-summary-key", + } + ], + ) + if expects_normalized_base_url: real_urlsplit = routes.urlsplit diff --git a/tests/test_issue1806_named_custom_provider_resolution.py b/tests/test_issue1806_named_custom_provider_resolution.py index 8328c33b..d8a7777e 100644 --- a/tests/test_issue1806_named_custom_provider_resolution.py +++ b/tests/test_issue1806_named_custom_provider_resolution.py @@ -8,7 +8,10 @@ model selection and send runtime auth down an impossible env-var path. from __future__ import annotations +import contextlib +import copy import json +import os import sys import types @@ -117,3 +120,5121 @@ def test_available_models_drops_base_url_derived_custom_slug(monkeypatch): named_models = [m["id"] for m in groups_by_id["custom:ollama-local"]["models"]] assert "carnice-9b:latest" in named_models + + +def _with_multi_custom_provider_config(): + """Active custom provider PLUS a second, non-active named custom provider. + + Mirrors the config.yaml shape that has no ``providers:`` map at all: every + endpoint lives in ``custom_providers:``, and only one of them is the active + ``model.provider``. + """ + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + old_path = getattr(config, "_cfg_path", None) + config.cfg.clear() + config.cfg.update( + { + "model": { + "default": "active/model", + "provider": "custom:active", + "base_url": "https://active.example/v1", + "api_key": "active-key", + }, + "custom_providers": [ + { + "name": "active", + "base_url": "https://active.example/v1", + "api_key": "active-key", + }, + { + "name": "omni", + "base_url": "https://omni.example/v1", + "api_key": "omni-key", + }, + ], + } + ) + try: + config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime + except Exception: + config._cfg_mtime = 0.0 + config._cfg_path = config._get_config_path() + + def restore(): + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime + config._cfg_path = old_path + config.invalidate_models_cache() + + return restore + + +def test_provider_qualified_id_uses_nonactive_custom_provider_base_url(): + """``@custom:omni:`` must route to omni's base_url, not the default. + + ``_get_provider_base_url`` only reads ``providers:`` and the ACTIVE + ``model.base_url``, so a named custom provider that lives solely in + ``custom_providers:`` used to resolve to base_url=None. The WebUI then sent + the bare model to the active endpoint and got back HTTP 400 "Invalid model + format or no credentials for provider: ". + """ + restore = _with_multi_custom_provider_config() + try: + model, provider, base_url = config.resolve_model_provider( + "@custom:omni:antigravity/gemini-3.7-flash-tiered" + ) + finally: + restore() + + assert model == "antigravity/gemini-3.7-flash-tiered" + assert provider == "custom:omni" + assert base_url == "https://omni.example/v1" + + +def test_provider_qualified_unknown_custom_slug_keeps_base_url_none(): + """An UNKNOWN ``custom:`` slug must stay base_url=None -- never a guess. + + Slugs derived from a base-url authority (``custom:local-(127.0.0.1:11434)``) + or from a provider that is simply not in ``custom_providers:`` have no + endpoint of their own. Guessing one (e.g. "there is only one custom provider, + use it" or "fall back to the active ``model.base_url``") would persist a + stale endpoint for that slug, which is the #4728 regression. Preserve the + prior behaviour: no unique matching entry -> no base_url. + """ + restore = _with_multi_custom_provider_config() + try: + model, provider, base_url = config.resolve_model_provider( + "@custom:not-configured:qwen/qwen-1.5b" + ) + finally: + restore() + + assert model == "qwen/qwen-1.5b" + assert provider == "custom:not-configured" + assert base_url is None + + +def test_provider_qualified_active_custom_slug_still_resolves(): + """The ACTIVE custom provider keeps resolving to its own endpoint.""" + restore = _with_multi_custom_provider_config() + try: + model, provider, base_url = config.resolve_model_provider( + "@custom:active:antigravity/gemini-3.7-flash-tiered" + ) + finally: + restore() + + assert model == "antigravity/gemini-3.7-flash-tiered" + assert provider == "custom:active" + assert base_url == "https://active.example/v1" + + +def test_provider_qualified_non_custom_provider_is_unaffected(): + """A non-``custom:`` @provider hint must not pick up a custom endpoint. + + The custom_providers lookup is gated on the ``custom:`` prefix, so an + @openrouter route still resolves through _get_provider_base_url() -- None + here, since openrouter is neither the active provider nor in ``providers:``. + """ + restore = _with_multi_custom_provider_config() + try: + model, provider, base_url = config.resolve_model_provider( + "@openrouter:anthropic/claude-sonnet-4.6" + ) + finally: + restore() + + assert model == "anthropic/claude-sonnet-4.6" + assert provider == "openrouter" + assert base_url is None + + +def _with_keyed_and_list_provider_config(): + """Same slug present BOTH as a ``providers:`` key and a ``custom_providers`` entry. + + Deployments that started on the legacy ``custom_providers:`` list and later + gained a keyed ``providers:`` map can carry two records for one slug, each + with its own ``base_url``. The other fixtures in this file deliberately model + the list-only shape, so this one pins which record wins. + """ + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + old_path = getattr(config, "_cfg_path", None) + config.cfg.clear() + config.cfg.update( + { + "model": { + "default": "active/model", + "provider": "custom:active", + "base_url": "https://active.example/v1", + "api_key": "active-key", + }, + "providers": { + "custom:omni": { + "base_url": "https://omni-keyed.example/v1", + "api_key": "omni-keyed-key", + }, + }, + "custom_providers": [ + { + "name": "active", + "base_url": "https://active.example/v1", + "api_key": "active-key", + }, + { + "name": "omni", + "base_url": "https://omni-list.example/v1", + "api_key": "omni-list-key", + }, + ], + } + ) + try: + config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime + except Exception: + config._cfg_mtime = 0.0 + config._cfg_path = config._get_config_path() + + def restore(): + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime + config._cfg_path = old_path + config.invalidate_models_cache() + + return restore + + +def test_list_entry_wins_over_keyed_providers_entry_for_same_slug(): + """``custom_providers[]`` outranks a same-slug ``providers:`` key. + + The named entry is the record the picker's ``custom:`` id is MINTED + from (``_custom_provider_slug_from_name`` reads ``custom_providers[].name``), + and it is the one credential resolution scans, so the endpoint must come from + that same entry -- otherwise a stale keyed ``providers:`` leftover could pair + entry A's URL with entry B's API key. Guards the precedence of the + ``custom_base_url if custom_base_url is not None else _get_provider_base_url()`` + ordering: both lookups return a URL here, so a flipped order would silently + route to ``omni-keyed`` instead. + """ + restore = _with_keyed_and_list_provider_config() + try: + # Sanity: the keyed entry really is resolvable, so this test would fail + # (not merely pass vacuously on a None fallback) if precedence flipped. + keyed_base_url = config._get_provider_base_url("custom:omni") + model, provider, base_url = config.resolve_model_provider( + "@custom:omni:antigravity/gemini-3.7-flash-tiered" + ) + conn_api_key, conn_base_url = config.resolve_custom_provider_connection("custom:omni") + finally: + restore() + + assert keyed_base_url == "https://omni-keyed.example/v1" + assert model == "antigravity/gemini-3.7-flash-tiered" + assert provider == "custom:omni" + assert base_url == "https://omni-list.example/v1" + assert conn_api_key == "omni-list-key" + assert conn_base_url == "https://omni-list.example/v1" + assert (base_url, conn_api_key) == ("https://omni-list.example/v1", "omni-list-key") + + +def _with_keyed_and_blank_list_provider_config(): + """Same slug present in ``providers:`` and as a ``custom_providers`` entry with blank base_url. + + The list entry exists and has a distinct API key, but its ``base_url`` is + empty. The keyed ``providers:`` entry has both a valid ``base_url`` and its + own distinct API key. + """ + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + old_path = getattr(config, "_cfg_path", None) + config.cfg.clear() + config.cfg.update( + { + "model": { + "default": "active/model", + "provider": "custom:active", + "base_url": "https://active.example/v1", + "api_key": "active-key", + }, + "providers": { + "custom:omni": { + "base_url": "https://omni-keyed.example/v1", + "api_key": "omni-keyed-key", + }, + }, + "custom_providers": [ + { + "name": "active", + "base_url": "https://active.example/v1", + "api_key": "active-key", + }, + { + "name": "omni", + "base_url": "", + "api_key": "omni-list-key", + }, + ], + } + ) + try: + config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime + except Exception: + config._cfg_mtime = 0.0 + config._cfg_path = config._get_config_path() + + def restore(): + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime + config._cfg_path = old_path + config.invalidate_models_cache() + + return restore + + +def test_blank_list_entry_does_not_fall_through_to_keyed_providers_endpoint(): + """A blank ``custom_providers[].base_url`` must not fall through to a keyed endpoint. + + When a slug exists both in ``custom_providers[]`` (with a blank or empty + base_url and API key K_list) and in ``providers:`` (with a populated base_url + and API key K_keyed), resolution must treat the list entry as authoritative. + Falling through to ``_get_provider_base_url()`` on empty base_url would pair + the keyed record's endpoint with the list record's API key, violating the + same-entry invariant between resolve_model_provider() and + resolve_custom_provider_connection(). + """ + restore = _with_keyed_and_blank_list_provider_config() + try: + keyed_base_url = config._get_provider_base_url("custom:omni") + model, provider, base_url = config.resolve_model_provider( + "@custom:omni:antigravity/gemini-3.7-flash-tiered" + ) + conn_api_key, conn_base_url = config.resolve_custom_provider_connection("custom:omni") + finally: + restore() + + assert keyed_base_url == "https://omni-keyed.example/v1" + assert model == "antigravity/gemini-3.7-flash-tiered" + assert provider == "custom:omni" + assert base_url is None + assert conn_base_url is None + assert conn_api_key == "omni-list-key" + assert (base_url, conn_api_key) != ("https://omni-keyed.example/v1", "omni-list-key") + + +def _setup_production_composed_runtime( + monkeypatch, + cfg_dict, + runtime_dict, + session_id="test-session-1806", + fail_first=None, + heal_mutate=None, +): + """Compose the production streaming send path around a capturing agent. + + ``fail_first`` drives the two 401 self-heal retry paths that rebuild the + runtime bundle a second time: + + * ``"returned_error"`` -- the first ``run_conversation`` RETURNS an auth + error without raising (``api/streaming.py`` returned-error retry). + * ``"raised"`` -- the first ``run_conversation`` RAISES it + (``api/streaming.py`` raised-exception retry). + + Both make ``_attempt_credential_self_heal`` hand back the same ambient + runtime dict production would re-resolve, so the retry sees the identical + truthy side-field sentinels the initial send did. + + ``heal_mutate`` is the hook for the state a retry guard actually defends + against: it runs INSIDE the stubbed self-heal, i.e. after the first agent + has already been constructed and has already failed with a 401, but BEFORE + the retry re-resolves its bundle. Mutating ``config.cfg`` there reproduces + the row being edited, unnamed or drained mid-turn -- the only way a route + that was routable at first resolution becomes terminal at retry + construction. + """ + from unittest import mock + import queue + import api.streaming as streaming + import api.oauth + + with config.SESSION_AGENT_CACHE_LOCK: + config.SESSION_AGENT_CACHE.clear() + + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + old_path = getattr(config, "_cfg_path", None) + config.cfg.clear() + config.cfg.update(cfg_dict) + try: + config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime + except Exception: + config._cfg_mtime = 0.0 + config._cfg_path = config._get_config_path() + + class FakeSession: + def __init__(self): + self.session_id = session_id + self.title = "Test" + self.workspace = "/tmp" + self.model = "test-model" + self.messages = [] + self.personality = None + self.input_tokens = 0 + self.output_tokens = 0 + self.estimated_cost = None + self.tool_calls = [] + self.active_stream_id = None + self.pending_user_message = None + self.pending_attachments = [] + self.pending_started_at = None + self.pending_user_source = None + self.profile = None + + def save(self, touch_updated_at=True, skip_index=False): + self._saved = touch_updated_at + + def compact(self): + return {"session_id": self.session_id, "messages": self.messages} + + captured = {} + + class CapturingAgent: + # Every constructor-routing field must be a NAMED parameter. The + # streaming path gates each optional kwarg on + # ``inspect.signature(AIAgent.__init__)``, so a double that only accepts + # model/provider/base_url/api_key silently filters the runtime-owned + # fields out — hiding exactly the stale-authority defect these tests pin. + def __init__( + self, + model=None, + provider=None, + base_url=None, + api_key=None, + api_mode=None, + acp_command=None, + acp_args=None, + credential_pool=None, + **kwargs, + ): + captured["init_kwargs"] = { + "model": model, + "provider": provider, + "base_url": base_url, + "api_key": api_key, + "api_mode": api_mode, + "acp_command": acp_command, + "acp_args": acp_args, + "credential_pool": credential_pool, + **kwargs, + } + captured.setdefault("init_kwargs_history", []).append( + dict(captured["init_kwargs"]) + ) + captured.setdefault("instances", []).append(self) + # Mirror ``agent/agent_init.py:_init_openai_client()``: + # + # if api_key and base_url: + # client_kwargs = _explicit_client_kwargs(...) + # else: + # client_kwargs = _routed_client_kwargs(...) + # + # An incomplete connection pair is NOT a refusal at this boundary — + # it is the signal to resolve a provider all over again, through the + # centralized router and then the init-time fallback chain. Recording + # the branch here is what lets a test assert the real defect ("this + # send would have been re-routed") instead of the weaker proxy + # ("base_url came back None"), which an unroutable bundle satisfies + # while still reaching a provider the user never chose. + if api_key and base_url: + captured.setdefault("explicit_client_kwargs_calls", []).append( + {"api_key": api_key, "base_url": base_url} + ) + else: + captured.setdefault("routed_client_kwargs_calls", []).append( + {"provider": provider, "api_key": api_key, "base_url": base_url} + ) + self.session_id = kwargs.get("session_id") + self.context_compressor = None + self.session_prompt_tokens = 0 + self.session_completion_tokens = 0 + self.session_estimated_cost_usd = None + self.reasoning_config = None + self.ephemeral_system_prompt = None + self._last_error = None + + def run_conversation(self, **kwargs): + captured["run_kwargs"] = kwargs + captured["run_calls"] = captured.get("run_calls", 0) + 1 + if fail_first is not None and captured["run_calls"] == 1: + if fail_first == "raised": + raise RuntimeError("401 Unauthorized") + return {"messages": [], "error": "401 Unauthorized"} + return { + "messages": [ + {"role": "user", "content": kwargs.get("persist_user_message", "")}, + {"role": "assistant", "content": "ok"}, + ] + } + + def interrupt(self, _message): + captured["interrupted"] = _message + + fake_session = FakeSession() + fake_stream_id = f"stream-{session_id}" + # Both the returned-error retry branch AND the outer error emission live + # behind the stale-writeback guard, which only lets the OWNING worker + # persist and emit. /api/chat/start stamps ``active_stream_id`` before + # dispatching the worker, so model that here unconditionally — otherwise the + # guard returns early and a terminal route verdict reaches the queue as + # nothing at all, which a refusal test would read as "no controlled failure". + fake_session.active_stream_id = fake_stream_id + fake_queue = queue.Queue() + + fake_runtime_module = types.ModuleType("hermes_cli.runtime_provider") + fake_runtime_module.resolve_runtime_provider = mock.Mock(return_value=dict(runtime_dict)) + fake_hermes_cli = types.ModuleType("hermes_cli") + fake_hermes_cli.runtime_provider = fake_runtime_module + fake_hermes_state = types.ModuleType("hermes_state") + fake_hermes_state.SessionDB = mock.Mock(return_value=object()) + + monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli) + monkeypatch.setitem(sys.modules, "hermes_cli.runtime_provider", fake_runtime_module) + monkeypatch.setitem(sys.modules, "hermes_state", fake_hermes_state) + + monkeypatch.setattr( + api.oauth, + "resolve_runtime_provider_with_anthropic_env_lock", + lambda resolver, **kwargs: resolver(**kwargs), + ) + monkeypatch.setattr(streaming, "get_session", lambda _session_id: fake_session) + monkeypatch.setattr(streaming, "_get_ai_agent", lambda: CapturingAgent) + monkeypatch.setattr("api.config.get_config", lambda: dict(config.cfg)) + monkeypatch.setattr("api.config._resolve_cli_toolsets", lambda *_args, **_kwargs: []) + if fail_first is not None: + # Production re-resolves the ambient runtime provider on a 401 heal, so + # the retry must see the same truthy side-field sentinels — that is + # exactly the state in which a partial rebuild leaks them through. + def _fake_self_heal(*_args, **_kwargs): + # Production re-reads provider state during the heal. Running the + # mutation here -- not before the send -- is what makes the FIRST + # resolution routable and only the RETRY resolution terminal. + if heal_mutate is not None: + heal_mutate() + return dict(runtime_dict) + + monkeypatch.setattr(streaming, "_attempt_credential_self_heal", _fake_self_heal) + + def restore(): + with config.SESSION_AGENT_CACHE_LOCK: + config.SESSION_AGENT_CACHE.clear() + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime + config._cfg_path = old_path + config.invalidate_models_cache() + + return fake_stream_id, fake_queue, captured, restore + + +def test_production_composed_nonblank_exact_list_row_yields_list_url_and_list_key(monkeypatch): + """(a) Nonblank exact list row plus distinct same-slug keyed row yields list URL/list key. + + In production, resolve_runtime_provider() scans providers: first and returns + the keyed URL and key. When an exact custom_providers[] entry matches, the + runtime send path must atomically replace both fields so that final AIAgent + construction receives list endpoint + list key, never keyed key. + """ + import api.streaming as streaming + + cfg_dict = { + "model": {"default": "active/model", "provider": "custom:active"}, + "providers": { + "custom:omni": { + "base_url": "https://keyed-url-sentinel.example/v1", + "api_key": "keyed-key-sentinel-abc", + }, + }, + "custom_providers": [ + { + "name": "omni", + "base_url": "https://list-url-sentinel.example/v1", + "api_key": "list-key-sentinel-xyz", + }, + ], + } + runtime_dict = { + "provider": "custom:omni", + "base_url": "https://keyed-url-sentinel.example/v1", + "api_key": "keyed-key-sentinel-abc", + } + stream_id, q, captured, restore = _setup_production_composed_runtime( + monkeypatch, cfg_dict, runtime_dict, session_id="session-1806-nonblank" + ) + try: + streaming.STREAMS[stream_id] = q + streaming._run_agent_streaming( + session_id="session-1806-nonblank", + msg_text="hello", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + workspace="/tmp", + stream_id=stream_id, + ) + finally: + streaming.STREAMS.pop(stream_id, None) + streaming.AGENT_INSTANCES.pop(stream_id, None) + restore() + + init_kwargs = captured["init_kwargs"] + assert init_kwargs["base_url"] == "https://list-url-sentinel.example/v1" + assert init_kwargs["api_key"] == "list-key-sentinel-xyz" + assert init_kwargs["provider"] == "custom" + assert init_kwargs["base_url"] != "https://keyed-url-sentinel.example/v1" + assert init_kwargs["api_key"] != "keyed-key-sentinel-abc" + + +def test_production_composed_blank_exact_list_row_cannot_repopulate_from_keyed_row(monkeypatch): + """(b) A blank exact list row is terminal — it cannot repopulate from the keyed row. + + When an exact ``custom_providers[]`` entry exists with an empty base_url, + that row is authoritative and the route has no endpoint. Handing AIAgent + ``base_url=None`` with the list key would NOT refuse: ``_init_openai_client()`` + honours an explicit pair only when both fields are truthy, so it would call + ``_routed_client_kwargs()`` and re-resolve a provider — reaching the keyed + row's endpoint by another door. The send must stop before construction. + """ + cfg_dict = { + "model": {"default": "active/model", "provider": "custom:active"}, + "providers": { + "custom:omni": { + "base_url": "https://keyed-url-sentinel.example/v1", + "api_key": "keyed-key-sentinel-abc", + }, + }, + "custom_providers": [ + { + "name": "omni", + "base_url": "", + "api_key": "list-key-sentinel-xyz", + }, + ], + } + runtime_dict = { + "provider": "custom:omni", + "base_url": "https://keyed-url-sentinel.example/v1", + "api_key": "keyed-key-sentinel-abc", + } + + captured, apperrors = _run_composed_send_expecting_refusal( + monkeypatch, + cfg_dict, + runtime_dict, + "session-1806-blank", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + ) + + payload = apperrors[-1] + assert "resolved no endpoint" in payload["message"], payload + assert "base_url" in payload["hint"], ( + f"the refusal must name the endpoint setting to fix: {payload}" + ) + # The refusal must not hand the user the keyed row it declined to fall + # through to — naming it would read as "this endpoint was used". + assert "keyed-url-sentinel" not in str(payload), payload + assert not captured.get("explicit_client_kwargs_calls"), ( + "a client was configured for a route with no endpoint" + ) + + +def test_production_composed_keyed_only_still_yields_keyed_url_and_key(monkeypatch): + """(c) Keyed-only/no-list still yields the keyed URL/key. + + When no matching row exists in custom_providers[], the keyed providers: + record is authoritative and supplies both base_url and api_key. + """ + import api.streaming as streaming + + cfg_dict = { + "model": {"default": "active/model", "provider": "custom:active"}, + "providers": { + "custom:omni": { + "base_url": "https://keyed-url-sentinel.example/v1", + "api_key": "keyed-key-sentinel-abc", + }, + }, + "custom_providers": [ + { + "name": "active", + "base_url": "https://active.example/v1", + "api_key": "active-key", + }, + ], + } + runtime_dict = { + "provider": "custom:omni", + "base_url": "https://keyed-url-sentinel.example/v1", + "api_key": "keyed-key-sentinel-abc", + } + stream_id, q, captured, restore = _setup_production_composed_runtime( + monkeypatch, cfg_dict, runtime_dict, session_id="session-1806-keyed" + ) + try: + streaming.STREAMS[stream_id] = q + streaming._run_agent_streaming( + session_id="session-1806-keyed", + msg_text="hello", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + workspace="/tmp", + stream_id=stream_id, + ) + finally: + streaming.STREAMS.pop(stream_id, None) + streaming.AGENT_INSTANCES.pop(stream_id, None) + restore() + + init_kwargs = captured["init_kwargs"] + assert init_kwargs["base_url"] == "https://keyed-url-sentinel.example/v1" + assert init_kwargs["api_key"] == "keyed-key-sentinel-abc" + assert init_kwargs["provider"] == "custom" + + +# ───────────────────────────────────────────────────────────────────────────── +# Full-constructor bundle authority (streaming initial send + both retry paths) +# +# The connection fields are only half the constructor contract. AIAgent also +# takes ``credential_pool``, ``api_mode``, ``acp_command`` and ``acp_args`` from +# the resolved runtime provider, and the ambient provider legitimately reports +# all four (it is what the process is otherwise authenticated as). A send that +# replaces provider/base_url/api_key from an exact ``custom_providers[]`` row but +# keeps those four builds an agent whose credential source, wire protocol and +# transport still point at the previous authority — the custom HTTP endpoint gets +# Anthropic credential pooling and a Claude ACP subprocess command. +# +# ``_AMBIENT_SIDE_FIELD_RUNTIME`` seeds every one of them truthy so a +# pass-through is a hard assertion failure rather than a vacuous ``None == None``. +# ───────────────────────────────────────────────────────────────────────────── + +_KEYED_VS_LIST_CFG = { + "model": {"default": "active/model", "provider": "custom:active"}, + "providers": { + "custom:omni": { + "base_url": "https://keyed-url-sentinel.example/v1", + "api_key": "keyed-key-sentinel-abc", + }, + }, + "custom_providers": [ + { + "name": "omni", + "base_url": "https://list-url-sentinel.example/v1", + "api_key": "list-key-sentinel-xyz", + }, + ], +} + +_AMBIENT_SIDE_FIELD_RUNTIME = { + "provider": "custom:omni", + "base_url": "https://keyed-url-sentinel.example/v1", + "api_key": "keyed-key-sentinel-abc", + # Runtime-owned constructor fields belonging to the AMBIENT provider. + "credential_pool": ["ambient-pool-sentinel-1", "ambient-pool-sentinel-2"], + "api_mode": "anthropic_messages", + "command": "claude-code-acp-sentinel", + "args": ["--acp-arg-sentinel"], +} + +_LIST_ROW_URL = "https://list-url-sentinel.example/v1" +_LIST_ROW_KEY = "list-key-sentinel-xyz" + + +# Case (d): every side field in ``_AMBIENT_SIDE_FIELD_RUNTIME`` is genuinely +# FOREIGN to ``_KEYED_VS_LIST_CFG`` -- the list row owns a different endpoint and +# declares none of these for itself, so provenance proves they belong to the +# ambient provider and every one must be cleared. This is an ownership verdict, +# not a blanket "custom routes never carry side fields" rule: the cases below +# pin records that DO own them, and there the same merge must keep them. +_FOREIGN_AMBIENT_SIDE_FIELDS = { + "api_mode": None, + "acp_command": None, + "acp_args": None, + "credential_pool": None, +} + + +def _assert_side_fields(init_kwargs, expected, label): + """Assert each constructor side field equals the authority that OWNS it.""" + for field, value in expected.items(): + actual = init_kwargs[field] + if callable(value): + assert actual is value, f"{label}: {field} lost its owner's value" + else: + assert actual == value, f"{label}: {field} is {actual!r}, expected {value!r}" + + +def _assert_exact_list_row_bundle(init_kwargs, label, side_fields=None): + """Assert the whole constructor bundle is target-owned, not a mixed one. + + ``side_fields`` names what the exact list row's authority resolves each side + field to; it defaults to case (d), the all-foreign ambient runtime. + """ + assert init_kwargs["base_url"] == _LIST_ROW_URL, label + assert init_kwargs["api_key"] == _LIST_ROW_KEY, label + assert init_kwargs["provider"] == "custom", label + # None of the keyed/ambient authority may survive anywhere in the bundle. + assert init_kwargs["base_url"] != "https://keyed-url-sentinel.example/v1", label + assert init_kwargs["api_key"] != "keyed-key-sentinel-abc", label + _assert_side_fields( + init_kwargs, + _FOREIGN_AMBIENT_SIDE_FIELDS if side_fields is None else side_fields, + label, + ) + + +def test_capturing_agent_exposes_every_runtime_constructor_field(monkeypatch): + """Guard the guard: the double must not signature-filter the side fields. + + ``_run_agent_streaming`` gates each optional kwarg on + ``inspect.signature(AIAgent.__init__).parameters``. A double that swallowed + ``credential_pool`` / ``api_mode`` / ``acp_command`` / ``acp_args`` into + ``**kwargs`` would make every assertion below pass vacuously, because the + stale values would never be passed at all. + """ + import inspect + + import api.streaming as streaming + + _stream_id, _q, _captured, restore = _setup_production_composed_runtime( + monkeypatch, dict(_KEYED_VS_LIST_CFG), {} + ) + try: + params = set(inspect.signature(streaming._get_ai_agent().__init__).parameters) + finally: + restore() + + for field in ("api_mode", "acp_command", "acp_args", "credential_pool"): + assert field in params, f"{field} would be signature-filtered out" + + +def test_production_composed_initial_send_replaces_full_runtime_bundle(monkeypatch): + """Initial send: exact list row owns the WHOLE constructor bundle.""" + import api.streaming as streaming + + stream_id, q, captured, restore = _setup_production_composed_runtime( + monkeypatch, + dict(_KEYED_VS_LIST_CFG), + dict(_AMBIENT_SIDE_FIELD_RUNTIME), + session_id="session-1806-bundle-initial", + ) + try: + streaming.STREAMS[stream_id] = q + streaming._run_agent_streaming( + session_id="session-1806-bundle-initial", + msg_text="hello", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + workspace="/tmp", + stream_id=stream_id, + ) + finally: + streaming.STREAMS.pop(stream_id, None) + streaming.AGENT_INSTANCES.pop(stream_id, None) + restore() + + _assert_exact_list_row_bundle(captured["init_kwargs"], "initial send") + + +def test_production_composed_returned_error_retry_replaces_full_runtime_bundle(monkeypatch): + """Returned-error 401 retry: the rebuilt bundle is target-owned too. + + The retry path rebuilds agent kwargs from the self-healed runtime dict. When + it only refreshed provider/key/base_url (plus ``credential_pool`` from the + heal result), the retry agent was constructed with the ambient provider's + pool/api_mode/ACP fields even though the endpoint is the custom list row. + """ + import api.streaming as streaming + + stream_id, q, captured, restore = _setup_production_composed_runtime( + monkeypatch, + dict(_KEYED_VS_LIST_CFG), + dict(_AMBIENT_SIDE_FIELD_RUNTIME), + session_id="session-1806-bundle-returned", + fail_first="returned_error", + ) + try: + streaming.STREAMS[stream_id] = q + streaming._run_agent_streaming( + session_id="session-1806-bundle-returned", + msg_text="hello", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + workspace="/tmp", + stream_id=stream_id, + ) + finally: + streaming.STREAMS.pop(stream_id, None) + streaming.AGENT_INSTANCES.pop(stream_id, None) + restore() + + history = captured["init_kwargs_history"] + assert len(history) >= 2, "returned-error retry did not construct a second agent" + for index, init_kwargs in enumerate(history): + _assert_exact_list_row_bundle(init_kwargs, f"returned-error construction #{index}") + + +def test_production_composed_raised_exception_retry_replaces_full_runtime_bundle(monkeypatch): + """Raised-exception 401 retry: same complete-bundle contract.""" + import api.streaming as streaming + + stream_id, q, captured, restore = _setup_production_composed_runtime( + monkeypatch, + dict(_KEYED_VS_LIST_CFG), + dict(_AMBIENT_SIDE_FIELD_RUNTIME), + session_id="session-1806-bundle-raised", + fail_first="raised", + ) + try: + streaming.STREAMS[stream_id] = q + streaming._run_agent_streaming( + session_id="session-1806-bundle-raised", + msg_text="hello", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + workspace="/tmp", + stream_id=stream_id, + ) + finally: + streaming.STREAMS.pop(stream_id, None) + streaming.AGENT_INSTANCES.pop(stream_id, None) + restore() + + history = captured["init_kwargs_history"] + assert len(history) >= 2, "raised-exception retry did not construct a second agent" + for index, init_kwargs in enumerate(history): + _assert_exact_list_row_bundle(init_kwargs, f"raised-exception construction #{index}") + + +def test_agent_cache_signature_tracks_the_resolved_bundle(monkeypatch): + """The cached-agent signature must be derived from the FINAL bundle. + + If ``_sig_blob`` still read ``api_mode`` / ACP / pool off the raw runtime + provider, two sends whose resolved bundles differ only in a cleared side + field would hash identically — so the second send would reuse an agent built + on the previous authority instead of minting a new one. + """ + import api.streaming as streaming + + def _run(runtime_dict, session_id): + stream_id, q, captured, restore = _setup_production_composed_runtime( + monkeypatch, dict(_KEYED_VS_LIST_CFG), runtime_dict, session_id=session_id + ) + try: + streaming.STREAMS[stream_id] = q + streaming._run_agent_streaming( + session_id=session_id, + msg_text="hello", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + workspace="/tmp", + stream_id=stream_id, + ) + with config.SESSION_AGENT_CACHE_LOCK: + return config.SESSION_AGENT_CACHE[session_id][1] + finally: + streaming.STREAMS.pop(stream_id, None) + streaming.AGENT_INSTANCES.pop(stream_id, None) + restore() + + # Same resolved bundle, wildly different ambient side fields: because the + # custom row clears them all, the signature must be identical. + plain_runtime = { + "provider": "custom:omni", + "base_url": "https://keyed-url-sentinel.example/v1", + "api_key": "keyed-key-sentinel-abc", + } + sig_plain = _run(plain_runtime, "session-1806-sig-plain") + sig_ambient = _run(dict(_AMBIENT_SIDE_FIELD_RUNTIME), "session-1806-sig-ambient") + assert sig_plain == sig_ambient, ( + "signature still varies with runtime fields the bundle cleared" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Ownership-aware side fields +# +# "Clear the runtime-owned side fields whenever a custom record supplies the +# connection" over-corrects. ``api_mode``, ``credential_pool``, ``acp_command`` +# and ``acp_args`` are not intrinsically ambient: a ``custom_providers[]`` row is +# free to declare ``api_mode: anthropic_messages``, and a keyed +# ``providers['custom:']`` record is free to declare a pool and an ACP +# transport. Blanket-clearing them downgrades an Anthropic-protocol row to +# chat-completions and drops a keyed record's own pool/transport. +# +# The rule these tests pin is provenance, not field name: +# +# * the selected record declares the field -> the record's value wins; +# * the runtime resolved the SAME endpoint -> its value is same-authority, keep; +# * the runtime resolved a DIFFERENT one -> proven foreign, clear. +# +# The same distinction gates ``dummy-key``: it is a statement that the endpoint +# is UNAUTHENTICATED, so it may only be substituted once the record's whole +# credential ladder (pool, api_key, key_env, CUSTOM__API_KEY, key_cmd, +# host-gated env) has come up empty. Substituting it over a ``key_cmd`` or a +# pooled credential turns a working endpoint into a 401. +# ───────────────────────────────────────────────────────────────────────────── + + +def _run_composed_send(monkeypatch, cfg_dict, runtime_dict, session_id, before_send=None): + """Drive ONE production-composed streaming send; return the constructor kwargs. + + ``before_send`` runs after the fake ``hermes_cli.runtime_provider`` module is + installed, so a test can hang extra runtime helpers (the credential-pool + lookup) off it before resolution happens. + """ + import api.streaming as streaming + + stream_id, q, captured, restore = _setup_production_composed_runtime( + monkeypatch, cfg_dict, runtime_dict, session_id=session_id + ) + try: + if before_send is not None: + before_send() + streaming.STREAMS[stream_id] = q + streaming._run_agent_streaming( + session_id=session_id, + msg_text="hello", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + workspace="/tmp", + stream_id=stream_id, + ) + finally: + streaming.STREAMS.pop(stream_id, None) + streaming.AGENT_INSTANCES.pop(stream_id, None) + restore() + return captured["init_kwargs"] + + +# ───────────────────────────────────────────────────────────────────────────── +# Terminal route verdicts: the send must STOP, not fail closed and continue +# +# "Fail closed" was the wrong shape for an unresolvable named route. Clearing +# ``base_url``/``api_key`` looks terminal in a bundle assertion, but at the +# constructor it is the opposite: ``_init_openai_client()`` honours an explicit +# pair only when BOTH fields are truthy and otherwise calls +# ``_routed_client_kwargs()``, which re-resolves a provider through the +# centralized router and the init-time fallback chain. So the very bundles the +# earlier tests asserted were "safe" are the ones that route the user's prompt — +# and whatever credential init finds — to a provider they never picked. +# +# The helpers below assert the real property: for both terminal shapes (a slug +# nothing owns, and an owned row whose declared credential resolved to nothing) +# NO agent is constructed at all, so ``_routed_client_kwargs()`` is never +# reached, the agent cache is never written, and the turn ends on a controlled +# ``provider_unroutable`` apperror instead. +# ───────────────────────────────────────────────────────────────────────────── + + +def _drain_apperrors(fake_queue): + """Return every ``apperror`` payload the worker queued.""" + import queue as _queue + + payloads = [] + while True: + try: + item = fake_queue.get_nowait() + except _queue.Empty: + break + if item and item[0] == "apperror": + payloads.append(item[1]) + return payloads + + +def _assert_route_refused(captured, apperrors, label, *, expected_reason=None): + """Assert the send stopped at the route verdict, before any provider routing.""" + routed = captured.get("routed_client_kwargs_calls", []) + assert not routed, ( + f"{label}: AIAgent was constructed with an incomplete connection pair " + f"{routed}, so _init_openai_client() fell through to " + f"_routed_client_kwargs() and re-resolved a provider" + ) + assert not captured.get("init_kwargs_history"), ( + f"{label}: an agent was constructed for an unroutable route" + ) + assert not captured.get("run_calls"), f"{label}: the turn was actually sent" + + assert apperrors, f"{label}: no controlled failure was emitted" + payload = apperrors[-1] + assert payload["type"] == "provider_unroutable", ( + f"{label}: emitted {payload['type']!r} instead of a provider-route failure" + ) + assert payload.get("hint"), f"{label}: the failure named no fix" + if expected_reason is not None: + assert expected_reason in payload.get("message", "") or expected_reason in payload.get( + "hint", "" + ), f"{label}: the failure did not name {expected_reason!r}: {payload}" + + with config.SESSION_AGENT_CACHE_LOCK: + assert not config.SESSION_AGENT_CACHE, ( + f"{label}: the agent cache was poisoned with an unroutable bundle, so " + f"every later turn in this session would reuse it" + ) + return payload + + +def _run_composed_send_expecting_refusal( + monkeypatch, cfg_dict, runtime_dict, session_id, *, model +): + """Drive one composed send whose route is terminal at the FIRST resolution. + + Returns ``(captured, apperrors)``. Deliberately takes no ``fail_first``: the + verdict lands before any agent exists, so there is no 401 for the self-heal + retries to act on and threading one through would only produce cases that + re-run identical code. The retry regions have their own harness, + :func:`_run_composed_retry_expecting_abandoned_heal`. + """ + import api.streaming as streaming + + stream_id, q, captured, restore = _setup_production_composed_runtime( + monkeypatch, cfg_dict, runtime_dict, session_id=session_id + ) + try: + streaming.STREAMS[stream_id] = q + streaming._run_agent_streaming( + session_id=session_id, + msg_text="hello", + model=model, + workspace="/tmp", + stream_id=stream_id, + ) + apperrors = _drain_apperrors(q) + # Read the cache BEFORE restore() clears it. + _assert_route_refused(captured, apperrors, session_id) + finally: + streaming.STREAMS.pop(stream_id, None) + streaming.AGENT_INSTANCES.pop(stream_id, None) + restore() + return captured, apperrors + + +def _exact_list_row_cfg(*, drop=(), **row_fields): + """``_KEYED_VS_LIST_CFG`` with the exact ``custom_providers[]`` row extended.""" + cfg_dict = copy.deepcopy(_KEYED_VS_LIST_CFG) + for field in drop: + cfg_dict["custom_providers"][0].pop(field, None) + cfg_dict["custom_providers"][0].update(row_fields) + return cfg_dict + + +def _ambient_runtime(**overrides): + runtime = copy.deepcopy(_AMBIENT_SIDE_FIELD_RUNTIME) + runtime.update(overrides) + return runtime + + +# ── (a) exact-list-owned Anthropic mode ────────────────────────────────────── + + +@pytest.mark.parametrize( + "row_fields,session_suffix", + [ + ({"api_mode": "anthropic_messages"}, "api-mode"), + # ``transport:`` is the v12-migration spelling and ``anthropic`` an + # accepted alias; a hand-edited config using either still owns the mode. + ({"transport": "anthropic"}, "transport-alias"), + ], +) +def test_exact_list_row_keeps_its_own_anthropic_api_mode( + monkeypatch, row_fields, session_suffix +): + """(a) The row declares its wire protocol, so neither clearing nor the ambient wins. + + The ambient runtime reports ``chat_completions`` for a DIFFERENT endpoint. + Both failure modes are visible here: passing the ambient value through gives + ``chat_completions``, blanket-clearing gives ``None``, and only reading the + row's own declaration gives ``anthropic_messages`` — which is what decides + whether the send speaks /v1/messages or /v1/chat/completions. + """ + init_kwargs = _run_composed_send( + monkeypatch, + _exact_list_row_cfg(**row_fields), + _ambient_runtime(api_mode="chat_completions"), + f"session-1806-owned-{session_suffix}", + ) + + _assert_exact_list_row_bundle( + init_kwargs, + f"exact list row owns api_mode ({session_suffix})", + side_fields={**_FOREIGN_AMBIENT_SIDE_FIELDS, "api_mode": "anthropic_messages"}, + ) + + +# ── (b) exact-list key_cmd / pool credential ───────────────────────────────── + + +def _fake_command_token_source(monkeypatch, build): + fake_module = types.ModuleType("agent.command_token_source") + fake_module.build_command_token_provider = build + monkeypatch.setitem(sys.modules, "agent.command_token_source", fake_module) + + +def test_exact_list_row_key_cmd_is_not_replaced_by_dummy_key(monkeypatch): + """(b) A ``key_cmd`` row mints a real per-request bearer, so it is NOT keyless. + + ``key_cmd`` names a command that prints a short-lived bearer; both wire + clients accept a callable api_key and mint one per request. Handing the + endpoint ``dummy-key`` instead — because the row carries no literal + ``api_key`` — is a guaranteed 401 against an endpoint that does want auth. + """ + built = {} + + def _token_provider(): + return "minted-bearer-sentinel" + + def _build(key_cmd, name): + built["key_cmd"] = key_cmd + built["name"] = name + return _token_provider + + _fake_command_token_source(monkeypatch, _build) + + init_kwargs = _run_composed_send( + monkeypatch, + _exact_list_row_cfg(drop=("api_key",), key_cmd="print-omni-bearer"), + _ambient_runtime(), + "session-1806-owned-key-cmd", + ) + + assert built["key_cmd"] == "print-omni-bearer", "key_cmd was never consulted" + assert built["name"] == "omni" + assert init_kwargs["api_key"] is _token_provider, "row's key_cmd token source lost" + assert init_kwargs["api_key"]() == "minted-bearer-sentinel" + assert init_kwargs["api_key"] != config.KEYLESS_CUSTOM_API_KEY + assert init_kwargs["api_key"] != "keyed-key-sentinel-abc" + assert init_kwargs["base_url"] == _LIST_ROW_URL + assert init_kwargs["provider"] == "custom" + _assert_side_fields(init_kwargs, _FOREIGN_AMBIENT_SIDE_FIELDS, "exact list row key_cmd") + + +def test_exact_list_row_unbuildable_key_cmd_still_refuses_dummy_key(monkeypatch): + """(b) ``dummy-key`` asserts "this endpoint is unauthenticated" — never a guess. + + When the token provider cannot be built (older agent build, broken command + spec) the endpoint is still an authenticated one whose credential is missing. + The placeholder would report that as an opaque 401, and sending with NO key + is no safer: an agent built with ``api_key=None`` never reaches an explicit + client, so ``_routed_client_kwargs()`` re-resolves a provider and the turn + leaves for whatever credential init finds next. The route is terminal. + """ + + def _build(_key_cmd, _name): + raise RuntimeError("command token source unavailable") + + _fake_command_token_source(monkeypatch, _build) + + captured, apperrors = _run_composed_send_expecting_refusal( + monkeypatch, + _exact_list_row_cfg(drop=("api_key",), key_cmd="print-omni-bearer"), + _ambient_runtime(), + "session-1806-owned-key-cmd-broken", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + ) + + payload = apperrors[-1] + assert "produced no API key" in payload["message"], payload + assert "key_cmd" in payload["hint"], ( + f"the refusal must name the credential setting to fix: {payload}" + ) + # Neither the placeholder nor the ambient keyed credential may appear + # anywhere on the refusal path. + assert config.KEYLESS_CUSTOM_API_KEY not in str(payload), payload + assert "keyed-key-sentinel-abc" not in str(payload), payload + assert not captured.get("explicit_client_kwargs_calls"), ( + "a client was configured for a route whose declared credential failed" + ) + + +def test_exact_list_row_pool_credential_and_pool_object_both_survive(monkeypatch): + """(b) A pooled row keeps the pool's key AND the pool object — from ITS endpoint. + + The credential and the ``credential_pool`` the agent rotates it with come + from one lookup keyed on the ROW's base_url. Clearing the pool (while keeping + its key) leaves the agent unable to rotate; keeping the ambient pool points + rotation at the previous authority; falling back to ``dummy-key`` drops the + credential entirely. + """ + pool_sentinel = ["list-row-pool-sentinel"] + seen = {} + + def _before_send(): + runtime_module = sys.modules["hermes_cli.runtime_provider"] + + def _try_resolve_from_custom_pool( + base_url, provider_label, api_mode_override=None, provider_name=None + ): + seen["base_url"] = base_url + seen["provider_name"] = provider_name + if base_url != _LIST_ROW_URL: + return None + return {"api_key": "pool-key-sentinel", "credential_pool": pool_sentinel} + + runtime_module._try_resolve_from_custom_pool = _try_resolve_from_custom_pool + + init_kwargs = _run_composed_send( + monkeypatch, + _exact_list_row_cfg(drop=("api_key",)), + _ambient_runtime(), + "session-1806-owned-pool", + before_send=_before_send, + ) + + # The pool was looked up for the ROW's endpoint, not the ambient one. + assert seen["base_url"] == _LIST_ROW_URL + assert seen["provider_name"] == "omni" + assert init_kwargs["api_key"] == "pool-key-sentinel" + assert init_kwargs["api_key"] != config.KEYLESS_CUSTOM_API_KEY + assert init_kwargs["api_key"] != "keyed-key-sentinel-abc" + _assert_side_fields( + init_kwargs, + {**_FOREIGN_AMBIENT_SIDE_FIELDS, "credential_pool": pool_sentinel}, + "exact list row pool", + ) + assert init_kwargs["credential_pool"] is pool_sentinel + assert init_kwargs["credential_pool"] != _AMBIENT_SIDE_FIELD_RUNTIME["credential_pool"] + + +# ── (c) keyed-only record's own side fields ────────────────────────────────── + + +_KEYED_ONLY_OWNED_CFG = { + "model": {"default": "active/model", "provider": "custom:active"}, + "providers": { + "custom:omni": { + "base_url": "https://keyed-url-sentinel.example/v1", + "api_key": "keyed-key-sentinel-abc", + # Side fields the KEYED record declares for itself. + "api_mode": "anthropic_messages", + "credential_pool": ["keyed-pool-sentinel"], + "acp_command": "keyed-acp-sentinel", + "acp_args": ["--keyed-arg-sentinel"], + }, + }, + "custom_providers": [ + { + "name": "active", + "base_url": "https://active.example/v1", + "api_key": "active-key", + }, + ], +} + + +def test_keyed_only_record_keeps_the_side_fields_it_owns(monkeypatch): + """(c) No list row: the keyed record is the authority for ITS side fields too. + + ``providers['custom:omni']`` supplies the endpoint and the credential, so it + also owns the pool, wire protocol and ACP transport it declares. Clearing + them because the route is ``custom:`` throws away the record's own + configuration; taking the ambient values (all four differ here) routes the + send through the previous authority. + """ + init_kwargs = _run_composed_send( + monkeypatch, + copy.deepcopy(_KEYED_ONLY_OWNED_CFG), + _ambient_runtime( + api_mode="chat_completions", + command="ambient-acp-sentinel", + args=["--ambient-arg-sentinel"], + credential_pool=["ambient-pool-sentinel"], + ), + "session-1806-keyed-owned", + ) + + assert init_kwargs["base_url"] == "https://keyed-url-sentinel.example/v1" + assert init_kwargs["api_key"] == "keyed-key-sentinel-abc" + assert init_kwargs["provider"] == "custom" + _assert_side_fields( + init_kwargs, + { + "api_mode": "anthropic_messages", + "credential_pool": ["keyed-pool-sentinel"], + "acp_command": "keyed-acp-sentinel", + "acp_args": ["--keyed-arg-sentinel"], + }, + "keyed-only owned side fields", + ) + + +def test_keyed_only_record_without_side_fields_keeps_same_endpoint_runtime(monkeypatch): + """(c) The clear is provenance-driven, not slug-driven. + + Here the keyed record declares no side fields and the runtime resolved the + SAME endpoint the record owns, so the runtime's values are same-authority. + Clearing them would strip a legitimately-pooled Anthropic-protocol endpoint + of its pool and protocol just because the route is spelled ``custom:``. + """ + cfg_dict = copy.deepcopy(_KEYED_ONLY_OWNED_CFG) + for field in ("api_mode", "credential_pool", "acp_command", "acp_args"): + cfg_dict["providers"]["custom:omni"].pop(field) + + init_kwargs = _run_composed_send( + monkeypatch, + cfg_dict, + _ambient_runtime(), + "session-1806-keyed-same-authority", + ) + + assert init_kwargs["base_url"] == "https://keyed-url-sentinel.example/v1" + _assert_side_fields( + init_kwargs, + { + "api_mode": _AMBIENT_SIDE_FIELD_RUNTIME["api_mode"], + "credential_pool": _AMBIENT_SIDE_FIELD_RUNTIME["credential_pool"], + "acp_command": _AMBIENT_SIDE_FIELD_RUNTIME["command"], + "acp_args": _AMBIENT_SIDE_FIELD_RUNTIME["args"], + }, + "keyed-only same-authority runtime", + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Non-streaming route consumers +# +# Four WebUI consumers build their own AIAgent outside the streaming path. +# Each resolved the endpoint deterministically (``resolve_model_provider`` now +# returns the exact ``custom_providers[]`` row's URL) and then applied the +# custom-provider connection with a FILL-ONLY pattern: +# +# if not api_key and _cp_key: api_key = _cp_key +# +# Because ``resolve_runtime_provider`` had already supplied a truthy key from the +# same-slug keyed ``providers:`` record, the guard never fired — so the final +# constructor received the LIST row's URL paired with the KEYED row's API key. +# Each test below asserts on the FINAL constructor kwargs, so it fails on that +# mixed bundle and passes only once the row is applied atomically. +# ───────────────────────────────────────────────────────────────────────────── + + +class _RouteAgentCaptured(Exception): + """Sentinel raised from the double's __init__ once the bundle is captured. + + All four consumers construct the agent and immediately use it (compress, + run_conversation, text completion). Stopping at construction keeps each test + scoped to the routing contract instead of to downstream persistence. + """ + + +# The runtime dict the route consumers see by default: production shape, where +# ``resolve_runtime_provider`` scans ``providers:`` first and so hands back the +# KEYED endpoint and the KEYED key for this slug. +_ROUTE_KEYED_RUNTIME = { + "provider": "custom:omni", + "base_url": "https://keyed-url-sentinel.example/v1", + "api_key": "keyed-key-sentinel-abc", +} + + +def _setup_route_consumer_runtime( + monkeypatch, session_messages=None, cfg_dict=None, runtime_dict=None +): + """Compose the keyed-vs-list config around a capturing route agent. + + Returns ``(captured, fake_session)``. ``captured["init_kwargs"]`` holds the + FINAL constructor bundle the consumer under test built. + + ``cfg_dict``/``runtime_dict`` override the defaults so a test can hand the + consumers an exact row that OWNS side fields (``api_mode``, + ``credential_pool``, ACP transport) and an ambient runtime that reports + different ones -- the probe for whether the complete bundle, or just its + three connection fields, reaches the constructor. + """ + import types as _types + from unittest import mock + + import api.config as _config + import api.oauth + import api.routes as routes + + cfg_dict = copy.deepcopy(_KEYED_VS_LIST_CFG if cfg_dict is None else cfg_dict) + monkeypatch.setattr(_config, "cfg", dict(cfg_dict), raising=False) + monkeypatch.setattr(_config, "get_config", lambda: copy.deepcopy(cfg_dict)) + + runtime_dict = copy.deepcopy( + _ROUTE_KEYED_RUNTIME if runtime_dict is None else runtime_dict + ) + fake_runtime_module = _types.ModuleType("hermes_cli.runtime_provider") + fake_runtime_module.resolve_runtime_provider = mock.Mock( + return_value=runtime_dict + ) + fake_hermes_cli = _types.ModuleType("hermes_cli") + fake_hermes_cli.__path__ = [] + fake_hermes_cli.runtime_provider = fake_runtime_module + monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli) + monkeypatch.setitem(sys.modules, "hermes_cli.runtime_provider", fake_runtime_module) + monkeypatch.setattr( + api.oauth, + "resolve_runtime_provider_with_anthropic_env_lock", + lambda resolver, **kwargs: resolver(**kwargs), + ) + + captured = {} + + class CapturingRouteAgent: + # Every constructor-routing field must be a NAMED parameter, for the + # same reason as the streaming double: the route consumers gate each + # optional kwarg on ``inspect.signature(AIAgent.__init__)``, so a double + # that swallowed them into ``**kwargs`` would filter out exactly the + # fields these tests pin and pass vacuously. + def __init__( + self, + model=None, + provider=None, + base_url=None, + api_key=None, + api_mode=None, + acp_command=None, + acp_args=None, + credential_pool=None, + **kwargs, + ): + captured["init_kwargs"] = { + "model": model, + "provider": provider, + "base_url": base_url, + "api_key": api_key, + "api_mode": api_mode, + "acp_command": acp_command, + "acp_args": acp_args, + "credential_pool": credential_pool, + **kwargs, + } + raise _RouteAgentCaptured("captured") + + monkeypatch.setattr(routes, "require_ai_agent_class", lambda: CapturingRouteAgent) + monkeypatch.setattr(routes, "ensure_agent_runtime_current", lambda *_a, **_k: None) + monkeypatch.setattr(routes, "_resolve_cli_toolsets", lambda *_a, **_k: []) + monkeypatch.setattr(routes, "_session_is_subagent_view_only", lambda *_a, **_k: False) + + class _FakeSession: + def __init__(self): + self.session_id = "session-1806-route" + self.title = "Test" + self.workspace = "/tmp" + self.model = "antigravity/gemini-3.7-flash-tiered" + self.model_provider = "custom:omni" + self.messages = list(session_messages or []) + self.context_messages = list(session_messages or []) + self.active_stream_id = None + self.pending_user_message = None + self.pending_attachments = [] + self.pending_started_at = None + self.pending_user_source = None + self.profile = None + + def save(self, *_a, **_k): + return None + + return captured, _FakeSession() + + +def _assert_list_row_not_keyed(init_kwargs, label): + """The endpoint AND the credential must both come from the list row.""" + assert init_kwargs["base_url"] == _LIST_ROW_URL, label + assert init_kwargs["api_key"] == _LIST_ROW_KEY, label + assert init_kwargs["base_url"] != "https://keyed-url-sentinel.example/v1", label + assert init_kwargs["api_key"] != "keyed-key-sentinel-abc", label + assert init_kwargs["provider"] == "custom", label + + +def _four_route_messages(): + return [ + {"role": "user", "content": "one", "timestamp": 1.0, "_ts": 1.0}, + {"role": "assistant", "content": "two", "timestamp": 2.0, "_ts": 2.0}, + {"role": "user", "content": "three", "timestamp": 3.0, "_ts": 3.0}, + {"role": "assistant", "content": "four", "timestamp": 4.0, "_ts": 4.0}, + ] + + +# Each driver composes the harness the way its consumer needs it and returns the +# FINAL constructor kwargs. Sharing them keeps the two contracts below -- +# "the endpoint and credential are one record's" and "the record's side fields +# reach the constructor too" -- asserted over the SAME five consumers, so a new +# consumer cannot be added to one list and forgotten in the other. + + +def _drive_sync_chat_route(monkeypatch, cfg_dict=None, runtime_dict=None): + """Consumer 1/5: POST /api/chat.""" + import api.routes as routes + + captured, fake_session = _setup_route_consumer_runtime( + monkeypatch, + session_messages=_four_route_messages(), + cfg_dict=cfg_dict, + runtime_dict=runtime_dict, + ) + monkeypatch.setattr(routes, "get_session", lambda _sid: fake_session) + monkeypatch.setattr(routes, "_agent_runtime_barrier_response", lambda **_k: None) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda ws: "/tmp") + monkeypatch.setattr( + routes, "_get_session_agent_lock", lambda _sid: contextlib.nullcontext() + ) + monkeypatch.setattr( + routes, "_read_profile_model_config", lambda *_a, **_k: (None, None, None) + ) + monkeypatch.setattr( + routes, + "_resolve_compatible_session_model_state", + lambda model, provider, **_k: (model, provider), + ) + monkeypatch.setattr(routes, "j", lambda _handler, payload, **_k: payload) + monkeypatch.setattr(routes, "bad", lambda _handler, message, *_a, **_k: {"error": message}) + + with pytest.raises(_RouteAgentCaptured): + routes._handle_chat_sync( + object(), {"session_id": "session-1806-route", "message": "hi"} + ) + return captured["init_kwargs"] + + +def _drive_manual_compression_route(monkeypatch, cfg_dict=None, runtime_dict=None): + """Consumer 2/5: POST /api/session/{sid}/compress.""" + import api.config as _config + import api.routes as routes + + captured, fake_session = _setup_route_consumer_runtime( + monkeypatch, + session_messages=_four_route_messages(), + cfg_dict=cfg_dict, + runtime_dict=runtime_dict, + ) + monkeypatch.setattr(routes, "get_session", lambda _sid: fake_session) + monkeypatch.setattr( + _config, "_get_session_agent_lock", lambda _sid: contextlib.nullcontext() + ) + monkeypatch.setattr(routes, "j", lambda _handler, payload, **_k: payload) + monkeypatch.setattr(routes, "bad", lambda _handler, message, *_a, **_k: {"error": message}) + + routes._handle_session_compress(object(), {"session_id": "session-1806-route"}) + return captured["init_kwargs"] + + +def _decline_auxiliary_client(monkeypatch, recorded_main_runtime=None): + """Force the auxiliary-model shortcut to decline. + + The consumers that have one would otherwise return before constructing the + main-model agent -- and that constructor is the bundle under test. + """ + import types as _types + + fake_aux = _types.ModuleType("agent.auxiliary_client") + + def _get_text_auxiliary_client(_task, main_runtime=None): + if recorded_main_runtime is not None: + recorded_main_runtime.update(main_runtime or {}) + return (None, None) + + fake_aux.get_text_auxiliary_client = _get_text_auxiliary_client + monkeypatch.setitem(sys.modules, "agent.auxiliary_client", fake_aux) + + +def _drive_commit_message_route( + monkeypatch, cfg_dict=None, runtime_dict=None, recorded_main_runtime=None +): + """Consumer 3/5: LLM git commit-message generation.""" + import api.routes as routes + + captured, fake_session = _setup_route_consumer_runtime( + monkeypatch, cfg_dict=cfg_dict, runtime_dict=runtime_dict + ) + _decline_auxiliary_client(monkeypatch, recorded_main_runtime) + + with pytest.raises(_RouteAgentCaptured): + routes._llm_git_commit_message("sys", "user", session=fake_session) + return captured["init_kwargs"] + + +def _drive_handoff_summary_route(monkeypatch, cfg_dict=None, runtime_dict=None): + """Consumer 4/5: on-demand handoff summary.""" + import api.models as models + import api.routes as routes + + captured, fake_session = _setup_route_consumer_runtime( + monkeypatch, + session_messages=_four_route_messages(), + cfg_dict=cfg_dict, + runtime_dict=runtime_dict, + ) + monkeypatch.setattr(models, "get_session", lambda _sid: fake_session) + monkeypatch.setattr( + models, + "count_conversation_rounds", + lambda _sid, since=None: models.CONVERSATION_ROUND_THRESHOLD + 1, + ) + monkeypatch.setattr( + models, "get_cli_session_messages", lambda _sid: _four_route_messages() + ) + monkeypatch.setattr(routes, "j", lambda _handler, payload, **_k: payload) + monkeypatch.setattr(routes, "bad", lambda _handler, message, *_a, **_k: {"error": message}) + + routes._handle_handoff_summary(object(), {"session_id": "session-1806-route"}) + return captured["init_kwargs"] + + +def _drive_update_summary_route( + monkeypatch, cfg_dict=None, runtime_dict=None, recorded_main_runtime=None +): + """Consumer 5/5: update summary (``_llm_update_summary``). + + This one resolves ``get_effective_default_model()`` rather than the session's + model, so the default has to name the slug under test. + """ + import api.routes as routes + + cfg_dict = copy.deepcopy(_KEYED_VS_LIST_CFG if cfg_dict is None else cfg_dict) + cfg_dict["model"] = { + "default": "@custom:omni:antigravity/gemini-3.7-flash-tiered", + "provider": "custom:omni", + } + captured, _fake_session = _setup_route_consumer_runtime( + monkeypatch, cfg_dict=cfg_dict, runtime_dict=runtime_dict + ) + _decline_auxiliary_client(monkeypatch, recorded_main_runtime) + + with pytest.raises(_RouteAgentCaptured): + routes._llm_update_summary("sys", "user", active_profile=None) + return captured["init_kwargs"] + + +_ROUTE_CONSUMER_DRIVERS = [ + (_drive_sync_chat_route, "sync chat (/api/chat)"), + (_drive_manual_compression_route, "manual compression (/compress)"), + (_drive_commit_message_route, "git commit message"), + (_drive_handoff_summary_route, "handoff summary"), + (_drive_update_summary_route, "update summary"), +] + + +@pytest.mark.parametrize( + "driver,label", _ROUTE_CONSUMER_DRIVERS, ids=[d[1] for d in _ROUTE_CONSUMER_DRIVERS] +) +def test_route_consumers_apply_exact_list_row_atomically(monkeypatch, driver, label): + """Every non-streaming consumer applies the exact row's URL *and* its key.""" + _assert_list_row_not_keyed(driver(monkeypatch), label) + + +# The two consumers whose auxiliary-client shortcut can answer the request +# outright -- for them ``main_runtime`` is the only carrier of the resolved +# authority, because AIAgent is never built. +_AUXILIARY_ROUTE_DRIVERS = [ + (_drive_commit_message_route, "git commit message"), + (_drive_update_summary_route, "update summary"), +] + + +@pytest.mark.parametrize( + "driver,label", + _AUXILIARY_ROUTE_DRIVERS, + ids=[d[1] for d in _AUXILIARY_ROUTE_DRIVERS], +) +def test_auxiliary_routes_hand_the_row_connection_to_the_auxiliary_client( + monkeypatch, driver, label +): + """The aux-client shortcut must see the row's connection, not the keyed one. + + It runs BEFORE the main-model constructor, so a consumer that resolved the + bundle only on the fallback path would still send this request to the keyed + endpoint. + """ + recorded_main_runtime = {} + driver(monkeypatch, recorded_main_runtime=recorded_main_runtime) + + assert recorded_main_runtime.get("base_url") == _LIST_ROW_URL, label + assert recorded_main_runtime.get("api_key") == _LIST_ROW_KEY, label + + +# ── The complete bundle, not just its three connection fields ──────────────── +# +# ``apply_custom_provider_connection_authority`` returns ``(provider, api_key, +# base_url)``. Every consumer above used to apply exactly that and construct the +# agent from it, so an exact row's ``api_mode`` and ``credential_pool`` -- the +# wire protocol the send speaks and the credential source it rotates -- were +# truncated away before AIAgent ever saw them, while the streaming path carried +# them through. The row below OWNS both, and the ambient runtime reports +# different values for all four side fields, so a truncating consumer fails on +# ``None`` and a pass-through consumer fails on the ambient sentinel. + + +_ROUTE_ROW_POOL_SENTINEL = ["list-row-pool-sentinel"] + +_ROUTE_OWNED_SIDE_FIELD_CFG = _exact_list_row_cfg( + api_mode="anthropic_messages", + credential_pool=_ROUTE_ROW_POOL_SENTINEL, +) + +# The ambient runtime disagrees on every side field it reports. +_ROUTE_AMBIENT_RUNTIME = { + **_ROUTE_KEYED_RUNTIME, + "api_mode": "chat_completions", + "credential_pool": ["ambient-pool-sentinel"], + "command": "ambient-acp-sentinel", + "args": ["--ambient-arg-sentinel"], +} + + +@pytest.mark.parametrize( + "driver,label", _ROUTE_CONSUMER_DRIVERS, ids=[d[1] for d in _ROUTE_CONSUMER_DRIVERS] +) +def test_route_consumers_pass_the_exact_rows_side_fields_to_the_constructor( + monkeypatch, driver, label +): + """The exact row's ``api_mode``/``credential_pool`` reach the FINAL constructor.""" + init_kwargs = driver( + monkeypatch, + cfg_dict=copy.deepcopy(_ROUTE_OWNED_SIDE_FIELD_CFG), + runtime_dict=copy.deepcopy(_ROUTE_AMBIENT_RUNTIME), + ) + + _assert_list_row_not_keyed(init_kwargs, label) + _assert_side_fields( + init_kwargs, + { + "api_mode": "anthropic_messages", + "credential_pool": _ROUTE_ROW_POOL_SENTINEL, + # The row declares no ACP transport and owns a different endpoint + # than the runtime, so the ambient subprocess is provably foreign. + "acp_command": None, + "acp_args": None, + }, + f"{label}: exact row side fields", + ) + assert init_kwargs["api_mode"] != _ROUTE_AMBIENT_RUNTIME["api_mode"], ( + f"{label}: the ambient wire protocol reached the constructor" + ) + assert init_kwargs["credential_pool"] != _ROUTE_AMBIENT_RUNTIME["credential_pool"], ( + f"{label}: the ambient credential pool reached the constructor" + ) + + +@pytest.mark.parametrize( + "driver,label", _ROUTE_CONSUMER_DRIVERS, ids=[d[1] for d in _ROUTE_CONSUMER_DRIVERS] +) +def test_route_consumers_clear_foreign_ambient_side_fields(monkeypatch, driver, label): + """A row that owns NO side fields still strips the ambient provider's. + + Complement of the test above: "carry the complete bundle" must not degrade + into "pass the runtime's side fields through". The row here declares none of + them and owns a different endpoint, so all four are provably foreign. + """ + init_kwargs = driver( + monkeypatch, runtime_dict=copy.deepcopy(_ROUTE_AMBIENT_RUNTIME) + ) + + _assert_list_row_not_keyed(init_kwargs, label) + _assert_side_fields(init_kwargs, _FOREIGN_AMBIENT_SIDE_FIELDS, f"{label}: foreign ambient") + + +@pytest.mark.parametrize( + "driver,label", + _AUXILIARY_ROUTE_DRIVERS, + ids=[d[1] for d in _AUXILIARY_ROUTE_DRIVERS], +) +def test_auxiliary_routes_hand_the_complete_bundle_to_the_auxiliary_client( + monkeypatch, driver, label +): + """``main_runtime`` carries the WHOLE bundle, not its three connection fields. + + When the auxiliary client answers, AIAgent is bypassed entirely, so the + side fields that never entered ``main_runtime`` were simply lost: the exact + row's ``api_mode: anthropic_messages`` silently degraded to the aux client's + default wire protocol, and the credential pool/ACP transport the row owns + never reached the send. The aux dict must therefore agree with the fallback + constructor field for field -- same authority, whichever path answers. + """ + import api.routes as routes + + recorded_main_runtime = {} + init_kwargs = driver( + monkeypatch, + cfg_dict=copy.deepcopy(_ROUTE_OWNED_SIDE_FIELD_CFG), + runtime_dict=copy.deepcopy(_ROUTE_AMBIENT_RUNTIME), + recorded_main_runtime=recorded_main_runtime, + ) + + _assert_list_row_not_keyed(recorded_main_runtime, f"{label}: aux main_runtime") + _assert_side_fields( + recorded_main_runtime, + { + "api_mode": "anthropic_messages", + "credential_pool": _ROUTE_ROW_POOL_SENTINEL, + # The row declares no ACP transport and owns a different endpoint + # than the runtime, so the ambient subprocess is provably foreign. + "acp_command": None, + "acp_args": None, + }, + f"{label}: aux main_runtime side fields", + ) + assert recorded_main_runtime["api_mode"] != _ROUTE_AMBIENT_RUNTIME["api_mode"], ( + f"{label}: the ambient wire protocol reached the auxiliary client" + ) + assert ( + recorded_main_runtime["credential_pool"] + != _ROUTE_AMBIENT_RUNTIME["credential_pool"] + ), f"{label}: the ambient credential pool reached the auxiliary client" + + for field in ("provider", "model", "base_url", "api_key") + tuple( + routes._AGENT_BUNDLE_SIDE_FIELDS + ): + assert recorded_main_runtime.get(field) == init_kwargs[field], ( + f"{label}: aux {field} disagrees with the fallback constructor, so " + "which path answers decides the authority" + ) + assert recorded_main_runtime["model"], f"{label}: aux main_runtime lost the model" + + +@pytest.mark.parametrize( + "driver,label", + _AUXILIARY_ROUTE_DRIVERS, + ids=[d[1] for d in _AUXILIARY_ROUTE_DRIVERS], +) +def test_auxiliary_routes_clear_foreign_ambient_side_fields(monkeypatch, driver, label): + """A row owning no side fields strips the ambient provider's from the aux dict too. + + Complement of the test above: "carry the complete bundle into + ``main_runtime``" must not degrade into "pass the runtime's side fields + through" on the path where nothing downstream re-resolves them. + """ + recorded_main_runtime = {} + driver( + monkeypatch, + runtime_dict=copy.deepcopy(_ROUTE_AMBIENT_RUNTIME), + recorded_main_runtime=recorded_main_runtime, + ) + + _assert_list_row_not_keyed(recorded_main_runtime, f"{label}: aux main_runtime") + _assert_side_fields( + recorded_main_runtime, + _FOREIGN_AMBIENT_SIDE_FIELDS, + f"{label}: aux foreign ambient", + ) + + +def test_capturing_route_agent_exposes_every_runtime_constructor_field(monkeypatch): + """Guard the guard, route edition. + + The consumers gate each optional kwarg on + ``inspect.signature(AIAgent.__init__).parameters``. A double that swallowed + ``api_mode`` / ``credential_pool`` / ``acp_command`` / ``acp_args`` into + ``**kwargs`` would make every assertion above pass vacuously, because the + fields would never be passed at all. + """ + import inspect + + import api.routes as routes + + _captured, _fake_session = _setup_route_consumer_runtime(monkeypatch) + agent_cls = routes.require_ai_agent_class() + params = set(inspect.signature(agent_cls.__init__).parameters) + + for field in routes._AGENT_BUNDLE_SIDE_FIELDS: + assert field in params, ( + f"the route double hides {field} behind **kwargs, so the " + "signature gate would filter it out and the assertions would be vacuous" + ) + + +def test_production_composed_retry_caches_agent_under_recomputed_signature(monkeypatch): + """Retry agents are cached under the healed bundle's signature, not the stale initial one.""" + import api.streaming as streaming + + computed_signatures = [] + real_compute_sig = streaming._compute_agent_cache_signature + + def tracking_compute_sig(*args, **kwargs): + sig = real_compute_sig(*args, **kwargs) + computed_signatures.append(sig) + return sig + + monkeypatch.setattr(streaming, "_compute_agent_cache_signature", tracking_compute_sig) + + stream_id, q, captured, restore = _setup_production_composed_runtime( + monkeypatch, + dict(_KEYED_VS_LIST_CFG), + dict(_AMBIENT_SIDE_FIELD_RUNTIME), + session_id="session-1806-retry-cache-sig", + fail_first="returned_error", + ) + # Give self-heal a distinct provider/endpoint so initial and healed signatures differ + healed_rt = dict(_AMBIENT_SIDE_FIELD_RUNTIME) + healed_rt["base_url"] = "https://healed-endpoint.example/v1" + monkeypatch.setattr( + streaming, + "_attempt_credential_self_heal", + lambda *_args, **_kwargs: dict(healed_rt), + ) + try: + streaming.STREAMS[stream_id] = q + streaming._run_agent_streaming( + session_id="session-1806-retry-cache-sig", + msg_text="hello", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + workspace="/tmp", + stream_id=stream_id, + ) + assert len(computed_signatures) >= 2, "signature was not recomputed on retry" + with config.SESSION_AGENT_CACHE_LOCK: + cached = config.SESSION_AGENT_CACHE.get("session-1806-retry-cache-sig") + assert cached is not None, "retry agent was not cached" + cached_agent, cached_sig = cached + assert cached_agent is not None + assert cached_sig == computed_signatures[-1] + finally: + streaming.STREAMS.pop(stream_id, None) + streaming.AGENT_INSTANCES.pop(stream_id, None) + restore() + + +# ───────────────────────────────────────────────────────────────────────────── +# Identity-owned selection, and the two ways a route can be "keyless" +# +# Selection for a named ``custom:`` must be owned by that identity: an +# exact normalized ``custom_providers[]`` row, an exact keyed record, or an +# explicitly matching ``model:``/bare-``custom`` authority. "The list happens to +# hold exactly one row, so use it" is NOT ownership — it resolved ``custom:ghost`` +# to the endpoint AND credential of a sole unrelated row named ``omni``, sending +# the prompt and that credential to a provider the user never named. +# +# The second half of the same contract is ``dummy-key``. It asserts "this +# endpoint is UNAUTHENTICATED", so it may only appear when the record declares no +# credential source at all. A DECLARED credential that failed to resolve (unset +# ``${ENV}``, ``key_env`` naming a missing variable, a pool that yielded nothing) +# is a misconfiguration to surface, not an unauthenticated endpoint: substituting +# the placeholder there reports it as an opaque 401 from the endpoint instead. +# ───────────────────────────────────────────────────────────────────────────── + + +_SOLE_UNRELATED_ROW_CFG = { + "model": {"default": "active/model", "provider": "custom:omni"}, + "custom_providers": [ + { + "name": "omni", + "base_url": "https://omni.example/v1", + "api_key": "omni-key", + }, + ], +} + + +def _with_direct_config(monkeypatch, cfg_dict): + """Point both ``config.cfg`` and ``get_config()`` at ``cfg_dict``.""" + monkeypatch.setattr(config, "get_config", lambda: copy.deepcopy(cfg_dict)) + monkeypatch.setitem(config.cfg, "custom_providers", cfg_dict.get("custom_providers", [])) + monkeypatch.setitem(config.cfg, "model", cfg_dict.get("model", {})) + monkeypatch.setitem(config.cfg, "providers", cfg_dict.get("providers", {})) + + +def test_unknown_named_slug_does_not_select_a_sole_unrelated_list_row(monkeypatch): + """The reviewer's probe: ``custom:ghost`` must not inherit sole row ``omni``. + + ``_select_custom_provider_record`` used to append ``custom_providers[0]`` + whenever the list held exactly one row, then accept it merely because it had + a key or a URL. Neither test is ownership: ``ghost`` and ``omni`` are + different identities, so the pair belongs to ``omni`` alone. + """ + _with_direct_config(monkeypatch, _SOLE_UNRELATED_ROW_CFG) + + assert config.resolve_custom_provider_connection("custom:ghost") == (None, None) + # The row is still authoritative for its OWN slug. + assert config.resolve_custom_provider_connection("custom:omni") == ( + "omni-key", + "https://omni.example/v1", + ) + + +def test_unknown_named_slug_reports_an_explicit_missing_selection(monkeypatch): + """The bundle carries the missing verdict instead of an ambiguous ``None``. + + ``keyless`` must be False on it: "nothing owns this route" is not a claim + that the route is unauthenticated, and it is ``keyless`` alone that gates + ``dummy-key``. + """ + _with_direct_config(monkeypatch, _SOLE_UNRELATED_ROW_CFG) + + ghost = config.resolve_custom_provider_bundle("custom:ghost") + assert ghost is not None, "a named custom route must report its selection outcome" + assert ghost["status"] == config.CUSTOM_SELECTION_MISSING + assert ghost["record"] is None + assert ghost["base_url"] is None + assert ghost["api_key"] is None + assert ghost["keyless"] is False, "a missing route must not be reported keyless" + assert ghost["owned"] == {} + + owned = config.resolve_custom_provider_bundle("custom:omni") + assert owned["status"] == config.CUSTOM_SELECTION_EXACT + assert owned["base_url"] == "https://omni.example/v1" + + +def test_unknown_named_slug_keeps_no_ambient_connection(monkeypatch): + """The merge refuses the ambient provider's URL/key/pool for an unowned slug.""" + _with_direct_config(monkeypatch, _SOLE_UNRELATED_ROW_CFG) + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:ghost", + "ambient-key-sentinel", + "https://ambient.example/v1", + dict(_AMBIENT_SIDE_FIELD_RUNTIME), + lookup_provider="custom:ghost", + ) + + assert bundle["base_url"] is None + assert bundle["api_key"] is None + assert bundle["api_key"] != config.KEYLESS_CUSTOM_API_KEY + # The provider must NOT be rewritten to generic ``custom``: that would + # present an unresolvable route as a resolved one. + assert bundle["provider"] == "custom:ghost" + _assert_side_fields(bundle, _FOREIGN_AMBIENT_SIDE_FIELDS, "unowned named slug") + + +# A provider LABEL is not proof of ownership. These two cases are how an unowned +# slug could still walk away with the ambient connection: +# +# 1. the runtime dict names ITSELF ``custom:ghost`` -- a self-assigned string +# on a connection the process authenticated for some other reason; and +# 2. no runtime dict at all, the non-streaming consumers' shape, where +# ``_connection_identity`` falls back to ``resolved_provider`` -- the very +# slug being looked up -- so the route would match itself. +# +# Both must fail closed exactly as the differently-labelled ambient runtime does: +# ownership is decided by config records, and here there are none. + + +_SELF_LABELLED_GHOST_RUNTIME = { + **_AMBIENT_SIDE_FIELD_RUNTIME, + "provider": "custom:ghost", + "base_url": "https://ambient.example/v1", + "api_key": "ambient-key-sentinel", +} + + +def _assert_ghost_bundle_failed_closed(bundle, label): + """No endpoint, no credential, no placeholder, no ambient side fields.""" + assert bundle["base_url"] is None, f"{label}: an unowned slug kept an endpoint" + assert bundle["api_key"] is None, f"{label}: an unowned slug kept a credential" + assert bundle["api_key"] != config.KEYLESS_CUSTOM_API_KEY, ( + f"{label}: an unresolvable route was handed the keyless placeholder" + ) + # Still the NAMED slug: rewriting it to generic ``custom`` would present an + # unresolvable route as a resolved one. + assert bundle["provider"] == "custom:ghost", label + _assert_side_fields(bundle, _FOREIGN_AMBIENT_SIDE_FIELDS, label) + + +@pytest.mark.parametrize( + "runtime_dict,label", + [ + (_SELF_LABELLED_GHOST_RUNTIME, "runtime labels itself custom:ghost"), + (None, "no runtime dict (non-streaming caller shape)"), + ], + ids=["self-labelled-runtime", "no-runtime-dict"], +) +def test_unknown_named_slug_fails_closed_even_when_the_runtime_claims_the_slug( + monkeypatch, runtime_dict, label +): + """A matching provider label must not resurrect the ambient connection. + + Nothing in config owns ``custom:ghost``: not an exact ``custom_providers[]`` + row, not a keyed ``providers:`` record, not a ``model:`` authority. The only + thing pointing at the slug is a string the caller supplied, so keeping the + URL, key, wire protocol and credential pool that came with it would send the + user's prompt and that credential to a provider they never configured. + """ + _with_direct_config(monkeypatch, _SOLE_UNRELATED_ROW_CFG) + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:ghost", + "ambient-key-sentinel", + "https://ambient.example/v1", + copy.deepcopy(runtime_dict) if runtime_dict else runtime_dict, + lookup_provider="custom:ghost", + ) + + _assert_ghost_bundle_failed_closed(bundle, label) + assert bundle["base_url"] != "https://ambient.example/v1", label + assert bundle["api_key"] != "ambient-key-sentinel", label + + +def test_unknown_named_slug_connection_view_also_fails_closed(monkeypatch): + """The three-field view reports the same verdict, and ``custom_owned`` False. + + Callers that genuinely construct nothing else still take this view, so the + self-labelled runtime must not reach them with a connection either. + """ + _with_direct_config(monkeypatch, _SOLE_UNRELATED_ROW_CFG) + + provider, api_key, base_url, custom_owned = ( + config.apply_custom_provider_connection_authority( + "custom:ghost", + "ambient-key-sentinel", + "https://ambient.example/v1", + lookup_provider="custom:ghost", + runtime_provider=copy.deepcopy(_SELF_LABELLED_GHOST_RUNTIME), + ) + ) + + assert base_url is None + assert api_key is None + assert provider == "custom:ghost" + assert custom_owned is False, "nothing owns the slug, so the record cannot be reported as owning it" + + +def test_unowned_named_slug_fails_closed_at_the_first_streaming_resolution(monkeypatch): + """The initial resolution refuses the unrelated row — terminally. + + The ambient runtime seeds a truthy URL, key and pool (plus api_mode and an + ACP transport), and config holds exactly ONE custom row — named ``omni``, + with a truthy URL and key of its own. The send asks for ``custom:ghost``, + which nothing owns. + + This covers the FIRST of the three streaming regions that build an agent, + and only that one: the verdict is terminal here, so no agent is constructed, + no turn is sent, and no 401 exists for the two self-heal retries to act on. + Parametrizing ``fail_first`` over this case would re-run identical code and + prove nothing about those retry guards — they are exercised directly by + ``test_retry_abandons_the_heal_*`` below, which start from a ROUTABLE route + and break it mid-turn. + """ + label = "initial send" + session_id = "session-1806-ghost-initial" + + captured, apperrors = _run_composed_send_expecting_refusal( + monkeypatch, + copy.deepcopy(_SOLE_UNRELATED_ROW_CFG), + dict(_AMBIENT_SIDE_FIELD_RUNTIME), + session_id, + model="@custom:ghost:antigravity/gemini-3.7-flash-tiered", + ) + + payload = apperrors[-1] + assert "custom:ghost" in payload["message"], f"{label}: {payload}" + assert "is not configured" in payload["message"], f"{label}: {payload}" + + # Nothing that could have been routed to may appear on the refusal path: + # not the unrelated sole row's pair, not the ambient provider's, and never + # the keyless placeholder. + blob = str(payload) + str(captured) + for leaked in ( + "https://omni.example/v1", + "omni-key", + _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], + _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], + config.KEYLESS_CUSTOM_API_KEY, + ): + assert leaked not in blob, f"{label}: {leaked!r} leaked into the refused route" + + assert not captured.get("explicit_client_kwargs_calls"), ( + f"{label}: a client was configured for a slug nothing owns" + ) + + +# ── declared-but-unresolved credentials are NOT keyless ────────────────────── + + +_MISSING_ENV_VAR = "HERMES_TEST_1806_UNSET_CREDENTIAL" + + +def _clear_credential_env(monkeypatch): + """Remove every env var the credential ladder could still mint a key from.""" + monkeypatch.delenv(_MISSING_ENV_VAR, raising=False) + monkeypatch.delenv("CUSTOM_OMNI_API_KEY", raising=False) + + +_UNRESOLVED_CREDENTIAL_ROWS = [ + # A declared ``${ENV}`` reference whose variable is unset. + ({"api_key": "${" + _MISSING_ENV_VAR + "}"}, "env-reference"), + # A declared ``key_env`` naming a variable that does not exist. + ({"key_env": _MISSING_ENV_VAR}, "key-env"), + # A configured credential pool that yields nothing for this endpoint. + ({"credential_pool": ["configured-pool-sentinel"]}, "unavailable-pool"), +] + + +@pytest.mark.parametrize("row_fields,label", _UNRESOLVED_CREDENTIAL_ROWS) +def test_declared_but_unresolved_credential_is_not_keyless(monkeypatch, row_fields, label): + """``keyless`` means "declares no credential", not "resolved no credential". + + Each row here DECLARES a credential source that produces nothing. Reporting + that as keyless is what let ``dummy-key`` be substituted for a genuinely + missing credential, turning a fixable misconfiguration into an opaque 401. + """ + _clear_credential_env(monkeypatch) + cfg_dict = _exact_list_row_cfg(drop=("api_key",), **row_fields) + _with_direct_config(monkeypatch, cfg_dict) + + bundle = config.resolve_custom_provider_bundle("custom:omni") + + assert bundle["api_key"] is None, f"{label}: the credential must not resolve here" + assert bundle["keyless"] is False, ( + f"{label}: a declared credential source was reported as keyless" + ) + + +@pytest.mark.parametrize("row_fields,label", _UNRESOLVED_CREDENTIAL_ROWS) +def test_declared_but_unresolved_credential_never_gets_the_dummy_key( + monkeypatch, row_fields, label +): + """End to end: the send STOPS — neither the placeholder nor a keyless send. + + The endpoint resolves (the row owns it), but the declared credential source + produced nothing. Sending anyway with ``api_key=None`` is not the safe + middle ground it looks like: with only one of the pair truthy, AIAgent falls + through to ``_routed_client_kwargs()`` and re-resolves a provider, so the + turn — and whatever credential init then finds — leaves for an endpoint the + user never chose. The turn ends on the actionable cause instead. + """ + _clear_credential_env(monkeypatch) + + captured, apperrors = _run_composed_send_expecting_refusal( + monkeypatch, + _exact_list_row_cfg(drop=("api_key",), **row_fields), + _ambient_runtime(), + f"session-1806-unresolved-{label}", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + ) + + payload = apperrors[-1] + assert "produced no API key" in payload["message"], f"{label}: {payload}" + assert "custom:omni" in payload["message"], ( + f"{label}: the refusal did not name the provider that failed: {payload}" + ) + assert config.KEYLESS_CUSTOM_API_KEY not in str(payload), ( + f"{label}: keyless placeholder masked a declared-but-missing credential" + ) + assert "keyed-key-sentinel-abc" not in str(payload), ( + f"{label}: the ambient keyed record leaked into the refusal" + ) + # The row owns its endpoint, so nothing may have been dialled with it. + assert not captured.get("explicit_client_kwargs_calls"), f"{label}: a client was configured" + + +def test_row_declaring_no_credential_is_still_keyless(monkeypatch): + """The negative control: a genuinely unauthenticated endpoint keeps ``dummy-key``. + + Local OpenAI-compatible servers routinely run without auth, and tightening + the keyless rule must not take that away — otherwise every keyless setup + regresses into an agent built with no credential at all. + """ + _clear_credential_env(monkeypatch) + + init_kwargs = _run_composed_send( + monkeypatch, + _exact_list_row_cfg(drop=("api_key",)), + _ambient_runtime(), + "session-1806-genuinely-keyless", + ) + + assert init_kwargs["api_key"] == config.KEYLESS_CUSTOM_API_KEY + assert init_kwargs["base_url"] == _LIST_ROW_URL + assert init_kwargs["provider"] == "custom" + + +# ── the exact row's credential is never the same-endpoint keyed row's ─────── +# +# Every unresolved-credential case above puts the keyed record on a DIFFERENT +# endpoint, so the merge's provenance test already proves the ambient credential +# foreign. That leaves the collision untested: a keyed +# ``providers["custom:"]`` record may declare the SAME ``base_url`` as the +# exact ``custom_providers[]`` row, and then endpoint equality alone cannot tell +# the row's own resolution from the keyed row's. Inferring "same authority" from +# the URL there hands the keyed row's key to the exact row — the row's endpoint +# married to somebody else's credential, which is the split-authority merge the +# whole module exists to prevent. +# +# An exact row is a COMPLETE record: its credential comes from its own ladder or +# it does not come at all. + + +_SHARED_ENDPOINT_URL = "https://shared-url-sentinel.example/v1" +_SHARED_ENDPOINT_KEYED_KEY = "shared-endpoint-keyed-sentinel-def" + + +def _shared_endpoint_cfg(**row_fields): + """Exact list row and same-slug keyed record on the IDENTICAL ``base_url``.""" + return { + "model": {"default": "active/model", "provider": "custom:active"}, + "providers": { + "custom:omni": { + "base_url": _SHARED_ENDPOINT_URL, + "api_key": _SHARED_ENDPOINT_KEYED_KEY, + }, + }, + "custom_providers": [ + {"name": "omni", "base_url": _SHARED_ENDPOINT_URL, **row_fields}, + ], + } + + +def _shared_endpoint_runtime(): + """The ambient runtime that resolution of the KEYED record produces. + + Same endpoint as the exact row, carrying the keyed record's credential — the + state in which URL equality stops being evidence of provenance. + """ + return _ambient_runtime( + base_url=_SHARED_ENDPOINT_URL, api_key=_SHARED_ENDPOINT_KEYED_KEY + ) + + +@pytest.mark.parametrize("row_fields,label", _UNRESOLVED_CREDENTIAL_ROWS) +def test_exact_row_unresolved_credential_ignores_same_endpoint_keyed_key( + monkeypatch, row_fields, label +): + """The row DECLARED a credential that produced nothing: the route is terminal. + + The keyed record sharing the endpoint changes nothing about that. If the + merge accepts ``_rt["api_key"]`` because the URLs match, the bundle silently + authenticates the row's endpoint with the keyed row's secret and reports + itself routable, so the user never learns their ``key_env`` is unset. + """ + _clear_credential_env(monkeypatch) + _with_direct_config(monkeypatch, _shared_endpoint_cfg(**row_fields)) + + resolved = config.resolve_custom_provider_bundle("custom:omni") + assert resolved["is_exact"] is True, f"{label}: the exact row was not selected" + assert resolved["keyless"] is False, ( + f"{label}: a declared credential source was reported as keyless" + ) + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + _SHARED_ENDPOINT_KEYED_KEY, + _SHARED_ENDPOINT_URL, + _shared_endpoint_runtime(), + lookup_provider="custom:omni", + ) + + assert bundle["base_url"] == _SHARED_ENDPOINT_URL, f"{label}: the row lost its endpoint" + assert bundle["api_key"] is None, ( + f"{label}: the exact row borrowed the same-endpoint keyed credential: {bundle!r}" + ) + assert bundle["api_key"] != _SHARED_ENDPOINT_KEYED_KEY, f"{label}: {bundle!r}" + assert bundle["api_key"] != config.KEYLESS_CUSTOM_API_KEY, ( + f"{label}: the placeholder masked a declared-but-missing credential" + ) + verdict = config.custom_provider_route_error(bundle) + assert verdict, f"{label}: an unroutable bundle reported itself routable: {bundle!r}" + assert verdict["reason"] == config.CUSTOM_ROUTE_NO_CREDENTIAL, f"{label}: {verdict}" + + +@pytest.mark.parametrize("row_fields,label", _UNRESOLVED_CREDENTIAL_ROWS) +def test_composed_send_refuses_exact_row_sharing_the_keyed_endpoint( + monkeypatch, row_fields, label +): + """End to end: the turn stops instead of dialling with the keyed row's key. + + The production-composed path is where the borrowed credential would actually + be spent — the endpoint resolves, so nothing downstream would question a + truthy ``api_key``. + """ + _clear_credential_env(monkeypatch) + + captured, apperrors = _run_composed_send_expecting_refusal( + monkeypatch, + _shared_endpoint_cfg(**row_fields), + _shared_endpoint_runtime(), + f"session-1806-shared-endpoint-{label}", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + ) + + payload = apperrors[-1] + assert "produced no API key" in payload["message"], f"{label}: {payload}" + assert "custom:omni" in payload["message"], f"{label}: {payload}" + assert _SHARED_ENDPOINT_KEYED_KEY not in str(payload), ( + f"{label}: the same-endpoint keyed credential leaked into the refusal" + ) + assert config.KEYLESS_CUSTOM_API_KEY not in str(payload), f"{label}: {payload}" + assert not captured.get("explicit_client_kwargs_calls"), ( + f"{label}: a client was configured for a route whose credential failed" + ) + + +def test_exact_row_declaring_no_credential_ignores_the_shared_endpoint_key(monkeypatch): + """The other half of the rule: a keyless row does not borrow the key either. + + The row declares NO credential, which is its own statement that the endpoint + is unauthenticated. ``dummy-key`` follows from the row, not from the keyed + record that happens to point at the same URL — so the route stays routable + and the keyed secret still never leaves the keyed record. + """ + _clear_credential_env(monkeypatch) + _with_direct_config(monkeypatch, _shared_endpoint_cfg()) + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + _SHARED_ENDPOINT_KEYED_KEY, + _SHARED_ENDPOINT_URL, + _shared_endpoint_runtime(), + lookup_provider="custom:omni", + ) + + assert bundle["base_url"] == _SHARED_ENDPOINT_URL + assert bundle["api_key"] == config.KEYLESS_CUSTOM_API_KEY, ( + f"the keyless row did not keep its own credential verdict: {bundle!r}" + ) + assert bundle["api_key"] != _SHARED_ENDPOINT_KEYED_KEY, bundle + assert config.custom_provider_route_error(bundle) is None, bundle + + +# The exact row's ENDPOINT obeys the same positive-tie rule as every other +# record branch. "Complete record" decides whose endpoint and whose credential +# the route gets; it does not decide the SPELLING of an endpoint both sides +# already agree on. +_ROW_OWN_KEY = "row-own-key-sentinel-ghi" +_FOREIGN_AMBIENT_URL = "https://ambient-sentinel.example/v1" + + +@pytest.mark.parametrize( + "runtime_base_url,label", + [ + (_SHARED_ENDPOINT_URL, "same normalized URL from distinct keyed record"), + (_FOREIGN_AMBIENT_URL, "foreign ambient endpoint"), + ], + ids=["same-normalized-url", "foreign-endpoint"], +) +def test_exact_row_preserves_own_endpoint_beside_distinct_keyed_runtime( + monkeypatch, runtime_base_url, label +): + """An exact row preserves its own declared endpoint spelling beside a distinct record. + + Endpoint equality alone is NOT selected-record provenance. When the runtime + carries credentials from a same-slug keyed record (_SHARED_ENDPOINT_KEYED_KEY) + while the exact row declares _ROW_OWN_KEY, matching normalized URL spelling + does not tie the runtime to this row. The selected exact row's own endpoint + spelling stands in both cases. + """ + _clear_credential_env(monkeypatch) + _with_direct_config( + monkeypatch, + _shared_endpoint_cfg(base_url=_SHARED_ENDPOINT_URL + "/", api_key=_ROW_OWN_KEY), + ) + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + _SHARED_ENDPOINT_KEYED_KEY, + runtime_base_url, + _ambient_runtime(base_url=runtime_base_url, api_key=_SHARED_ENDPOINT_KEYED_KEY), + lookup_provider="custom:omni", + ) + + assert bundle["base_url"] == _SHARED_ENDPOINT_URL + "/", f"{label}: {bundle!r}" + assert bundle["base_url"] != _FOREIGN_AMBIENT_URL, ( + f"{label}: the row was pointed at the ambient provider's endpoint" + ) + assert bundle["api_key"] == _ROW_OWN_KEY, ( + f"{label}: the exact row did not supply its own credential: {bundle!r}" + ) + assert bundle["api_key"] != _SHARED_ENDPOINT_KEYED_KEY, f"{label}: {bundle!r}" + assert config.custom_provider_route_error(bundle) is None, f"{label}: {bundle!r}" + + +# ───────────────────────────────────────────────────────────────────────────── +# The two 401 self-heal RETRIES are route boundaries too +# +# Everything above stops a terminal route at the FIRST resolution, where no agent +# exists yet. The self-heal retries are a different boundary, and a strictly +# worse one: the route WAS routable when the turn started, an agent was built and +# written to ``SESSION_AGENT_CACHE`` under a valid bundle signature, the provider +# answered 401, and only THEN does the re-resolve run. Whatever that second +# resolution returns is what the retry agent gets constructed from. +# +# That window is real state, not a hypothetical: between the first send and the +# heal the owning ``custom_providers[]`` row can be edited, renamed or deleted in +# Settings, and its ``key_env`` / pool can stop yielding a credential. The +# refreshed bundle is then terminal, and building the retry agent from it hands +# ``_init_openai_client()`` an incomplete pair — so the retry, the send that +# actually carries the user's prompt, is the one that re-enters +# ``_routed_client_kwargs()``. It also overwrites a GOOD cache entry with the +# poisoned agent, so every later turn in the session reuses it. +# +# ``heal_mutate`` runs inside the stubbed ``_attempt_credential_self_heal`` — +# after the first agent has already failed with its 401, before the retry +# re-resolves its bundle — which is the only point at which that transition can +# be staged. Each test below asserts the first send really happened (one agent, +# one turn, one cache write), so a regression that makes the route terminal up +# front cannot pass these by the back door. +# ───────────────────────────────────────────────────────────────────────────── + + +# No ``providers:`` block and a ``model.provider`` naming an UNRELATED slug: the +# single list row is the ONLY authority for ``custom:omni``, so removing or +# renaming it below leaves the slug owned by nothing at all. A same-slug keyed +# record or a ``model:`` block naming ``custom:omni`` would each still be an +# authority and would make the "unowned" cases test something weaker. +_RETRY_OWNED_CFG = { + "model": {"default": "active/model", "provider": "custom:active"}, + "custom_providers": [ + { + "name": "omni", + "base_url": _LIST_ROW_URL, + "api_key": _LIST_ROW_KEY, + }, + ], +} + +_RETRY_MODEL = "@custom:omni:antigravity/gemini-3.7-flash-tiered" + + +def _retry_cfg_rows(**row_fields): + """The owning row with ``row_fields`` applied; ``None`` values drop the field.""" + row = dict(_RETRY_OWNED_CFG["custom_providers"][0]) + for field, value in row_fields.items(): + if value is None: + row.pop(field, None) + else: + row[field] = value + return [row] + + +def _drop_the_owning_row(): + """The row is deleted mid-turn — nothing owns ``custom:omni`` any more.""" + config.cfg["custom_providers"] = [] + + +def _rename_the_owning_row(): + """The row is renamed mid-turn, so it now owns a DIFFERENT slug. + + The pair is still sitting in config, which is exactly the shape that made + the sole-unrelated-row fallback look harmless: ``custom:omni`` must not + inherit it just because it is the only row left. + """ + config.cfg["custom_providers"] = _retry_cfg_rows(name="omni-renamed") + + +def _malform_the_owning_row(): + """The row loses its name, so it names no slug and therefore owns none. + + ``CUSTOM_SELECTION_MALFORMED`` proper describes a bare ``custom:`` lookup + with no slug behind it, which cannot arise mid-turn — the lookup is fixed at + the first resolve. A row that stops naming any identity is the reachable + malformed-record analogue, and it must fail closed the same way. + """ + config.cfg["custom_providers"] = _retry_cfg_rows(name=None) + + +_RETRY_UNOWNED_MUTATIONS = [ + (_drop_the_owning_row, "deleted", "row deleted"), + (_rename_the_owning_row, "renamed", "row renamed to another slug"), + (_malform_the_owning_row, "unnamed", "row lost its name"), +] + + +def _retry_endpoint_mutation(): + """Return a heal mutation that removes only the owning row's endpoint.""" + + def _mutate(): + # Keep the credential so this is the exact credential-only record shape: + # the row still owns the named route, but cannot supply an endpoint. + config.cfg["custom_providers"] = _retry_cfg_rows(base_url=None) + + return _mutate + + +def _retry_credential_mutation(row_fields): + """Return a heal mutation that keeps the endpoint but breaks the credential.""" + + def _mutate(): + # ``api_key`` first so a row that DECLARES one (the ``${ENV}`` shape) + # overrides the drop, while ``key_env`` / ``credential_pool`` rows keep + # it dropped. Either way the row still owns its endpoint. + fields = {"api_key": None, **row_fields} + config.cfg["custom_providers"] = _retry_cfg_rows(**fields) + + return _mutate + + +def _run_composed_retry_expecting_abandoned_heal( + monkeypatch, cfg_dict, runtime_dict, session_id, *, fail_first, heal_mutate +): + """Drive a send that starts routable and turns terminal at the 401 retry. + + Returns ``(captured, apperrors, cache_at_heal, cache_after)``. Both cache + views are read while the worker's entries are still live — ``restore()`` + clears ``SESSION_AGENT_CACHE`` — so the caller can compare what the initial + send cached against what survived the abandoned heal. + """ + import api.streaming as streaming + + cache_at_heal = {} + + def _stage_the_terminal_retry(): + # Snapshot the entry the FIRST (routable) agent was cached under, taken + # before the route is broken. The abandoned retry must leave it exactly + # as it is: replacing it is how one mid-turn config edit would re-route + # every remaining turn in the session. + with config.SESSION_AGENT_CACHE_LOCK: + cache_at_heal.update(config.SESSION_AGENT_CACHE) + heal_mutate() + + stream_id, q, captured, restore = _setup_production_composed_runtime( + monkeypatch, + copy.deepcopy(cfg_dict), + runtime_dict, + session_id=session_id, + fail_first=fail_first, + heal_mutate=_stage_the_terminal_retry, + ) + try: + streaming.STREAMS[stream_id] = q + streaming._run_agent_streaming( + session_id=session_id, + msg_text="hello", + model=_RETRY_MODEL, + workspace="/tmp", + stream_id=stream_id, + ) + apperrors = _drain_apperrors(q) + with config.SESSION_AGENT_CACHE_LOCK: + cache_after = dict(config.SESSION_AGENT_CACHE) + finally: + streaming.STREAMS.pop(stream_id, None) + streaming.AGENT_INSTANCES.pop(stream_id, None) + restore() + return captured, apperrors, cache_at_heal, cache_after + + +def _assert_retry_abandoned( + captured, + apperrors, + cache_at_heal, + cache_after, + session_id, + label, + *, + expected_cause, + expected_initial_url=_LIST_ROW_URL, + expected_initial_key=_LIST_ROW_KEY, +): + """Assert the retry stopped at the refreshed route verdict, not at the 401.""" + # (0) The case is only meaningful if the FIRST send was routable and really + # ran. Without this, a regression that made the route terminal at initial + # resolution would satisfy every assertion below for the wrong reason. + history = captured.get("init_kwargs_history", []) + assert history, f"{label}: the initial send never constructed an agent" + assert history[0]["base_url"] == expected_initial_url, ( + f"{label}: the initial send did not resolve the owning row, so no 401 " + f"retry path was ever reached: {history[0]}" + ) + assert history[0]["api_key"] == expected_initial_key, f"{label}: {history[0]}" + assert cache_at_heal.get(session_id), ( + f"{label}: the initial agent was never cached, so this case cannot show " + f"that the abandoned retry left a good cache entry alone" + ) + + # (1) No second agent — the retry construction is what the guard prevents. + assert len(history) == 1, ( + f"{label}: the retry constructed a second agent on a route that had " + f"become terminal ({len(history)} constructions: {history})" + ) + assert len(captured.get("instances", [])) == 1, label + + # (2) _routed_client_kwargs() is never reached. The one explicit call is the + # initial, complete pair; anything else means an incomplete pair reached + # _init_openai_client() and re-entered provider routing. + routed = captured.get("routed_client_kwargs_calls", []) + assert not routed, ( + f"{label}: the retry agent was built with an incomplete connection pair " + f"{routed}, so _init_openai_client() fell through to " + f"_routed_client_kwargs() and re-resolved a provider" + ) + explicit = captured.get("explicit_client_kwargs_calls", []) + assert explicit == [{"api_key": expected_initial_key, "base_url": expected_initial_url}], ( + f"{label}: expected only the initial send's explicit pair, got {explicit}" + ) + + # (3) The retry turn was never sent. + assert captured.get("run_calls") == 1, ( + f"{label}: run_conversation ran {captured.get('run_calls')!r} times — the " + f"retry turn was sent on a route that no longer resolves" + ) + + # (4) The cache still holds the FIRST agent under the FIRST bundle + # signature. A retry write here would be the durable half of the defect: + # later turns reuse the cached agent without re-resolving at all. + assert list(cache_after) == [session_id], ( + f"{label}: unexpected agent-cache contents {list(cache_after)}" + ) + assert cache_after[session_id][0] is cache_at_heal[session_id][0], ( + f"{label}: the agent cache was rewritten with a retry agent built on an " + f"unroutable bundle" + ) + assert cache_after[session_id][1] == cache_at_heal[session_id][1], ( + f"{label}: a cache entry was written under the invalid bundle's signature" + ) + assert cache_after[session_id][0] is captured["instances"][0], label + + # (5) The client is told the real cause, not the 401 that triggered the heal. + assert apperrors, f"{label}: no controlled failure was emitted" + payload = apperrors[-1] + assert payload["type"] == "provider_unroutable", ( + f"{label}: emitted {payload['type']!r} instead of a provider-route " + f"failure — the 401 is the symptom, the unroutable route is the cause" + ) + assert expected_cause in payload.get("message", ""), ( + f"{label}: the failure did not name {expected_cause!r}: {payload}" + ) + assert "custom:omni" in payload.get("message", ""), ( + f"{label}: the failure did not name the provider that failed: {payload}" + ) + assert payload.get("hint"), f"{label}: the failure named no fix" + + # Nothing the abandoned retry could have been re-routed to may appear on the + # refusal path, and the keyless placeholder may never stand in for it. + blob = str(payload) + for leaked in ( + _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], + _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], + config.KEYLESS_CUSTOM_API_KEY, + ): + assert leaked not in blob, f"{label}: {leaked!r} leaked into the refusal" + return payload + + +@pytest.mark.parametrize("fail_first", ["returned_error", "raised"]) +@pytest.mark.parametrize( + "heal_mutate,mutation_slug,mutation_label", + _RETRY_UNOWNED_MUTATIONS, + ids=[slug for _fn, slug, _label in _RETRY_UNOWNED_MUTATIONS], +) +def test_retry_abandons_the_heal_when_the_slug_stops_being_owned( + monkeypatch, fail_first, heal_mutate, mutation_slug, mutation_label +): + """Case A — the refreshed route is unowned, so the retry must not be built. + + The turn starts on a row that owns ``custom:omni`` outright, so an agent is + constructed with the row's exact pair and the turn is sent. The provider + answers 401. Before the heal re-resolves, the row stops owning the slug. + + Building the retry from that bundle is the whole defect: its key and URL are + empty, which ``_init_openai_client()`` reads as "resolve a provider yourself" + — and the ambient runtime dict the heal returns still carries a truthy + endpoint, credential and pool for a provider the user never named. + """ + label = f"{mutation_label} / {fail_first}" + session_id = f"session-1806-retry-unowned-{fail_first}-{mutation_slug}" + + captured, apperrors, cache_at_heal, cache_after = ( + _run_composed_retry_expecting_abandoned_heal( + monkeypatch, + _RETRY_OWNED_CFG, + _ambient_runtime(), + session_id, + fail_first=fail_first, + heal_mutate=heal_mutate, + ) + ) + + _assert_retry_abandoned( + captured, + apperrors, + cache_at_heal, + cache_after, + session_id, + label, + expected_cause="is not configured", + ) + + +@pytest.mark.parametrize("fail_first", ["returned_error", "raised"]) +@pytest.mark.parametrize("row_fields,credential_label", _UNRESOLVED_CREDENTIAL_ROWS) +def test_retry_abandons_the_heal_when_the_refreshed_credential_resolves_to_nothing( + monkeypatch, fail_first, row_fields, credential_label +): + """Case B — the row still owns the route, but its credential now yields nothing. + + This is the shape a 401 self-heal is most likely to meet in the wild: the + 401 happened BECAUSE the credential went away, so the re-resolve finds the + same row with a declared-but-unresolved source. ``keyless`` is False there, + so no ``dummy-key`` is substituted and the pair stays incomplete — and an + incomplete pair at the constructor is a re-route, not a refusal. + + The retry therefore has to stop, and the turn has to end naming the + credential setting rather than the 401 it produced. + """ + _clear_credential_env(monkeypatch) + label = f"{credential_label} / {fail_first}" + session_id = f"session-1806-retry-nocred-{fail_first}-{credential_label}" + + captured, apperrors, cache_at_heal, cache_after = ( + _run_composed_retry_expecting_abandoned_heal( + monkeypatch, + _RETRY_OWNED_CFG, + _ambient_runtime(), + session_id, + fail_first=fail_first, + heal_mutate=_retry_credential_mutation(row_fields), + ) + ) + + _assert_retry_abandoned( + captured, + apperrors, + cache_at_heal, + cache_after, + session_id, + label, + expected_cause="produced no API key", + ) + + +def test_retry_still_succeeds_when_the_refreshed_route_is_still_routable(monkeypatch): + """The negative control: a heal that re-resolves a GOOD route still retries. + + Without this, every assertion above would also pass if the guards simply + abandoned all self-heals — which would take the #1401 credential-refresh + retry away entirely. Here the mutation swaps the row's key for a NEW one + (the refresh a heal exists to pick up), so the retry must be constructed + with the new pair and the turn must be sent a second time. + """ + session_id = "session-1806-retry-still-routable" + + def _rotate_the_credential(): + config.cfg["custom_providers"] = _retry_cfg_rows(api_key="rotated-key-sentinel") + + import api.streaming as streaming + + stream_id, q, captured, restore = _setup_production_composed_runtime( + monkeypatch, + copy.deepcopy(_RETRY_OWNED_CFG), + _ambient_runtime(), + session_id=session_id, + fail_first="returned_error", + heal_mutate=_rotate_the_credential, + ) + try: + streaming.STREAMS[stream_id] = q + streaming._run_agent_streaming( + session_id=session_id, + msg_text="hello", + model=_RETRY_MODEL, + workspace="/tmp", + stream_id=stream_id, + ) + apperrors = _drain_apperrors(q) + finally: + streaming.STREAMS.pop(stream_id, None) + streaming.AGENT_INSTANCES.pop(stream_id, None) + restore() + + history = captured["init_kwargs_history"] + assert len(history) == 2, f"the routable heal did not retry: {history}" + assert history[1]["api_key"] == "rotated-key-sentinel" + assert history[1]["base_url"] == _LIST_ROW_URL + assert captured["run_calls"] == 2, "the refreshed credential never sent a turn" + assert not apperrors, f"a routable retry emitted a failure: {apperrors}" + + +# ───────────────────────────────────────────────────────────────────────────── +# The generic bare-``custom`` authority owns NO named slug +# +# ``providers['custom']`` and a ``model:`` block whose provider is the bare +# string ``custom`` name no slug at all. While they were still eligible +# candidates for every ``custom:`` lookup, ``custom:ghost`` selected one of +# them and inherited its endpoint, credential, credential pool, ``api_mode`` and +# ACP transport — the same wrong-authority pairing the sole-unrelated-row rule +# above exists to prevent, only sourced from ``providers:``/``model:`` instead of +# ``custom_providers[]``. A named route is identity-owned: exact slug match, or +# nothing. +# ───────────────────────────────────────────────────────────────────────────── + + +_BARE_CUSTOM_PROVIDERS_RECORD = { + "base_url": "https://bare-providers-sentinel.example/v1", + "api_key": "bare-providers-key-sentinel", + "api_mode": "anthropic_messages", + "credential_pool": ["bare-pool-sentinel"], + "acp_command": "bare-acp-sentinel", + "acp_args": ["--bare-arg-sentinel"], +} + +_BARE_CUSTOM_MODEL_BLOCK = { + "provider": "custom", + "base_url": "https://bare-model-sentinel.example/v1", + "api_key": "bare-model-key-sentinel", +} + + +def _bare_custom_cfg(*, providers_record=True, model_block=True): + """Config carrying the generic bare-``custom`` authority, but no ``ghost``.""" + cfg_dict = { + "model": {"default": "ghostly/model"}, + "providers": {}, + "custom_providers": [ + { + "name": "omni", + "base_url": "https://omni.example/v1", + "api_key": "omni-key", + }, + ], + } + if providers_record: + cfg_dict["providers"]["custom"] = copy.deepcopy(_BARE_CUSTOM_PROVIDERS_RECORD) + if model_block: + cfg_dict["model"].update(copy.deepcopy(_BARE_CUSTOM_MODEL_BLOCK)) + return cfg_dict + + +def _clear_ghost_credential_env(monkeypatch): + """Keep the ``CUSTOM__API_KEY`` convention out of these verdicts.""" + monkeypatch.delenv("CUSTOM_GHOST_API_KEY", raising=False) + monkeypatch.delenv("CUSTOM_OMNI_API_KEY", raising=False) + + +_BARE_CUSTOM_SHAPES = [ + (True, False, "providers['custom'] only"), + (False, True, "model.provider: custom only"), + (True, True, "both bare-custom authorities"), +] + + +@pytest.mark.parametrize( + "providers_record,model_block,label", + _BARE_CUSTOM_SHAPES, + ids=["providers-custom", "model-provider-custom", "both"], +) +def test_unknown_named_slug_never_claims_the_bare_custom_authority( + monkeypatch, providers_record, model_block, label +): + """``custom:ghost`` must fail closed even with a generic ``custom`` record present. + + Neither authority names ``ghost``, so neither owns the route. Selecting one + would pair the user's prompt with an endpoint and a credential they never + pointed this slug at. + """ + _clear_ghost_credential_env(monkeypatch) + _with_direct_config( + monkeypatch, _bare_custom_cfg(providers_record=providers_record, model_block=model_block) + ) + + assert config.resolve_custom_provider_connection("custom:ghost") == (None, None), label + + ghost = config.resolve_custom_provider_bundle("custom:ghost") + assert ghost["status"] == config.CUSTOM_SELECTION_MISSING, label + assert ghost["source"] == "", label + assert ghost["record"] is None, f"{label}: a bare-custom record was selected for a named slug" + assert ghost["base_url"] is None, label + assert ghost["api_key"] is None, label + assert ghost["keyless"] is False, f"{label}: an unowned route was reported keyless" + assert ghost["owned"] == {}, f"{label}: an unowned route claimed side fields" + + # The named row is still authoritative for its OWN slug. + owned = config.resolve_custom_provider_bundle("custom:omni") + assert owned["status"] == config.CUSTOM_SELECTION_EXACT, label + assert owned["base_url"] == "https://omni.example/v1", label + + +@pytest.mark.parametrize( + "providers_record,model_block,label", + _BARE_CUSTOM_SHAPES, + ids=["providers-custom", "model-provider-custom", "both"], +) +def test_unknown_named_slug_inherits_no_bare_custom_connection_or_side_fields( + monkeypatch, providers_record, model_block, label +): + """The merge carries nothing from the bare-``custom`` record onto ``ghost``. + + Not the endpoint, not the credential, and — because a bare record's + ``api_mode``/pool/ACP transport are as unowned as its URL — none of the side + fields either. + """ + _clear_ghost_credential_env(monkeypatch) + _with_direct_config( + monkeypatch, _bare_custom_cfg(providers_record=providers_record, model_block=model_block) + ) + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:ghost", + "ambient-key-sentinel", + "https://ambient.example/v1", + dict(_AMBIENT_SIDE_FIELD_RUNTIME), + lookup_provider="custom:ghost", + ) + + _assert_ghost_bundle_failed_closed(bundle, label) + assert bundle["base_url"] != _BARE_CUSTOM_PROVIDERS_RECORD["base_url"], label + assert bundle["base_url"] != _BARE_CUSTOM_MODEL_BLOCK["base_url"], label + assert bundle["api_key"] != _BARE_CUSTOM_PROVIDERS_RECORD["api_key"], label + assert bundle["api_key"] != _BARE_CUSTOM_MODEL_BLOCK["api_key"], label + + +def test_unknown_named_slug_connection_view_ignores_the_bare_custom_record(monkeypatch): + """The three-field view reaches the same verdict, and reports ``custom_owned`` False.""" + _clear_ghost_credential_env(monkeypatch) + _with_direct_config(monkeypatch, _bare_custom_cfg()) + + provider, api_key, base_url, custom_owned = ( + config.apply_custom_provider_connection_authority( + "custom:ghost", + "ambient-key-sentinel", + "https://ambient.example/v1", + lookup_provider="custom:ghost", + runtime_provider=copy.deepcopy(_SELF_LABELLED_GHOST_RUNTIME), + ) + ) + + assert base_url is None + assert api_key is None + assert provider == "custom:ghost" + assert custom_owned is False + + +def test_bare_custom_route_still_uses_the_bare_custom_authority(monkeypatch): + """The carve-out: closing the generic path for NAMED slugs, not for bare ``custom``. + + ``providers['custom']`` is the bare route's own identity record, so it stays + eligible there — the tightening above is about a named slug borrowing an + authority that never named it. + """ + _clear_ghost_credential_env(monkeypatch) + _with_direct_config(monkeypatch, _bare_custom_cfg(model_block=False)) + + bare = config.resolve_custom_provider_bundle("custom:custom") + assert bare["status"] == config.CUSTOM_SELECTION_KEYED + assert bare["source"] == "providers" + assert bare["base_url"] == _BARE_CUSTOM_PROVIDERS_RECORD["base_url"] + assert bare["api_key"] == _BARE_CUSTOM_PROVIDERS_RECORD["api_key"] + assert bare["owned"]["api_mode"] == "anthropic_messages" + assert bare["owned"]["credential_pool"] == ["bare-pool-sentinel"] + assert bare["owned"]["acp_command"] == "bare-acp-sentinel" + + +def test_model_block_naming_the_slug_outright_still_owns_it(monkeypatch): + """Only the GENERIC ``custom`` spelling is refused, not an explicit slug. + + A ``model:`` block whose provider is ``custom:ghost`` names this identity, so + it remains the route's authority. + """ + _clear_ghost_credential_env(monkeypatch) + cfg_dict = _bare_custom_cfg(model_block=False) + cfg_dict["model"].update( + { + "provider": "custom:ghost", + "base_url": "https://named-model-sentinel.example/v1", + "api_key": "named-model-key-sentinel", + } + ) + _with_direct_config(monkeypatch, cfg_dict) + + ghost = config.resolve_custom_provider_bundle("custom:ghost") + assert ghost["status"] == config.CUSTOM_SELECTION_KEYED + assert ghost["source"] == "model" + assert ghost["base_url"] == "https://named-model-sentinel.example/v1" + assert ghost["api_key"] == "named-model-key-sentinel" + # And it beats the bare-custom record that is still sitting in providers:. + assert ghost["base_url"] != _BARE_CUSTOM_PROVIDERS_RECORD["base_url"] + + +# ───────────────────────────────────────────────────────────────────────────── +# An identity-keyed record owning ONLY side fields or a dynamic credential +# +# ``providers['custom:']`` names this provider outright, so whatever it +# declares, it owns. Judging it by "did a STATIC api_key resolve, or is there a +# base_url?" classified a perfectly valid ``key_cmd``-only / ``api_mode``-only / +# pool-only / ACP-only record as ``missing`` — and the merge then CLEARED the +# very fields that record exists to supply, reverting them to the ambient +# runtime's. +# ───────────────────────────────────────────────────────────────────────────── + + +def _keyed_side_field_cfg(record): + """Config whose only authority for ``omni`` is the keyed record ``record``.""" + return { + "model": {"default": "active/model", "provider": "custom:active"}, + "providers": {"custom:omni": copy.deepcopy(record)}, + "custom_providers": [ + { + "name": "active", + "base_url": "https://active.example/v1", + "api_key": "active-key", + }, + ], + } + + +_KEYED_SIDE_FIELD_ONLY_RECORDS = [ + ( + {"api_mode": "anthropic_messages"}, + {"api_mode": "anthropic_messages"}, + True, + "api_mode only", + ), + ( + {"transport": "anthropic"}, + {"api_mode": "anthropic_messages"}, + True, + "transport alias only", + ), + ( + {"credential_pool": ["keyed-pool-sentinel"]}, + {"credential_pool": ["keyed-pool-sentinel"]}, + # A configured pool is a declared credential source, so NOT keyless. + False, + "credential_pool only", + ), + ( + {"acp_command": "keyed-acp-sentinel", "acp_args": ["--keyed-arg-sentinel"]}, + {"acp_command": "keyed-acp-sentinel", "acp_args": ["--keyed-arg-sentinel"]}, + True, + "ACP transport only", + ), +] + + +@pytest.mark.parametrize( + "record,expected_owned,expected_keyless,label", + _KEYED_SIDE_FIELD_ONLY_RECORDS, + ids=["api-mode", "transport-alias", "credential-pool", "acp"], +) +def test_keyed_record_owning_only_side_fields_is_not_missing( + monkeypatch, record, expected_owned, expected_keyless, label +): + """A keyed record with no static URL/key still OWNS what it declares.""" + _clear_credential_env(monkeypatch) + _with_direct_config(monkeypatch, _keyed_side_field_cfg(record)) + + bundle = config.resolve_custom_provider_bundle("custom:omni") + assert bundle["status"] == config.CUSTOM_SELECTION_KEYED, ( + f"{label}: a keyed record that owns side fields was classified as missing" + ) + assert bundle["source"] == "providers", label + assert bundle["record"] is not None, label + assert bundle["owned"] == expected_owned, label + assert bundle["is_exact"] is False, label + # It declares no endpoint of its own; that is unowned, not invalid. + assert bundle["base_url"] is None, label + assert bundle["keyless"] is expected_keyless, label + + +def test_keyed_record_owning_only_key_cmd_is_not_missing(monkeypatch): + """``key_cmd`` is a real credential source, so a ``key_cmd``-only record owns the route. + + It resolves no STATIC key by design — the command mints a short-lived bearer + per request — so a static-key test reported the record as missing and the + route fell back to the ambient authority. + """ + _clear_credential_env(monkeypatch) + + def _token_provider(): + return "minted-bearer-sentinel" + + _fake_command_token_source(monkeypatch, lambda _key_cmd, _name: _token_provider) + _with_direct_config(monkeypatch, _keyed_side_field_cfg({"key_cmd": "print-omni-bearer"})) + + bundle = config.resolve_custom_provider_bundle("custom:omni") + assert bundle["status"] == config.CUSTOM_SELECTION_KEYED + assert bundle["source"] == "providers" + assert bundle["api_key"] is _token_provider, "the keyed record's key_cmd token source was lost" + assert bundle["keyless"] is False, "a declared key_cmd must never be reported keyless" + + +def test_keyed_record_owning_only_unresolved_env_credential_is_not_missing(monkeypatch): + """A declared-but-unset ``key_env`` still names this slug's credential source. + + Treating it as missing sends the route to the ambient endpoint instead of + surfacing the misconfiguration. + """ + _clear_credential_env(monkeypatch) + _with_direct_config(monkeypatch, _keyed_side_field_cfg({"key_env": _MISSING_ENV_VAR})) + + bundle = config.resolve_custom_provider_bundle("custom:omni") + assert bundle["status"] == config.CUSTOM_SELECTION_KEYED + assert bundle["api_key"] is None + assert bundle["keyless"] is False, "a declared-but-unresolved credential is not keyless" + + +_KEYED_SIDE_FIELDS_ONLY_CFG = { + "model": {"default": "active/model", "provider": "custom:active"}, + "providers": { + "custom:omni": { + # No base_url, no api_key: side fields are ALL this record declares. + "api_mode": "anthropic_messages", + "credential_pool": ["keyed-pool-sentinel"], + "acp_command": "keyed-acp-sentinel", + "acp_args": ["--keyed-arg-sentinel"], + }, + }, + "custom_providers": [ + { + "name": "active", + "base_url": "https://active.example/v1", + "api_key": "active-key", + }, + ], +} + + +def test_keyed_record_side_fields_survive_the_merge_without_a_static_pair(monkeypatch): + """The merge applies the record's owned fields instead of clearing them. + + The route itself is terminal — this record declares no endpoint, so it + borrows none — but "fail the route closed" must not degrade into "blank the + whole bundle". The fields the record declares for ITSELF are still its own + statement about itself, and only the ambient authority's values go with the + endpoint that was refused. + """ + _clear_credential_env(monkeypatch) + _with_direct_config(monkeypatch, copy.deepcopy(_KEYED_SIDE_FIELDS_ONLY_CFG)) + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + "ambient-key-sentinel", + "https://ambient.example/v1", + _ambient_runtime( + api_mode="chat_completions", + command="ambient-acp-sentinel", + args=["--ambient-arg-sentinel"], + credential_pool=["ambient-pool-sentinel"], + ), + lookup_provider="custom:omni", + ) + + _assert_side_fields( + bundle, + { + "api_mode": "anthropic_messages", + "credential_pool": ["keyed-pool-sentinel"], + "acp_command": "keyed-acp-sentinel", + "acp_args": ["--keyed-arg-sentinel"], + }, + "keyed record owning only side fields", + ) + # The record owns no endpoint, so the route is terminal and keeps neither the + # ambient URL nor a credential to pair with it. + verdict = bundle[config.CUSTOM_ROUTE_ERROR_FIELD] + assert verdict is not None and verdict["reason"] == config.CUSTOM_ROUTE_NO_ENDPOINT + assert bundle["base_url"] is None + assert bundle["api_key"] is None + + +def test_keyed_record_side_fields_survive_the_runtime_bundle(monkeypatch): + """End to end: the production-composed send REFUSES, keeping no ambient pair. + + This record owns side fields and nothing else — no endpoint of its own. It + used to reach the constructor on the ambient provider's URL under the "keyed + fill-only" rule, which is exactly the defect: the record's own authority + decided the wire protocol, pool and transport while the ACTIVE provider + decided where the prompt went. Owning ``api_mode`` is not owning an endpoint. + + So the send stops at the terminal verdict instead, and the merge-level test + above keeps pinning that the record's owned fields still ride on the bundle + it hands back. ``custom:omni``'s own side fields therefore never arrive at a + constructor here, because no constructor runs at all. + """ + _clear_credential_env(monkeypatch) + + captured, apperrors = _run_composed_send_expecting_refusal( + monkeypatch, + copy.deepcopy(_KEYED_SIDE_FIELDS_ONLY_CFG), + _ambient_runtime( + api_mode="chat_completions", + command="ambient-acp-sentinel", + args=["--ambient-arg-sentinel"], + credential_pool=["ambient-pool-sentinel"], + ), + "session-1806-keyed-side-fields-only", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + ) + + payload = apperrors[-1] + assert config.CUSTOM_ROUTE_NO_ENDPOINT in str(payload) or "resolved no endpoint" in ( + payload.get("message", "") + ), payload + blob = str(payload) + str(captured) + for leaked in ( + _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], + _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], + "ambient-pool-sentinel", + "ambient-acp-sentinel", + ): + assert leaked not in blob, ( + f"the ambient provider's {leaked!r} survived a route with no endpoint" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Non-streaming consumers: the verdict is terminal off the streaming path too +# +# ``api/streaming.py`` is not the only thing that builds an AIAgent from a +# merged bundle. POST /api/chat and the four auxiliary consumers all go through +# ``routes._resolve_agent_connection_bundle()``, which is the single chokepoint +# that must RAISE rather than hand back a bundle with a hole in it: an +# incomplete ``(api_key, base_url)`` pair is not a refusal at the constructor, +# it is the signal to re-resolve a provider through ``_routed_client_kwargs()``. +# These pin the raise, the 400 POST /api/chat turns it into, and the negative +# control that a routable bundle still comes back untouched. +# ───────────────────────────────────────────────────────────────────────────── + + +def _terminal_route_cfg(kind): + """Config producing each of the three terminal verdicts for one slug.""" + if kind == "unowned": + # Exactly one row, named something else: nothing owns ``custom:ghost``. + return copy.deepcopy(_SOLE_UNRELATED_ROW_CFG) + if kind == "no_endpoint": + # The exact row owns the slug and blanks the endpoint, so the keyed + # row's URL must not be reachable by falling through. + return _exact_list_row_cfg(base_url="") + # The exact row declares a credential source that resolves to nothing. + return _exact_list_row_cfg(drop=("api_key",), key_env=_MISSING_ENV_VAR) + + +_TERMINAL_ROUTE_CASES = [ + ("unowned", "custom:ghost", config.CUSTOM_ROUTE_UNOWNED, "is not configured", "Settings"), + ( + "no_endpoint", + "custom:omni", + config.CUSTOM_ROUTE_NO_ENDPOINT, + "resolved no endpoint", + "base_url", + ), + ( + "no_credential", + "custom:omni", + config.CUSTOM_ROUTE_NO_CREDENTIAL, + "produced no API key", + "key_env", + ), +] + + +@pytest.mark.parametrize( + "kind,slug,expected_reason,message_fragment,hint_fragment", _TERMINAL_ROUTE_CASES +) +def test_agent_connection_bundle_raises_on_terminal_route( + monkeypatch, kind, slug, expected_reason, message_fragment, hint_fragment +): + """The non-streaming chokepoint refuses instead of returning a holed bundle. + + Returning ``{"base_url": None, ...}`` here would look terminal to the caller + and be the opposite at the constructor, so all five consumers that share + this helper would each have had to remember to check. Raising makes the + refusal structural — and the exception subclasses ``ValueError``, which is + what the existing handlers at those call sites already catch. + """ + import api.routes as routes + + _clear_credential_env(monkeypatch) + _with_direct_config(monkeypatch, _terminal_route_cfg(kind)) + + # Mirror POST /api/chat's own sequence: resolve the model's provider, then + # let the ambient runtime fill the endpoint/credential it did not resolve. + _model, provider, base_url = config.resolve_model_provider( + f"@{slug}:antigravity/gemini-3.7-flash-tiered" + ) + runtime = copy.deepcopy(_ROUTE_KEYED_RUNTIME) + api_key = runtime["api_key"] + if not base_url: + base_url = runtime["base_url"] + + with pytest.raises(config.CustomProviderRouteError) as excinfo: + routes._resolve_agent_connection_bundle(provider, api_key, base_url, runtime) + + err = excinfo.value + assert isinstance(err, ValueError), ( + "the existing except-ValueError handlers at the five call sites must keep catching it" + ) + assert err.reason == expected_reason + assert err.provider == slug + assert message_fragment in err.message, err.message + assert hint_fragment in err.hint, err.hint + + +def test_agent_connection_bundle_returns_routable_bundle_unchanged(monkeypatch): + """The negative control: a routable named route still comes back, not raised. + + Without this, the three cases above are equally satisfied by a helper that + refuses everything — which would take every working custom provider down. + """ + import api.routes as routes + + _with_direct_config(monkeypatch, copy.deepcopy(_KEYED_VS_LIST_CFG)) + + bundle = routes._resolve_agent_connection_bundle( + "custom:omni", + "keyed-key-sentinel-abc", + "https://keyed-url-sentinel.example/v1", + copy.deepcopy(_ROUTE_KEYED_RUNTIME), + ) + + assert bundle["base_url"] == _LIST_ROW_URL + assert bundle["api_key"] == _LIST_ROW_KEY + assert bundle[config.CUSTOM_ROUTE_ERROR_FIELD] is None + + +def _drive_sync_chat_route_expecting_refusal(monkeypatch, cfg_dict, slug): + """Drive POST /api/chat to its refusal; return ``(payload, status, captured)``. + + The session carries the explicit ``@custom::`` form the picker + emits, which ``model_with_provider_context()`` passes through untouched. + That matters for the unowned case: a BARE model plus a stale + ``model_provider`` never mints an ``@custom:ghost:`` route in the first + place (the #7356 guard drops the hint), so the explicit form is the shape in + which an unowned slug actually reaches this route. + """ + import api.routes as routes + + captured, fake_session = _setup_route_consumer_runtime( + monkeypatch, + session_messages=_four_route_messages(), + cfg_dict=cfg_dict, + runtime_dict=copy.deepcopy(_ROUTE_KEYED_RUNTIME), + ) + fake_session.model = f"@{slug}:antigravity/gemini-3.7-flash-tiered" + fake_session.model_provider = slug + + responses = [] + monkeypatch.setattr(routes, "get_session", lambda _sid: fake_session) + monkeypatch.setattr(routes, "_agent_runtime_barrier_response", lambda **_k: None) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda ws: "/tmp") + monkeypatch.setattr( + routes, "_get_session_agent_lock", lambda _sid: contextlib.nullcontext() + ) + monkeypatch.setattr( + routes, "_read_profile_model_config", lambda *_a, **_k: (None, None, None) + ) + monkeypatch.setattr( + routes, + "_resolve_compatible_session_model_state", + lambda model, provider, **_k: (model, provider), + ) + # Capture the status alongside the payload -- "which HTTP code" is half the + # contract here (400 user-fixable, not a 500 traceback). + def _capture_j(_handler, payload, **kwargs): + responses.append((payload, kwargs.get("status", 200))) + return payload + + monkeypatch.setattr(routes, "j", _capture_j) + monkeypatch.setattr(routes, "bad", lambda _handler, message, *_a, **_k: {"error": message}) + + routes._handle_chat_sync( + object(), {"session_id": "session-1806-route", "message": "hi"} + ) + + assert responses, "POST /api/chat returned no response at all" + payload, status = responses[-1] + return payload, status, captured + + +@pytest.mark.parametrize( + "kind,slug,expected_reason,message_fragment,hint_fragment", _TERMINAL_ROUTE_CASES +) +def test_sync_chat_route_answers_400_on_unroutable_custom_provider( + monkeypatch, kind, slug, expected_reason, message_fragment, hint_fragment +): + """POST /api/chat answers the actionable cause, and never builds the agent. + + 400, not 500: an unroutable ``custom:`` is a user-fixable provider + misconfiguration, exactly like the ambiguous-slug collision. The constructor + must not be reached at all — the capturing double raises on ``__init__``, so + a bundle that got that far would surface as a leaked + ``_RouteAgentCaptured`` rather than a quiet pass. + """ + _clear_credential_env(monkeypatch) + + payload, status, captured = _drive_sync_chat_route_expecting_refusal( + monkeypatch, _terminal_route_cfg(kind), slug + ) + + assert status == 400, payload + assert payload["type"] == "custom_provider_unroutable", payload + assert payload["reason"] == expected_reason, payload + assert message_fragment in payload["error"], payload + assert slug in payload["error"], payload + assert hint_fragment in payload["hint"], payload + + assert "init_kwargs" not in captured, ( + "an agent was constructed for an unroutable route on the non-streaming path" + ) + # The refusal must not hand back the endpoint or credential it declined to + # route through. + assert "keyed-url-sentinel" not in str(payload), payload + assert "keyed-key-sentinel-abc" not in str(payload), payload + + +# ───────────────────────────────────────────────────────────────────────────── +# The standard Hermes v12 config shape: a RAW ``providers:`` record +# +# Everything above this point describes a config that spells a named custom +# provider one of the two ways the WebUI itself writes: a ``custom_providers[]`` +# row, or a ``providers['custom:']`` key. Hermes v12 writes neither. Its +# config is +# +# model: +# provider: custom:omni +# providers: +# omni: +# base_url: https://omni.example/v1 +# key_env: OMNI_GATE_KEY +# +# and the installed Agent routes it fine: ``_match_new_style_provider()`` scans +# the ENABLED entries of the raw ``providers:`` mapping and takes the first whose +# alias set — minted from BOTH the display ``name`` and the config KEY — holds +# the requested identity. The WebUI looked only at the ``custom:``-prefixed +# spellings, so every send on a stock v12 config failed closed as +# ``unowned_custom_provider`` while the CLI on the same config sent happily. +# +# These regressions use ONLY that shape: no ``custom_providers[]`` list entry and +# no ``providers['custom:omni']`` key. :func:`_v12_raw_cfg` asserts the absence, +# so a future edit that quietly re-adds either spelling turns the whole section +# vacuous loudly rather than silently. +# ───────────────────────────────────────────────────────────────────────────── + + +_V12_URL = "https://v12-raw-url-sentinel.example/v1" +_V12_KEY = "v12-raw-key-sentinel" +_V12_KEY_ENV = "HERMES_TEST_1806_V12_OMNI_KEY" + + +def _v12_raw_cfg(record, *, key="omni", model_provider="custom:omni"): + """``providers: {: record}`` and a ``model:`` block — nothing else. + + The ``model:`` block deliberately declares NO connection field of its own. + ``model.provider: custom:omni`` makes it a candidate in + ``_select_custom_provider_record``, and a base_url there would let it own + the route — which would prove nothing about the raw record under test. + """ + cfg_dict = { + "model": { + "default": "antigravity/gemini-3.7-flash-tiered", + "provider": model_provider, + }, + "providers": {key: copy.deepcopy(record)}, + } + assert "custom_providers" not in cfg_dict, "the raw shape carries no list" + assert not any( + str(k).lower().startswith("custom") for k in cfg_dict["providers"] + ), "the raw shape carries no ``custom:``-prefixed key" + assert not (set(cfg_dict["model"]) & {"base_url", "api_key", "key_env"}), ( + "the model: block must declare no connection of its own" + ) + return cfg_dict + + +def _v12_env(monkeypatch): + """Seed the ``key_env``/``api_key_env`` credential and clear the ladder below it.""" + monkeypatch.setenv(_V12_KEY_ENV, _V12_KEY) + # ``CUSTOM__API_KEY`` is the next rung of the record's own credential + # ladder; leaving a stray one set would mask a record whose declared env var + # was never read at all. + monkeypatch.delenv("CUSTOM_OMNI_API_KEY", raising=False) + monkeypatch.delenv("CUSTOM_MY_OMNI_API_KEY", raising=False) + + +# Each variant is ONE raw record that must resolve to the same (URL, key) pair +# through a different pair of field spellings. ``api``/``url`` are the Agent's +# ``_entry_url`` precedence and ``api_key_env`` its ``key_env`` synonym; a config +# hand-written or migrated either way names the same endpoint. +_V12_RECORD_VARIANTS = [ + ({"base_url": _V12_URL, "key_env": _V12_KEY_ENV}, "base_url+key_env"), + ({"api": _V12_URL, "api_key_env": _V12_KEY_ENV}, "api+api_key_env"), + ({"url": _V12_URL, "key_env": _V12_KEY_ENV}, "url+key_env"), + ({"base_url": _V12_URL, "api_key": _V12_KEY}, "base_url+static_api_key"), + # Display name AND config key disagree: the key alone still names the slug. + ( + {"name": "My Omni", "base_url": _V12_URL, "key_env": _V12_KEY_ENV}, + "aliased-display-name", + ), +] + +_V12_VARIANT_IDS = [label for _record, label in _V12_RECORD_VARIANTS] + + +@pytest.mark.parametrize("record,label", _V12_RECORD_VARIANTS, ids=_V12_VARIANT_IDS) +def test_v12_raw_provider_record_owns_its_named_slug(monkeypatch, record, label): + """``providers: {omni: ...}`` is the authority for ``custom:omni``. + + The bug: none of these resolved at all. ``custom:omni`` matched no + ``custom_providers[]`` row and no ``providers['custom:omni']`` key, so the + route was reported unowned and the send refused. + """ + _v12_env(monkeypatch) + _with_direct_config(monkeypatch, _v12_raw_cfg(record)) + + api_key, base_url = config.resolve_custom_provider_connection("custom:omni") + assert base_url == _V12_URL, label + assert api_key == _V12_KEY, label + + bundle = config.resolve_custom_provider_bundle("custom:omni") + assert bundle["status"] == config.CUSTOM_SELECTION_KEYED, label + assert bundle["record"] is not None, label + assert bundle["base_url"] == _V12_URL, label + assert bundle["api_key"] == _V12_KEY, label + # A record that resolved a credential is not keyless, so the route can never + # be handed the placeholder that claims the endpoint needs no auth. + assert bundle["keyless"] is False, label + assert bundle["api_key"] != config.KEYLESS_CUSTOM_API_KEY, label + + +def test_v12_raw_record_is_reached_by_its_display_name_too(monkeypatch): + """One record, keyed ``omni`` and named ``My Omni``, answers to BOTH identities. + + The Agent mints its alias set from the display name and the config key + together, so ``custom:omni`` and ``custom:my-omni`` are the same provider to + it. A WebUI that honoured only one of them would refuse half the sends the + picker can emit for a single configured endpoint. + """ + _v12_env(monkeypatch) + record = {"name": "My Omni", "base_url": _V12_URL, "key_env": _V12_KEY_ENV} + + for slug in ("custom:omni", "custom:my-omni"): + _with_direct_config(monkeypatch, _v12_raw_cfg(record, model_provider=slug)) + assert config.resolve_custom_provider_connection(slug) == (_V12_KEY, _V12_URL), slug + + # …and an identity the record does NOT name is still refused, so the alias + # set widens the vocabulary without widening ownership. + _with_direct_config(monkeypatch, _v12_raw_cfg(record, model_provider="custom:omni")) + assert config.resolve_custom_provider_connection("custom:ghost") == (None, None) + ghost = config.resolve_custom_provider_bundle("custom:ghost") + assert ghost["status"] == config.CUSTOM_SELECTION_MISSING + + +# ── the non-active raw record: its endpoint, not the active provider's ─────── + + +def test_v12_raw_record_supplies_its_own_endpoint_beside_its_own_key(monkeypatch): + """A NON-ACTIVE raw record must not pair its key with the ambient endpoint. + + This is the #1806 split-authority failure mirrored onto the raw shape. The + ambient runtime has resolved the ACTIVE provider, so it seeds a truthy + ``base_url`` of its own. The merge then takes the credential from the raw + record (the runtime is a different authority, so its key is not this + provider's) — and, filling only what the runtime had left empty, kept the + ACTIVE provider's URL. The send went to the active endpoint carrying the + non-active provider's key: a guaranteed 401 at best, and the user's prompt + delivered to a provider they did not pick at worst. + + The record declares the endpoint, so the record's endpoint wins. + """ + _v12_env(monkeypatch) + _with_direct_config( + monkeypatch, _v12_raw_cfg({"base_url": _V12_URL, "key_env": _V12_KEY_ENV}) + ) + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], + _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], + copy.deepcopy(_AMBIENT_SIDE_FIELD_RUNTIME), + lookup_provider="custom:omni", + ) + + assert bundle["base_url"] == _V12_URL + assert bundle["api_key"] == _V12_KEY + assert bundle["base_url"] != _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], ( + "the record's key was paired with the ambient provider's endpoint" + ) + assert bundle["api_key"] != _AMBIENT_SIDE_FIELD_RUNTIME["api_key"] + assert bundle[config.CUSTOM_ROUTE_ERROR_FIELD] is None + # The runtime is a proven-foreign authority, so none of its side fields ride + # along to the custom HTTP endpoint either. + _assert_side_fields(bundle, _FOREIGN_AMBIENT_SIDE_FIELDS, "non-active raw record") + + +def test_v12_raw_record_keeps_the_runtimes_spelling_of_the_same_endpoint(monkeypatch): + """Negative control: same authority, so the runtime's normalized URL stands. + + Without this, the test above is equally satisfied by "always overwrite the + runtime's endpoint with the config spelling" — which would discard the + normalized form the runtime actually resolved (trailing-``/v1`` + de-duplication and friends) for an endpoint that is provably the same one. + """ + _v12_env(monkeypatch) + _with_direct_config( + monkeypatch, _v12_raw_cfg({"base_url": _V12_URL + "/", "key_env": _V12_KEY_ENV}) + ) + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + None, + _V12_URL, + {"provider": "custom:omni", "base_url": _V12_URL, "api_key": _V12_KEY}, + lookup_provider="custom:omni", + ) + + assert bundle["base_url"] == _V12_URL, "a same-authority runtime spelling was discarded" + assert bundle["api_key"] == _V12_KEY + assert bundle[config.CUSTOM_ROUTE_ERROR_FIELD] is None + + +def test_matching_identity_metadata_with_conflicting_credential_rejects_tie(monkeypatch): + """Even when identity metadata matches, conflicting credentials strictly reject a tie.""" + _v12_env(monkeypatch) + _with_direct_config( + monkeypatch, _v12_raw_cfg({"base_url": _V12_URL + "/", "key_env": _V12_KEY_ENV}) + ) + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + None, + _V12_URL, + { + "provider": "custom:omni", + "base_url": _V12_URL, + "record_id": "omni", + "api_key": "conflicting-foreign-key", + }, + lookup_provider="custom:omni", + ) + + assert bundle["base_url"] == _V12_URL + "/" + assert bundle["api_key"] == _V12_KEY + + +_CALLER_RESOLVED_URL = "https://caller-resolved-sentinel.example/v1" + + +def test_v12_raw_record_supplies_the_endpoint_nothing_is_positively_tied_to(monkeypatch): + """With NO runtime dict there is no TIE, so the record's own endpoint wins. + + The endpoint the caller arrived with was resolved before this slug was ever + looked up, so it says nothing about the record selected for it. Keeping it + only because no runtime dict arrived to contradict it reads silence as + provenance — and pairs the record's own credential with whatever endpoint + happened to be in flight, which is the split-authority send this module + exists to stop. + + So the rule is positive: an endpoint survives the merge only when the + SELECTED record supplied it, or when a runtime dict is positively tied to + that same record (:func:`_custom_runtime_endpoint_is_record_owned`, pinned by + the same-authority control above). An absent runtime dict is neither, so the + record's ``base_url`` displaces the caller's. The #2271 fill-only shape lives + on the injected-``connection_resolver`` path, where there IS no record to + take provenance from — see the control below. + """ + _v12_env(monkeypatch) + _with_direct_config( + monkeypatch, _v12_raw_cfg({"base_url": _V12_URL, "key_env": _V12_KEY_ENV}) + ) + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + None, + _CALLER_RESOLVED_URL, + None, + lookup_provider="custom:omni", + ) + + assert bundle["base_url"] == _V12_URL, ( + "the record's credential was left beside an endpoint it never declared" + ) + assert bundle["base_url"] != _CALLER_RESOLVED_URL + assert bundle["api_key"] == _V12_KEY + assert bundle[config.CUSTOM_ROUTE_ERROR_FIELD] is None + + +def test_injected_connection_resolver_still_keeps_the_callers_base_url(monkeypatch): + """Negative control for #2271: no record, so the fill-only shape is unchanged. + + An injected ``connection_resolver`` reports a bare (key, URL) pair and IS the + whole authority on that path — there is no config record whose endpoint + provenance could displace anything. Applying the record rule here would + silently re-point every such caller at the resolver's spelling, which is + #2271's ``test_named_custom_provider_keeps_existing_runtime_base_url``. + """ + _v12_env(monkeypatch) + _with_direct_config( + monkeypatch, _v12_raw_cfg({"base_url": _V12_URL, "key_env": _V12_KEY_ENV}) + ) + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + None, + _CALLER_RESOLVED_URL, + None, + lookup_provider="custom:omni", + connection_resolver=lambda _pid, **_kw: ( + "resolver-key-sentinel", + "https://resolver-config-sentinel.example/v1", + ), + ) + + assert bundle["base_url"] == _CALLER_RESOLVED_URL, ( + "the resolver path lost #2271's fill-only base_url handling" + ) + assert bundle["api_key"] == "resolver-key-sentinel" + assert bundle[config.CUSTOM_ROUTE_ERROR_FIELD] is None + + +# ── disabled rows are invisible to the Agent, so they own nothing here ─────── + + +_V12_DISABLED_FLAGS = [False, "false", "no", "off", "0"] + + +@pytest.mark.parametrize("flag", _V12_DISABLED_FLAGS, ids=[str(f) for f in _V12_DISABLED_FLAGS]) +def test_v12_disabled_raw_record_fails_closed_as_unowned(monkeypatch, flag): + """A switched-off row must not quietly keep routing. + + ``is_provider_enabled()`` hides a falsey ``enabled:`` entry from the Agent's + resolver, so honouring it here would make the WebUI send through an endpoint + the user disabled — and send it through the AMBIENT connection the moment the + row itself resolved nothing. Both halves are pinned: the verdict is + ``unowned_custom_provider``, and the bundle keeps no endpoint, credential, + placeholder or side field from anywhere. + """ + _v12_env(monkeypatch) + record = {"enabled": flag, "base_url": _V12_URL, "key_env": _V12_KEY_ENV} + _with_direct_config(monkeypatch, _v12_raw_cfg(record)) + + assert config.resolve_custom_provider_connection("custom:omni") == (None, None) + selection = config.resolve_custom_provider_bundle("custom:omni") + assert selection["status"] == config.CUSTOM_SELECTION_MISSING + assert selection["record"] is None + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], + _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], + copy.deepcopy(_AMBIENT_SIDE_FIELD_RUNTIME), + lookup_provider="custom:omni", + ) + verdict = bundle[config.CUSTOM_ROUTE_ERROR_FIELD] + assert verdict is not None, "a disabled row left the route looking routable" + assert verdict["reason"] == config.CUSTOM_ROUTE_UNOWNED + assert verdict["provider"] == "custom:omni" + assert bundle["base_url"] is None + assert bundle["api_key"] is None + assert bundle["api_key"] != config.KEYLESS_CUSTOM_API_KEY + assert bundle["provider"] == "custom:omni" + _assert_side_fields(bundle, _FOREIGN_AMBIENT_SIDE_FIELDS, f"enabled: {flag!r}") + # The disabled row's own endpoint and credential must not leak either. + assert _V12_URL not in str(bundle) + assert _V12_KEY not in str(bundle) + + +@pytest.mark.parametrize("flag", [True, "true", "yes", "on", "1"], ids=lambda f: str(f)) +def test_v12_explicitly_enabled_raw_record_still_owns_its_slug(monkeypatch, flag): + """Negative control: only the FALSEY words hide a row. + + A mirror that read ``enabled`` as "present means off", or that treated any + string as truthy-by-presence, would take every explicitly-enabled v12 + provider offline — the same outage as the bug, from the opposite direction. + """ + _v12_env(monkeypatch) + record = {"enabled": flag, "base_url": _V12_URL, "key_env": _V12_KEY_ENV} + _with_direct_config(monkeypatch, _v12_raw_cfg(record)) + + assert config.resolve_custom_provider_connection("custom:omni") == (_V12_KEY, _V12_URL) + + +# ── two raw records claiming one identity split the authority ──────────────── + + +_V12_AMBIGUOUS_CASES = [ + ( + # Two config KEYS whose alias sets overlap: ``Omni`` normalizes onto the + # other record's key. + { + "omni": {"base_url": "https://omni-a.example/v1", "api_key": "omni-a-key"}, + "omni-two": { + "name": "Omni", + "base_url": "https://omni-b.example/v1", + "api_key": "omni-b-key", + }, + }, + "key-vs-display-name", + ), + ( + # Two display NAMES that normalize to the same slug: ``Omni`` and + # ``omni`` are one identity to the Agent's alias minting. + { + "gate-a": { + "name": "Omni", + "base_url": "https://omni-a.example/v1", + "key_env": _V12_KEY_ENV, + }, + "gate-b": { + "name": "omni", + "base_url": "https://omni-b.example/v1", + "api_key": "omni-b-key", + }, + }, + "display-name-vs-display-name", + ), + ( + # The legacy ``custom:``-carrying spelling of a display name is the same + # identity as the bare config key. + { + "omni": {"base_url": "https://omni-a.example/v1", "api_key": "omni-a-key"}, + "gate-b": { + "name": "custom:omni", + "base_url": "https://omni-b.example/v1", + "api_key": "omni-b-key", + }, + }, + "bare-key-vs-custom-prefixed-name", + ), +] + + +@pytest.mark.parametrize( + "providers_map,label", + [(m, lbl) for m, lbl in _V12_AMBIGUOUS_CASES], + ids=[lbl for _m, lbl in _V12_AMBIGUOUS_CASES], +) +def test_v12_ambiguous_raw_records_fail_closed(monkeypatch, providers_map, label): + """Two raw records naming one slug is a refusal, not a first-match race. + + Config order would decide which endpoint and which credential the send gets, + and nothing forces those two to come from the SAME record — the split + authority ``_unique_custom_provider_entry`` already refuses for + ``custom_providers[]``. Every entry point into the route must raise, because + each of them is the first thing some consumer calls. + """ + # The first case's ``omni`` key is ambiguous only because the OTHER record + # exists, so build the config by hand rather than via ``_v12_raw_cfg``. + cfg_dict = { + "model": {"default": "antigravity/gemini-3.7-flash-tiered", "provider": "custom:omni"}, + "providers": copy.deepcopy(providers_map), + } + _v12_env(monkeypatch) + _with_direct_config(monkeypatch, cfg_dict) + + import api.routes as routes + + entry_points = { + "resolve_custom_provider_connection": lambda: config.resolve_custom_provider_connection( + "custom:omni" + ), + "resolve_custom_provider_bundle": lambda: config.resolve_custom_provider_bundle( + "custom:omni" + ), + "merge_custom_provider_runtime_bundle": lambda: ( + config.merge_custom_provider_runtime_bundle( + "custom:omni", + _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], + _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], + copy.deepcopy(_AMBIENT_SIDE_FIELD_RUNTIME), + lookup_provider="custom:omni", + ) + ), + "_resolve_agent_connection_bundle": lambda: routes._resolve_agent_connection_bundle( + "custom:omni", + _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], + _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], + copy.deepcopy(_AMBIENT_SIDE_FIELD_RUNTIME), + ), + } + + for name, call in entry_points.items(): + with pytest.raises(config.AmbiguousCustomProviderError) as excinfo: + call() + # The refusal has to be actionable: it must name the colliding records + # and subclass ValueError, which is what the existing handlers at the + # consumer call sites already catch. + assert isinstance(excinfo.value, ValueError), f"{label}/{name}" + message = str(excinfo.value) + for key in providers_map: + assert key in message, f"{label}/{name}: {message}" + + # An identity only ONE of them names is unaffected: a collision on ``omni`` + # must not take an unrelated provider down with it. + unique_cfg = { + "model": cfg_dict["model"], + "providers": { + "solo": {"base_url": _V12_URL, "key_env": _V12_KEY_ENV}, + **copy.deepcopy(providers_map), + }, + } + _with_direct_config(monkeypatch, unique_cfg) + assert config.resolve_custom_provider_connection("custom:solo") == (_V12_KEY, _V12_URL) + + +def test_v12_record_naming_the_slug_without_a_connection_is_not_a_collision(monkeypatch): + """A record that declares nothing is not a competing authority. + + Only a record that could actually supply an endpoint or a credential can + split the authority, so a same-slug row holding e.g. a models allowlist and + nothing else must neither win the route nor block it. Refusing here would + fail closed on configs that are not ambiguous at all. + """ + _v12_env(monkeypatch) + cfg_dict = { + "model": {"default": "antigravity/gemini-3.7-flash-tiered", "provider": "custom:omni"}, + "providers": { + "omni": {"base_url": _V12_URL, "key_env": _V12_KEY_ENV}, + "omni-notes": {"name": "Omni", "models": ["antigravity/gemini-3.7-flash-tiered"]}, + }, + } + _with_direct_config(monkeypatch, cfg_dict) + + assert config.resolve_custom_provider_connection("custom:omni") == (_V12_KEY, _V12_URL) + + +# ── the three streaming regions that build an agent ────────────────────────── + + +def _assert_v12_bundle(init_kwargs, label, *, expected_key=_V12_KEY): + """The constructor bundle is wholly owned by the raw v12 record.""" + assert init_kwargs["base_url"] == _V12_URL, label + assert init_kwargs["api_key"] == expected_key, label + # ``custom``, not ``custom:omni``: the named provider has supplied a concrete + # endpoint, so Agent init must not synthesize ``CUSTOM:OMNI_API_KEY`` hints. + assert init_kwargs["provider"] == "custom", label + assert init_kwargs["base_url"] != _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], label + assert init_kwargs["api_key"] != _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], label + assert init_kwargs["api_key"] != config.KEYLESS_CUSTOM_API_KEY, label + _assert_side_fields(init_kwargs, _FOREIGN_AMBIENT_SIDE_FIELDS, label) + + +def _assert_explicit_client_only(captured, label): + """``_init_openai_client()`` took the explicit branch on EVERY construction. + + The weaker ``base_url is not None`` proxy is satisfied by a bundle that still + sends the turn somewhere else: an incomplete pair makes Agent init call + ``_routed_client_kwargs()`` and re-resolve a provider through the centralized + router. Asserting the branch is what pins "this send reached the endpoint the + user picked". + """ + routed = captured.get("routed_client_kwargs_calls", []) + assert not routed, f"{label}: _routed_client_kwargs() was reached: {routed}" + explicit = captured.get("explicit_client_kwargs_calls", []) + assert explicit, f"{label}: no client was configured at all" + for call in explicit: + assert call["base_url"] == _V12_URL, label + assert call["api_key"] == _V12_KEY, label + + +@pytest.mark.parametrize("record,label", _V12_RECORD_VARIANTS, ids=_V12_VARIANT_IDS) +def test_v12_raw_record_initial_streaming_send_builds_the_agent(monkeypatch, record, label): + """Initial send: the stock v12 config routes, and routes to its OWN endpoint. + + Before the fix this send did not merely mis-route — it never happened. The + route was refused as ``unowned_custom_provider`` on a config the CLI sends on + every day. + """ + import api.streaming as streaming + + _v12_env(monkeypatch) + session_id = f"session-1806-v12-initial-{label}" + stream_id, q, captured, restore = _setup_production_composed_runtime( + monkeypatch, + _v12_raw_cfg(record), + dict(_AMBIENT_SIDE_FIELD_RUNTIME), + session_id=session_id, + ) + try: + streaming.STREAMS[stream_id] = q + streaming._run_agent_streaming( + session_id=session_id, + msg_text="hello", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + workspace="/tmp", + stream_id=stream_id, + ) + apperrors = _drain_apperrors(q) + finally: + streaming.STREAMS.pop(stream_id, None) + streaming.AGENT_INSTANCES.pop(stream_id, None) + restore() + + assert not apperrors, f"{label}: the send failed closed: {apperrors}" + assert captured.get("run_calls"), f"{label}: the turn was never sent" + _assert_v12_bundle(captured["init_kwargs"], f"{label}/initial send") + _assert_explicit_client_only(captured, f"{label}/initial send") + + +@pytest.mark.parametrize( + "fail_first,label", + [("returned_error", "returned-error heal"), ("raised", "raised-exception heal")], + ids=["returned-error", "raised-exception"], +) +def test_v12_raw_record_survives_both_credential_heal_paths(monkeypatch, fail_first, label): + """Both 401 self-heal retries rebuild the SAME raw-record bundle. + + The retry paths re-resolve the runtime provider from scratch, so they are + where a partial rebuild leaks the ambient authority back in: the heal hands + back the ambient dict, and a retry that refreshed only provider/key/base_url + would reconstruct the agent with the ambient pool, wire protocol and ACP + transport beside the custom endpoint. Every construction in the history is + checked, not just the last. + """ + import api.streaming as streaming + + _v12_env(monkeypatch) + session_id = f"session-1806-v12-{fail_first}" + stream_id, q, captured, restore = _setup_production_composed_runtime( + monkeypatch, + _v12_raw_cfg({"base_url": _V12_URL, "key_env": _V12_KEY_ENV}), + dict(_AMBIENT_SIDE_FIELD_RUNTIME), + session_id=session_id, + fail_first=fail_first, + ) + try: + streaming.STREAMS[stream_id] = q + streaming._run_agent_streaming( + session_id=session_id, + msg_text="hello", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + workspace="/tmp", + stream_id=stream_id, + ) + finally: + streaming.STREAMS.pop(stream_id, None) + streaming.AGENT_INSTANCES.pop(stream_id, None) + restore() + + history = captured["init_kwargs_history"] + assert len(history) >= 2, f"{label}: the retry constructed no second agent" + for index, init_kwargs in enumerate(history): + _assert_v12_bundle(init_kwargs, f"{label} construction #{index}") + _assert_explicit_client_only(captured, label) + + +def test_v12_disabled_record_refuses_the_streaming_send(monkeypatch): + """A disabled v12 row stops the turn instead of routing it anywhere. + + The complement of the two tests above at the same boundary: no agent is + constructed, so ``_routed_client_kwargs()`` is never reached, the agent cache + is never poisoned, and the turn ends on a controlled ``provider_unroutable`` + apperror naming the provider and a fix. + """ + _v12_env(monkeypatch) + captured, apperrors = _run_composed_send_expecting_refusal( + monkeypatch, + _v12_raw_cfg({"enabled": False, "base_url": _V12_URL, "key_env": _V12_KEY_ENV}), + dict(_AMBIENT_SIDE_FIELD_RUNTIME), + "session-1806-v12-disabled", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + ) + + payload = apperrors[-1] + assert "custom:omni" in payload["message"], payload + assert "is not configured" in payload["message"], payload + + blob = str(payload) + str(captured) + for leaked in ( + _V12_URL, + _V12_KEY, + _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], + _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], + config.KEYLESS_CUSTOM_API_KEY, + ): + assert leaked not in blob, f"{leaked!r} leaked into the refused route" + assert not captured.get("explicit_client_kwargs_calls") + + +# ── the shared non-streaming constructor boundary ──────────────────────────── + + +@pytest.mark.parametrize("record,label", _V12_RECORD_VARIANTS, ids=_V12_VARIANT_IDS) +def test_v12_raw_record_resolves_at_the_non_streaming_chokepoint(monkeypatch, record, label): + """``_resolve_agent_connection_bundle()`` returns the record's bundle, unraised. + + POST /api/chat and the four auxiliary consumers all build their agent from + here, so a v12 config that only worked on the streaming path would still take + compression, commit messages, handoffs and summaries down. The helper RAISES + on a terminal verdict, so a clean return is itself half the assertion. + """ + import api.routes as routes + + _v12_env(monkeypatch) + _with_direct_config(monkeypatch, _v12_raw_cfg(record)) + + # Mirror POST /api/chat's own sequence: resolve the model's provider, then + # let the ambient runtime fill the endpoint it did not resolve. + _model, provider, base_url = config.resolve_model_provider( + "@custom:omni:antigravity/gemini-3.7-flash-tiered" + ) + assert provider == "custom:omni", label + runtime = copy.deepcopy(_AMBIENT_SIDE_FIELD_RUNTIME) + bundle = routes._resolve_agent_connection_bundle( + provider, + runtime["api_key"], + base_url or runtime["base_url"], + runtime, + ) + + assert bundle[config.CUSTOM_ROUTE_ERROR_FIELD] is None, label + _assert_v12_bundle(bundle, f"{label}/non-streaming chokepoint") + + +def test_v12_disabled_record_raises_at_the_non_streaming_chokepoint(monkeypatch): + """The same boundary refuses a disabled row, with the actionable reason.""" + import api.routes as routes + + _v12_env(monkeypatch) + _with_direct_config( + monkeypatch, + _v12_raw_cfg({"enabled": "off", "base_url": _V12_URL, "key_env": _V12_KEY_ENV}), + ) + + with pytest.raises(config.CustomProviderRouteError) as excinfo: + routes._resolve_agent_connection_bundle( + "custom:omni", + _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], + _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], + copy.deepcopy(_AMBIENT_SIDE_FIELD_RUNTIME), + ) + + err = excinfo.value + assert isinstance(err, ValueError) + assert err.reason == config.CUSTOM_ROUTE_UNOWNED + assert err.provider == "custom:omni" + assert "is not configured" in err.message, err.message + assert err.hint, "the refusal named no fix" + + +# ── the agent cache signature is derived from the resolved bundle ──────────── + + +def _v12_send_signature(monkeypatch, cfg_dict, runtime_dict, session_id): + """Drive one composed send and return the signature its agent was cached under.""" + import api.streaming as streaming + + stream_id, q, _captured, restore = _setup_production_composed_runtime( + monkeypatch, cfg_dict, runtime_dict, session_id=session_id + ) + try: + streaming.STREAMS[stream_id] = q + streaming._run_agent_streaming( + session_id=session_id, + msg_text="hello", + model="@custom:omni:antigravity/gemini-3.7-flash-tiered", + workspace="/tmp", + stream_id=stream_id, + ) + with config.SESSION_AGENT_CACHE_LOCK: + assert session_id in config.SESSION_AGENT_CACHE, "the send cached no agent" + return config.SESSION_AGENT_CACHE[session_id][1] + finally: + streaming.STREAMS.pop(stream_id, None) + streaming.AGENT_INSTANCES.pop(stream_id, None) + restore() + + +def test_v12_cache_signature_is_bundle_derived_not_runtime_derived(monkeypatch): + """Two sends whose RESOLVED bundle is identical hash identically. + + The raw record clears every ambient side field, so a signature still taken + off the raw runtime provider would differ between these two sends even though + the agents they build are the same — churning a new agent per send. + """ + _v12_env(monkeypatch) + record = {"base_url": _V12_URL, "key_env": _V12_KEY_ENV} + + sig_plain = _v12_send_signature( + monkeypatch, + _v12_raw_cfg(record), + {"provider": "custom:omni", "base_url": "https://ambient.example/v1", "api_key": "amb"}, + "session-1806-v12-sig-plain", + ) + sig_ambient = _v12_send_signature( + monkeypatch, + _v12_raw_cfg(record), + dict(_AMBIENT_SIDE_FIELD_RUNTIME), + "session-1806-v12-sig-ambient", + ) + + assert sig_plain == sig_ambient, ( + "the signature still varies with runtime fields the bundle cleared" + ) + + +_V12_SIGNATURE_MUTATIONS = [ + ({"base_url": _V12_URL + "-moved", "key_env": _V12_KEY_ENV}, "endpoint"), + ({"base_url": _V12_URL, "api_key": _V12_KEY + "-rotated"}, "credential"), + ( + {"base_url": _V12_URL, "key_env": _V12_KEY_ENV, "api_mode": "anthropic_messages"}, + "api_mode", + ), + ( + { + "base_url": _V12_URL, + "key_env": _V12_KEY_ENV, + "command": "v12-acp-command-sentinel", + "args": ["--v12-acp-arg"], + }, + "acp_transport", + ), +] + + +@pytest.mark.parametrize( + "record,label", + _V12_SIGNATURE_MUTATIONS, + ids=[label for _r, label in _V12_SIGNATURE_MUTATIONS], +) +def test_v12_cache_signature_tracks_the_provider_configuration(monkeypatch, record, label): + """Editing the raw record mints a NEW agent instead of reusing the cached one. + + The cache is keyed by session, so a signature blind to any part of the + provider configuration would keep serving an agent built on the PREVIOUS + endpoint, credential, wire protocol or ACP transport for the rest of the + session — the user changes a setting and nothing happens. + """ + _v12_env(monkeypatch) + baseline = {"base_url": _V12_URL, "key_env": _V12_KEY_ENV} + + sig_before = _v12_send_signature( + monkeypatch, + _v12_raw_cfg(baseline), + dict(_AMBIENT_SIDE_FIELD_RUNTIME), + "session-1806-v12-sig-baseline", + ) + sig_after = _v12_send_signature( + monkeypatch, + _v12_raw_cfg(record), + dict(_AMBIENT_SIDE_FIELD_RUNTIME), + "session-1806-v12-sig-mutated", + ) + + assert sig_before != sig_after, ( + f"the signature ignores the record's {label}, so a cached agent built on " + f"the previous one would be reused for the rest of the session" + ) + + +# ── the WebUI and the installed Agent must select the SAME bundle ──────────── +# +# ``hermes_cli`` is not importable here, so the oracle below is written from the +# Agent's documented algorithm rather than by calling it — and deliberately NOT +# by calling ``api.config``'s mirrors of it, which would make the comparison a +# tautology. Two independent implementations agreeing on the same config is the +# only evidence available that a v12 send lands in the same place whichever of +# the two resolves it. That agreement is the entire point of the fix: the bug was +# precisely the WebUI and the Agent disagreeing about a stock v12 config. + + +def _oracle_agent_aliases(display_name, provider_key): + """``hermes_cli.providers.custom_provider_aliases()``, reimplemented.""" + aliases = set() + for value in (display_name, provider_key): + raw = str(value or "").strip().lower() + if not raw: + continue + dashed = raw.replace(" ", "-") + aliases.add(raw) + aliases.add(dashed) + aliases.add(dashed if dashed.startswith("custom:") else "custom:" + dashed) + if dashed.startswith("custom:"): + bare = dashed.split(":", 1)[1] + if bare: + aliases.add(bare) + aliases.add("custom:" + dashed) + aliases.discard("") + return aliases + + +def _oracle_agent_enabled(record): + """``hermes_cli.config_providers.is_provider_enabled()``, reimplemented.""" + flag = record.get("enabled", True) + if isinstance(flag, bool): + return flag + if isinstance(flag, str): + return flag.strip().lower() not in {"false", "0", "no", "off"} + return bool(flag) + + +def _oracle_agent_selection(cfg_dict, requested): + """``_match_new_style_provider()``: first ENABLED raw record whose aliases match. + + Returns the complete connection bundle the Agent would construct with, or + ``None`` when nothing in ``providers:`` names ``requested``. + """ + wanted = str(requested or "").strip().lower() + providers_cfg = cfg_dict.get("providers") or {} + for provider_key, record in providers_cfg.items(): + if not isinstance(record, dict) or not record: + continue + if not _oracle_agent_enabled(record): + continue + if wanted not in _oracle_agent_aliases(record.get("name") or provider_key, provider_key): + continue + url = None + for field in ("api", "url", "base_url"): # _entry_url precedence + value = record.get(field) + if isinstance(value, str) and value.strip(): + url = value.strip() + break + api_key = record.get("api_key") + if not api_key: + env_name = record.get("key_env") or record.get("api_key_env") + if env_name: + api_key = os.environ.get(str(env_name)) + return { + # The Agent routes a resolved named endpoint through the generic + # OpenAI-compatible custom client, exactly as the WebUI bundle does. + "provider": "custom", + "base_url": url, + "api_key": api_key or None, + "api_mode": record.get("api_mode") or record.get("transport") or None, + "acp_command": record.get("acp_command") or record.get("command") or None, + "acp_args": record.get("acp_args") or record.get("args") or None, + } + return None + + +_V12_ORACLE_RECORDS = [ + ({"base_url": _V12_URL, "key_env": _V12_KEY_ENV}, "base_url+key_env"), + ({"api": _V12_URL, "api_key_env": _V12_KEY_ENV}, "api+api_key_env"), + ({"url": _V12_URL, "api_key": _V12_KEY}, "url+static_api_key"), + ({"name": "My Omni", "base_url": _V12_URL, "key_env": _V12_KEY_ENV}, "aliased-name"), + ( + { + "base_url": _V12_URL, + "key_env": _V12_KEY_ENV, + "api_mode": "anthropic_messages", + "command": "v12-acp-command-sentinel", + "args": ["--v12-acp-arg"], + }, + "side-fields-owned", + ), + ({"enabled": False, "base_url": _V12_URL, "key_env": _V12_KEY_ENV}, "disabled"), +] + + +@pytest.mark.parametrize( + "record,label", + _V12_ORACLE_RECORDS, + ids=[label for _r, label in _V12_ORACLE_RECORDS], +) +def test_webui_and_agent_select_the_same_complete_bundle(monkeypatch, record, label): + """One config, two resolvers, one connection bundle — including the side fields. + + Not just the URL and the key: ``api_mode`` decides the wire protocol and + ``acp_command``/``acp_args`` the transport, so a WebUI that agreed on the + endpoint and disagreed on those would still talk to it differently than the + CLI does. The disabled case is in the same table on purpose — agreeing that + a row owns NOTHING is as much a part of the contract as agreeing where it + points. + """ + _v12_env(monkeypatch) + cfg_dict = _v12_raw_cfg(record) + _with_direct_config(monkeypatch, cfg_dict) + + expected = _oracle_agent_selection(cfg_dict, "custom:omni") + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], + _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], + copy.deepcopy(_AMBIENT_SIDE_FIELD_RUNTIME), + lookup_provider="custom:omni", + ) + fields = ("provider", "base_url", "api_key", "api_mode", "acp_command", "acp_args") + + if expected is None: + # The Agent routes this nowhere, so neither may the WebUI — and it must + # say so terminally rather than hand back a holed bundle. + verdict = bundle[config.CUSTOM_ROUTE_ERROR_FIELD] + assert verdict is not None, label + assert verdict["reason"] == config.CUSTOM_ROUTE_UNOWNED, label + assert bundle["base_url"] is None and bundle["api_key"] is None, label + return + + assert bundle[config.CUSTOM_ROUTE_ERROR_FIELD] is None, label + actual = {field: bundle[field] for field in fields} + assert actual == expected, ( + f"{label}: the WebUI and the Agent would reach this provider differently" + ) + # ``credential_pool`` has no counterpart in the raw record, so the ambient + # provider's must not ride along with an endpoint it does not belong to. + assert bundle["credential_pool"] is None, label + + +def test_the_agent_oracle_disagrees_with_the_prefixed_only_reader(monkeypatch): + """Guard the oracle: it must actually resolve what the old reader could not. + + If ``_oracle_agent_selection`` quietly returned ``None`` for the raw shape, + every comparison above would collapse into the unowned branch and prove + nothing. Pin that it resolves the stock v12 record — and that it is reading + the RAW key, not a ``custom:``-prefixed one, by checking it also refuses a + record that names a different identity. + """ + _v12_env(monkeypatch) + cfg_dict = _v12_raw_cfg({"base_url": _V12_URL, "key_env": _V12_KEY_ENV}) + + selection = _oracle_agent_selection(cfg_dict, "custom:omni") + assert selection is not None, "the oracle cannot resolve the stock v12 shape" + assert selection["base_url"] == _V12_URL + assert selection["api_key"] == _V12_KEY + + assert _oracle_agent_selection(cfg_dict, "custom:ghost") is None + + +# ───────────────────────────────────────────────────────────────────────────── +# Reviewer probe: a DISABLED exact-keyed record must not route either +# +# ``providers['custom:']`` is the one candidate that names the slug by its +# own exact config key, and it was the one rung of the ladder that never ran the +# ``enabled`` check. A switched-off keyed record therefore kept handing out a +# complete, routable bundle — its endpoint AND its secret — while every other +# shape of the same disable failed closed. +# ───────────────────────────────────────────────────────────────────────────── + + +_EXACT_KEYED_URL = "https://exact-keyed-disabled-sentinel.example/v1" +_EXACT_KEYED_KEY = "exact-keyed-disabled-key-sentinel" + + +def _exact_keyed_disabled_cfg(flag): + """``providers['custom:omni']`` switched off, beside a live ambient provider. + + The generic ``custom`` record and the ``model:`` block are the less-specific + candidates the disabled key must NOT fall through to: honouring the disable + by routing the turn through the ACTIVE provider's endpoint and credential is + the same leak from the other direction. + """ + return { + "model": {"default": "active/model", "provider": "custom:active"}, + "providers": { + "custom:omni": { + "enabled": flag, + "base_url": _EXACT_KEYED_URL, + "api_key": _EXACT_KEYED_KEY, + }, + "custom": { + "base_url": _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], + "api_key": _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], + }, + }, + } + + +@pytest.mark.parametrize("flag", _V12_DISABLED_FLAGS, ids=[str(f) for f in _V12_DISABLED_FLAGS]) +def test_exact_keyed_disabled_provider_fails_closed(monkeypatch, flag): + """A disabled ``providers['custom:']`` owns nothing, at every boundary. + + Pinned across all three surfaces a send can enter through — the connection + view, the streaming bundle merge and the non-streaming chokepoint — because + the selection bug sat below all of them and a fix at one would leave the + other two routing a row the user switched off. + """ + import api.routes as routes + + _clear_credential_env(monkeypatch) + _with_direct_config(monkeypatch, _exact_keyed_disabled_cfg(flag)) + label = f"enabled: {flag!r}" + + # 1. Selection and the connection view: nothing owns the slug. + assert config.resolve_custom_provider_connection("custom:omni") == (None, None), label + selection = config.resolve_custom_provider_bundle("custom:omni") + assert selection["status"] == config.CUSTOM_SELECTION_MISSING, label + assert selection["record"] is None, label + assert selection["base_url"] is None and selection["api_key"] is None, label + + # 2. The streaming bundle merge: terminal, and stripped of every authority. + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], + _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], + _ambient_runtime(provider="custom:active"), + lookup_provider="custom:omni", + ) + verdict = bundle[config.CUSTOM_ROUTE_ERROR_FIELD] + assert verdict is not None, f"{label}: a disabled keyed record still looked routable" + assert verdict["reason"] == config.CUSTOM_ROUTE_UNOWNED, label + assert verdict["provider"] == "custom:omni", label + assert bundle["base_url"] is None and bundle["api_key"] is None, label + assert bundle["api_key"] != config.KEYLESS_CUSTOM_API_KEY, label + _assert_side_fields(bundle, _FOREIGN_AMBIENT_SIDE_FIELDS, label) + + # Neither the disabled record's own pair nor the ambient one it must not + # inherit may survive anywhere in the bundle. + blob = str(bundle) + for leaked in ( + _EXACT_KEYED_URL, + _EXACT_KEYED_KEY, + _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], + _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], + ): + assert leaked not in blob, f"{label}: {leaked!r} leaked onto the disabled route" + + # 3. The non-streaming chokepoint raises rather than returning a holed bundle. + with pytest.raises(config.CustomProviderRouteError) as excinfo: + routes._resolve_agent_connection_bundle( + "custom:omni", + _AMBIENT_SIDE_FIELD_RUNTIME["api_key"], + _AMBIENT_SIDE_FIELD_RUNTIME["base_url"], + _ambient_runtime(provider="custom:active"), + ) + err = excinfo.value + assert err.reason == config.CUSTOM_ROUTE_UNOWNED, label + assert err.provider == "custom:omni", label + assert err.hint, f"{label}: the refusal named no fix" + + +def test_exact_keyed_enabled_provider_still_owns_its_slug(monkeypatch): + """Negative control: only a FALSEY ``enabled`` hides the keyed record. + + A guard that read ``enabled`` as "present means off" would take every + explicitly-enabled keyed provider offline — the same outage as the leak, from + the opposite direction. + """ + _clear_credential_env(monkeypatch) + _with_direct_config(monkeypatch, _exact_keyed_disabled_cfg(True)) + + assert config.resolve_custom_provider_connection("custom:omni") == ( + _EXACT_KEYED_KEY, + _EXACT_KEYED_URL, + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# Reviewer probe: a shared endpoint is not shared credential authority +# +# ``_custom_bundle_endpoint_matches()`` identified the runtime as same-authority +# from the URL alone, so a record that merely declared the SAME base_url as the +# active provider was handed the active provider's key. Two providers sharing a +# host — a gateway fronting two accounts, a local proxy — is an ordinary setup, +# and there the record's URL rode out beside somebody else's secret. +# ───────────────────────────────────────────────────────────────────────────── + + +_SHARED_HOST_URL = "https://shared.example/v1" +_SHARED_HOST_AMBIENT_KEY = "ambient-secret-sentinel" +_SHARED_HOST_RECORD_KEY = "record-secret-sentinel" + + +def _shared_host_cfg(record): + """Raw provider ``omni`` on the shared host, while ``active-other`` is live.""" + return { + "model": {"default": "active/model", "provider": "active-other"}, + "providers": {"omni": copy.deepcopy(record)}, + } + + +def _shared_host_runtime(): + """The ambient runtime: a DIFFERENT provider that reached the same host.""" + return { + "provider": "active-other", + "base_url": _SHARED_HOST_URL, + "api_key": _SHARED_HOST_AMBIENT_KEY, + } + + +def test_shared_endpoint_different_provider_does_not_split_credential(monkeypatch): + """The record's OWN credential rides with the record's endpoint. + + Identical URLs plus different keys is exactly the case URL-equality cannot + tell apart: the ambient runtime names itself ``active-other``, so it is + provably a different authority no matter which host it reached. + """ + _clear_credential_env(monkeypatch) + record = {"base_url": _SHARED_HOST_URL, "api_key": _SHARED_HOST_RECORD_KEY} + _with_direct_config(monkeypatch, _shared_host_cfg(record)) + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + _SHARED_HOST_AMBIENT_KEY, + _SHARED_HOST_URL, + _shared_host_runtime(), + lookup_provider="custom:omni", + ) + + assert bundle[config.CUSTOM_ROUTE_ERROR_FIELD] is None + assert bundle["base_url"] == _SHARED_HOST_URL + assert bundle["api_key"] == _SHARED_HOST_RECORD_KEY, ( + "the non-active record inherited the ambient provider's secret across a " + "shared endpoint" + ) + assert bundle["api_key"] != _SHARED_HOST_AMBIENT_KEY + # The ambient provider is a foreign authority here, so its side fields go too. + assert bundle["credential_pool"] is None + + +def test_shared_endpoint_unresolved_key_fails_closed_not_borrow_ambient(monkeypatch): + """A declared-but-unresolved credential fails closed; it does not borrow. + + The dangerous shape: the record declares ``key_env`` and the variable is + unset, so its own ladder yields nothing. Filling that hole from the ambient + runtime just because both name one host is precisely the split-authority + send — the active provider's key against the record's route. + """ + _clear_credential_env(monkeypatch) + monkeypatch.delenv(_MISSING_ENV_VAR, raising=False) + record = {"base_url": _SHARED_HOST_URL, "key_env": _MISSING_ENV_VAR} + _with_direct_config(monkeypatch, _shared_host_cfg(record)) + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + _SHARED_HOST_AMBIENT_KEY, + _SHARED_HOST_URL, + _shared_host_runtime(), + lookup_provider="custom:omni", + ) + + verdict = bundle[config.CUSTOM_ROUTE_ERROR_FIELD] + assert verdict is not None, "an unresolvable credential still looked routable" + assert verdict["reason"] == config.CUSTOM_ROUTE_NO_CREDENTIAL + assert verdict["provider"] == "custom:omni" + assert bundle["api_key"] is None + assert bundle["api_key"] != _SHARED_HOST_AMBIENT_KEY + # Not the keyless placeholder either: the record DOES declare a credential + # source, so an unauthenticated send would be a silent 401, not a keyless one. + assert bundle["api_key"] != config.KEYLESS_CUSTOM_API_KEY + assert _SHARED_HOST_AMBIENT_KEY not in str(bundle) + + +def test_same_provider_sharing_its_endpoint_still_keeps_the_runtime_fields(monkeypatch): + """Negative control: the identity test must not orphan the provider's OWN runtime. + + The ambient runtime dict normally names the very slug being resolved (that is + the active-custom-provider shape the WebUI writes). A provenance test that + rejected it would clear the side fields of every live custom provider. + """ + _clear_credential_env(monkeypatch) + record = {"base_url": _SHARED_HOST_URL, "api_key": _SHARED_HOST_RECORD_KEY} + _with_direct_config(monkeypatch, _shared_host_cfg(record)) + + runtime = _shared_host_runtime() + runtime["provider"] = "custom:omni" + runtime["api_mode"] = "chat_completions" + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + _SHARED_HOST_AMBIENT_KEY, + _SHARED_HOST_URL, + runtime, + lookup_provider="custom:omni", + ) + + assert bundle[config.CUSTOM_ROUTE_ERROR_FIELD] is None + assert bundle["base_url"] == _SHARED_HOST_URL + # Still the record's own credential — same authority does not make the + # ambient key authoritative over one the record declares for itself. + assert bundle["api_key"] == _SHARED_HOST_RECORD_KEY + assert bundle["api_mode"] == "chat_completions", ( + "the provider's own runtime side field was cleared as foreign" + ) + + +# ───────────────────────────────────────────────────────────────────────────── +# A credential-only record borrows NO endpoint (PR #7319) +# +# The shape that started this: ``providers: {omni: {api_key: ...}}`` — a raw v12 +# record that declares a CREDENTIAL and no ``base_url`` — while the process is +# already talking to a different, ACTIVE provider whose URL and key are sitting +# in the runtime dict. Selection legitimately picks the omni record (it owns the +# slug, and a declared-but-endpoint-less record is not "missing"), and the merge +# then had no positive statement about where that record's endpoint came from. +# The seeded ``base_url`` was simply still there, so the bundle handed the +# constructor the ACTIVE provider's URL beside the omni record's secret: a +# non-active provider's credential, and the user's prompt, delivered to an +# endpoint neither the record nor the user ever named. +# +# ``endpoint_owned`` is the positive half of that provenance, and it is False +# here. Absence of contradiction is not a tie: the caller's ``resolved_base_url`` +# and a missing runtime dict are both silence. So the route is terminal on its +# own name — :data:`config.CUSTOM_ROUTE_NO_ENDPOINT` — and keeps neither the +# ambient endpoint nor a credential to pair with it, because pairing them is the +# whole defect. +# +# These assert the property at the boundaries that can actually send: the +# production-composed streaming send (all three agent-constructing regions), the +# non-streaming chokepoint, and the auxiliary client — which bypasses AIAgent +# entirely, so ``main_runtime`` would be the only place the stolen pair reached +# the wire. The last two tests are the controls that keep the refusal narrow: a +# COMPLETE record still sends, and a genuinely keyless one still gets the +# placeholder. +# ───────────────────────────────────────────────────────────────────────────── + + +# The non-active record's own secret: the thing that must never leave with the +# active provider's URL. +_CREDENTIAL_ONLY_KEY = "credential-only-omni-key-sentinel" +# The endpoint the record declares in the two control cases below. +_OMNI_OWN_URL = "https://omni-own-endpoint-sentinel.example/v1" + +# The ACTIVE provider: truthy URL and key, plus truthy side fields, all resolved +# for somebody else entirely. Named ``custom:active-other`` so identity settles +# the provenance question without relying on the URLs differing. +_ACTIVE_OTHER_URL = "https://active-other-sentinel.example/v1" +_ACTIVE_OTHER_KEY = "active-other-key-sentinel" +_ACTIVE_OTHER_POOL = ["active-other-pool-sentinel"] +_ACTIVE_OTHER_ACP = "active-other-acp-sentinel" + +_ACTIVE_OTHER_RUNTIME = { + "provider": "custom:active-other", + "base_url": _ACTIVE_OTHER_URL, + "api_key": _ACTIVE_OTHER_KEY, + "api_mode": "chat_completions", + "command": _ACTIVE_OTHER_ACP, + "args": ["--active-other-arg-sentinel"], + "credential_pool": _ACTIVE_OTHER_POOL, +} + +# Everything the refused route could have walked away with. Asserted as a set +# over the whole captured harness, so a leak through ANY field — bundle, error +# payload, constructor kwargs, aux handoff — fails rather than only the fields a +# test remembered to name. +_ACTIVE_OTHER_SENTINELS = ( + _ACTIVE_OTHER_URL, + _ACTIVE_OTHER_KEY, + _ACTIVE_OTHER_POOL[0], + _ACTIVE_OTHER_ACP, +) + +_CREDENTIAL_ONLY_MODEL = "@custom:omni:antigravity/gemini-3.7-flash-tiered" + + +def _credential_only_cfg(): + """The raw v12 record that declares a credential and NO endpoint.""" + return _v12_raw_cfg({"api_key": _CREDENTIAL_ONLY_KEY}) + + +def _assert_no_borrowed_pair(blob, label): + """Neither the record's own secret nor the active provider's connection.""" + for leaked in _ACTIVE_OTHER_SENTINELS: + assert leaked not in blob, ( + f"{label}: the ACTIVE provider's {leaked!r} survived a route whose " + "record declares no endpoint" + ) + assert _CREDENTIAL_ONLY_KEY not in blob, ( + f"{label}: the non-active record's credential was handed on without an " + "endpoint of its own to send it to" + ) + assert config.KEYLESS_CUSTOM_API_KEY not in blob, ( + f"{label}: a record that DECLARES a credential was called keyless" + ) + + +def test_credential_only_record_refuses_the_active_providers_endpoint(monkeypatch): + """The merge verdict itself: terminal, and holding neither half of the pair.""" + _clear_credential_env(monkeypatch) + _with_direct_config(monkeypatch, _credential_only_cfg()) + + selection = config.resolve_custom_provider_bundle("custom:omni") + assert selection["record"] is not None, ( + "a credential-only record must still OWN its slug — treating it as " + "missing is a different bug with the same symptom" + ) + assert selection["endpoint_owned"] is False + assert selection["api_key"] == _CREDENTIAL_ONLY_KEY + assert selection["keyless"] is False + + bundle = config.merge_custom_provider_runtime_bundle( + "custom:omni", + _ACTIVE_OTHER_KEY, + _ACTIVE_OTHER_URL, + copy.deepcopy(_ACTIVE_OTHER_RUNTIME), + lookup_provider="custom:omni", + ) + + verdict = bundle[config.CUSTOM_ROUTE_ERROR_FIELD] + assert verdict is not None, "an endpoint-less record still looked routable" + assert verdict["reason"] == config.CUSTOM_ROUTE_NO_ENDPOINT + assert verdict["provider"] == "custom:omni" + assert verdict["hint"], "the refusal named no setting to fix" + assert bundle["base_url"] is None + assert bundle["api_key"] is None + # Still the NAMED slug: rewriting it to generic ``custom`` would present an + # unresolvable route as a resolved one. + assert bundle["provider"] == "custom:omni" + # The refused endpoint's side fields go with it; this record declares none. + _assert_side_fields(bundle, _FOREIGN_AMBIENT_SIDE_FIELDS, "credential-only record") + _assert_no_borrowed_pair(str(bundle), "merged bundle") + + +def test_credential_only_record_refuses_the_production_composed_send(monkeypatch): + """Refuse on initial send before any constructor, client, or cache write. + + A raw record declaring only a credential and no endpoint must fail closed + at initial resolution as ``CUSTOM_ROUTE_NO_ENDPOINT`` + (custom_provider_endpoint_unresolved), constructing no agent, making no + explicit client call, sending no turn, and writing no cache entry. + """ + _clear_credential_env(monkeypatch) + + captured, apperrors = _run_composed_send_expecting_refusal( + monkeypatch, + _credential_only_cfg(), + copy.deepcopy(_ACTIVE_OTHER_RUNTIME), + "session-1806-credential-only-omni", + model=_CREDENTIAL_ONLY_MODEL, + ) + + payload = apperrors[-1] + # The STRUCTURED verdict, not the prose: a generic failure whose message + # happens to read "resolved no endpoint" would pass a message-only check, + # and the message is free to be reworded. This is the same terminal reason + # the merge-level and auxiliary-client regressions assert. + # The literal is pinned alongside the constant because it is the wire value + # the client branches on; renaming it silently would be a breaking change. + assert payload["reason"] == config.CUSTOM_ROUTE_NO_ENDPOINT == ( + "custom_provider_endpoint_unresolved" + ), f"the failure did not carry {config.CUSTOM_ROUTE_NO_ENDPOINT}: {payload}" + assert "custom:omni" in payload["message"], payload + assert "resolved no endpoint" in payload["message"], ( + f"the failure did not name {config.CUSTOM_ROUTE_NO_ENDPOINT}: {payload}" + ) + assert "base_url" in payload["hint"], payload + + assert not captured.get("init_kwargs_history"), ( + "an agent was constructed for a record that declares no endpoint" + ) + assert not captured.get("explicit_client_kwargs_calls"), ( + "a client was configured with the active provider's borrowed connection" + ) + + _assert_no_borrowed_pair(str(payload) + str(captured), "composed send") + + +@pytest.mark.parametrize("fail_first", ["returned_error", "raised"]) +def test_credential_only_record_refuses_the_production_composed_retry( + monkeypatch, fail_first +): + """A standard-v12 record losing only its endpoint stops a 401 self-heal. + + The initial send is routable and cached with the complete raw record. During + the self-heal, only that record's ``base_url`` is removed; its credential and + the foreign ambient runtime bundle remain available. The refreshed terminal + route must therefore prevent every retry construction and cache write. + """ + _clear_credential_env(monkeypatch) + session_id = f"session-1806-credential-only-omni-{fail_first}" + label = f"credential-only record / {fail_first}" + cfg_dict = _v12_raw_cfg({"base_url": _V12_URL, "api_key": _V12_KEY}) + + def remove_only_the_endpoint(): + config.cfg["providers"]["omni"]["base_url"] = "" + + captured, apperrors, cache_at_heal, cache_after = ( + _run_composed_retry_expecting_abandoned_heal( + monkeypatch, + cfg_dict, + copy.deepcopy(_ACTIVE_OTHER_RUNTIME), + session_id, + fail_first=fail_first, + heal_mutate=remove_only_the_endpoint, + ) + ) + + _assert_retry_abandoned( + captured, + apperrors, + cache_at_heal, + cache_after, + session_id, + label, + expected_cause="resolved no endpoint", + expected_initial_url=_V12_URL, + expected_initial_key=_V12_KEY, + ) + + payload = apperrors[-1] + assert payload["reason"] == config.CUSTOM_ROUTE_NO_ENDPOINT, payload + assert "base_url" in payload["hint"], payload + assert _V12_KEY not in str(payload), f"{label}: leaked the record's own key into payload" + blob = str(captured) + str(apperrors) + for leaked in _ACTIVE_OTHER_SENTINELS: + assert leaked not in blob, f"{label}: {leaked!r} leaked into the retry path" + assert config.KEYLESS_CUSTOM_API_KEY not in blob + + +# The two consumers whose auxiliary client can answer outright. There AIAgent is +# never built, so ``main_runtime`` is the ONLY carrier of the resolved authority +# — and the only place a borrowed pair could still reach the wire after every +# constructor assertion above passes. +_CREDENTIAL_ONLY_AUX_DRIVERS = [ + ( + "git commit message", + lambda routes, session: routes._llm_git_commit_message("sys", "user", session=session), + ), + ( + "update summary", + lambda routes, _session: routes._llm_update_summary("sys", "user", active_profile=None), + ), +] + + +@pytest.mark.parametrize( + "label,invoke", + _CREDENTIAL_ONLY_AUX_DRIVERS, + ids=[d[0] for d in _CREDENTIAL_ONLY_AUX_DRIVERS], +) +def test_credential_only_record_reaches_no_auxiliary_client(monkeypatch, label, invoke): + """The refusal is terminal BEFORE ``main_runtime`` is built, so the aux client + is never handed the active provider's connection. + + ``_resolve_agent_connection_bundle`` raises at the chokepoint, which is + upstream of ``_auxiliary_main_runtime``. Returning the holed bundle instead + would let the aux path send with the borrowed pair while bypassing every + AIAgent-side guard entirely. + """ + import api.routes as routes + + _clear_credential_env(monkeypatch) + cfg_dict = _credential_only_cfg() + cfg_dict["model"] = {"default": _CREDENTIAL_ONLY_MODEL, "provider": "custom:omni"} + + captured, fake_session = _setup_route_consumer_runtime( + monkeypatch, + cfg_dict=cfg_dict, + runtime_dict=copy.deepcopy(_ACTIVE_OTHER_RUNTIME), + ) + recorded_main_runtime = {} + _decline_auxiliary_client(monkeypatch, recorded_main_runtime) + + with pytest.raises(config.CustomProviderRouteError) as excinfo: + invoke(routes, fake_session) + + assert excinfo.value.reason == config.CUSTOM_ROUTE_NO_ENDPOINT, label + assert excinfo.value.provider == "custom:omni", label + assert excinfo.value.hint, f"{label}: the refusal named no setting to fix" + + assert not recorded_main_runtime, ( + f"{label}: the auxiliary client was handed {recorded_main_runtime!r} for a " + "route with no endpoint of its own" + ) + assert "init_kwargs" not in captured, f"{label}: an agent was constructed anyway" + _assert_no_borrowed_pair( + str(excinfo.value) + str(recorded_main_runtime) + str(captured), label + ) + + +# ── controls: the refusal is about MISSING provenance, not about raw records ── + + +def test_complete_raw_record_still_sends_through_its_own_endpoint(monkeypatch): + """Control: the same record WITH a ``base_url`` routes exactly as before. + + Without this, "refuse when ``endpoint_owned`` is False" is equally satisfied + by refusing every raw ``providers:`` record — which would break the + standard v12 shape outright. + """ + _clear_credential_env(monkeypatch) + + init_kwargs = _run_composed_send( + monkeypatch, + _v12_raw_cfg({"base_url": _OMNI_OWN_URL, "api_key": _CREDENTIAL_ONLY_KEY}), + copy.deepcopy(_ACTIVE_OTHER_RUNTIME), + "session-1806-credential-only-control-complete", + ) + + assert init_kwargs["base_url"] == _OMNI_OWN_URL + assert init_kwargs["api_key"] == _CREDENTIAL_ONLY_KEY + assert init_kwargs["provider"] == "custom" + # The record owns an endpoint the active provider did not resolve, so the + # active provider's side fields are provably foreign and go. + _assert_side_fields(init_kwargs, _FOREIGN_AMBIENT_SIDE_FIELDS, "complete raw record") + for leaked in _ACTIVE_OTHER_SENTINELS: + assert leaked not in str(init_kwargs), ( + f"the active provider's {leaked!r} reached a record-owned route" + ) + + +def test_genuinely_keyless_raw_record_still_sends_with_the_placeholder(monkeypatch): + """Control: a record declaring an endpoint and NO credential is still keyless. + + The mirror of the case under test — endpoint but no credential, rather than + credential but no endpoint. This one is a complete statement about the route + ("that endpoint is unauthenticated"), so it must still route with + :data:`config.KEYLESS_CUSTOM_API_KEY` and must NOT borrow the active + provider's key on the way. + """ + _clear_credential_env(monkeypatch) + + init_kwargs = _run_composed_send( + monkeypatch, + _v12_raw_cfg({"base_url": _OMNI_OWN_URL}), + copy.deepcopy(_ACTIVE_OTHER_RUNTIME), + "session-1806-credential-only-control-keyless", + ) + + assert init_kwargs["base_url"] == _OMNI_OWN_URL + assert init_kwargs["api_key"] == config.KEYLESS_CUSTOM_API_KEY + assert init_kwargs["api_key"] != _ACTIVE_OTHER_KEY + assert init_kwargs["provider"] == "custom" diff --git a/tests/test_issue1897_profile_switch_agent_cache.py b/tests/test_issue1897_profile_switch_agent_cache.py index 7c6782f7..684c5a01 100644 --- a/tests/test_issue1897_profile_switch_agent_cache.py +++ b/tests/test_issue1897_profile_switch_agent_cache.py @@ -13,10 +13,151 @@ REPO = Path(__file__).resolve().parent.parent STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8") +def _compute_agent_cache_signature_source() -> str: + """Return the source of the `_compute_agent_cache_signature()` helper. + + The signature blob used to be inlined in the streaming send path; it now + lives in this helper so the initial send and both self-heal retry paths + derive the signature from the same final runtime bundle. + """ + start = STREAMING_PY.index("def _compute_agent_cache_signature(") + end = STREAMING_PY.index("\ndef ", start) + return STREAMING_PY[start:end] + + def _signature_block() -> str: - sig_start = STREAMING_PY.index("_sig_blob = _json.dumps") - sig_end = STREAMING_PY.index("], sort_keys=True)", sig_start) - return STREAMING_PY[sig_start:sig_end] + """Return the `_json.dumps([...])` field list the signature hashes.""" + helper = _compute_agent_cache_signature_source() + sig_start = helper.index("_sig_blob = _json.dumps") + sig_end = helper.index("], sort_keys=True)", sig_start) + return helper[sig_start:sig_end] + + +def _production_signature_calls() -> list[tuple[int, str]]: + """Return `(offset, source)` for every production signature call site. + + Paren-balanced so the whole multi-line keyword-argument list is captured, + and every live call site is returned so a retry path cannot silently drop a + field that the initial send still passes. Commented-out occurrences are + skipped -- a disabled retry call must read as missing, not as present. + """ + marker = "_agent_sig = _compute_agent_cache_signature(" + calls: list[tuple[int, str]] = [] + pos = STREAMING_PY.find(marker) + while pos != -1: + line_start = STREAMING_PY.rfind("\n", 0, pos) + 1 + if STREAMING_PY[line_start:pos].strip(): + # Commented-out (or otherwise non-statement) occurrence: it no + # longer runs, so it must not count as a live call site. + pos = STREAMING_PY.find(marker, pos + 1) + continue + depth = 0 + for idx in range(pos + len(marker) - 1, len(STREAMING_PY)): + char = STREAMING_PY[idx] + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + calls.append((pos, STREAMING_PY[pos:idx + 1])) + break + else: + raise AssertionError("unterminated _compute_agent_cache_signature( call") + pos = STREAMING_PY.find(marker, pos + 1) + return calls + + +# Lifecycle anchors for the streaming send path. Pinning one signature call to +# each region keeps the oracle honest: a retry call that silently disappears +# fails the region check instead of hiding behind the calls that remain. +INITIAL_SEND_MARKER = "# ── Agent cache: reuse across messages in the same session ──" +RETURNED_ERROR_SELF_HEAL_MARKER = ( + "logger.info('[webui] self-heal: retrying stream after credential refresh')" +) +RAISED_EXCEPTION_SELF_HEAL_MARKER = ( + "logger.info('[webui] self-heal (except path): " + "retrying stream after credential refresh')" +) + +# Each region ends at the cache write that stores the signature it just +# computed. Bounding the region at the write -- rather than at the next +# lifecycle anchor or at EOF -- is what proves the signature is recomputed +# *before* it is cached: a call moved below its own write lands outside the +# region and fails, instead of passing while the write consumes a stale +# `_agent_sig`. +INITIAL_SEND_CACHE_WRITE_MARKER = ( + "SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)" +) +RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER = ( + "_SAC[session_id] = (agent, _agent_sig)" +) +RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER = ( + "_SAC2[session_id] = (_heal_agent, _agent_sig)" +) + + +def _marker_offset(marker: str) -> int: + """Return the single offset of a lifecycle marker in streaming.py.""" + offset = STREAMING_PY.index(marker) + assert STREAMING_PY.find(marker, offset + 1) == -1, ( + "lifecycle marker is no longer unique in streaming.py:\n" + marker + ) + return offset + + +def _signature_calls_by_region( + calls: list[tuple[int, str]], +) -> dict[str, tuple[int, str]]: + """Map each lifecycle region to the single signature call inside it. + + The regions are the initial send, the returned-error self-heal retry and + the raised-exception self-heal retry; each runs from its lifecycle anchor + to the cache write that consumes `_agent_sig`. Requiring exactly one call + inside those bounds means neither a dropped retry call (masked by its + surviving siblings) nor a call recomputed after its own cache write (which + would leave the write storing a stale signature) can read as correct. + """ + initial = _marker_offset(INITIAL_SEND_MARKER) + initial_cache_write = _marker_offset(INITIAL_SEND_CACHE_WRITE_MARKER) + returned_error = _marker_offset(RETURNED_ERROR_SELF_HEAL_MARKER) + returned_error_cache_write = _marker_offset( + RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER + ) + raised_exception = _marker_offset(RAISED_EXCEPTION_SELF_HEAL_MARKER) + raised_exception_cache_write = _marker_offset( + RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER + ) + assert ( + initial + < initial_cache_write + < returned_error + < returned_error_cache_write + < raised_exception + < raised_exception_cache_write + ), ( + "streaming.py lifecycle regions are out of order; these anchors no " + "longer describe the send path" + ) + + bounds = { + "initial send": (initial, initial_cache_write), + "returned-error self-heal": (returned_error, returned_error_cache_write), + "raised-exception self-heal": ( + raised_exception, + raised_exception_cache_write, + ), + } + by_region: dict[str, tuple[int, str]] = {} + for region, (start, end) in bounds.items(): + found = [(offset, call) for offset, call in calls if start < offset < end] + assert len(found) == 1, ( + "expected exactly one _compute_agent_cache_signature() call in the " + f"{region} region of streaming.py -- between its lifecycle anchor " + f"and the cache write that stores `_agent_sig` -- found " + f"{len(found)}" + ) + by_region[region] = found[0] + return by_region def test_same_session_profile_switch_rebuilds_agent_under_new_soul_home(tmp_path, monkeypatch): @@ -225,22 +366,51 @@ def test_same_session_profile_switch_rebuilds_agent_under_new_soul_home(tmp_path def test_cache_signature_includes_profile_home(): block = _signature_block() - assert "_profile_home" in block, ( - "SESSION_AGENT_CACHE signature is missing `_profile_home`. Without this, " + assert "profile_home" in block, ( + "SESSION_AGENT_CACHE signature is missing `profile_home`. Without this, " "same-session profile switches reuse the cached agent built under the " "previous profile's HERMES_HOME, leaking the old SOUL.md into new turns." ) + calls = _production_signature_calls() + assert len(calls) == 3, ( + "expected exactly three _compute_agent_cache_signature() call sites " + "(initial send plus both self-heal retries), found " + f"{len(calls)}" + ) + for region, (_offset, call) in _signature_calls_by_region(calls).items(): + assert "profile_home=_profile_home" in call, ( + f"the {region} signature call site must pass the resolved profile home, " + "or a retry re-caches the agent under a signature that ignores the active profile:\n" + call + ) + def test_profile_home_resolved_before_cache_signature(): profile_home_assignment = STREAMING_PY.index("_profile_home = str(_profile_home_path)") - sig_start = STREAMING_PY.index("_sig_blob = _json.dumps") - assert profile_home_assignment < sig_start + + calls = _production_signature_calls() + assert len(calls) == 3, ( + "expected exactly three _compute_agent_cache_signature() call sites " + "(initial send plus both self-heal retries), found " + f"{len(calls)}" + ) + for region, (offset, call) in _signature_calls_by_region(calls).items(): + assert profile_home_assignment < offset, ( + "`_profile_home` must be resolved before the cache signature is " + f"computed, otherwise the {region} signature hashes a stale/unbound " + "home." + ) + assert "profile_home=_profile_home" in call, call + assert "max_iterations_cfg=_max_iterations_cfg" in call, call + assert "max_tokens_cfg=_max_tokens_cfg" in call, call def test_signature_uses_profile_home_with_fallback(): block = _signature_block() - assert "_profile_home or ''" in block, ( - "Signature should use `_profile_home or ''` so empty-home deployments get " + assert "profile_home or ''" in block, ( + "Signature should use `profile_home or ''` so empty-home deployments get " "a stable cache key rather than unnecessary cache churn." ) + + for _offset, call in _production_signature_calls(): + assert "profile_home=_profile_home" in call, call diff --git a/tests/test_sprint42.py b/tests/test_sprint42.py index b3bc27ee..a8b6d5ce 100644 --- a/tests/test_sprint42.py +++ b/tests/test_sprint42.py @@ -123,12 +123,17 @@ class TestRuntimeRouteInjection(unittest.TestCase): Since issue #772 these are passed defensively via inspect-guarded kwargs so the WebUI degrades gracefully against older hermes-agent builds. + + They are read from the resolved connection bundle rather than straight + off the runtime provider dict: a named custom provider owns none of them, + so the bundle clears them and forwarding ``_rt`` directly would send the + ambient provider's transport/protocol/pool to a custom endpoint. """ for snippet in ( - "_agent_kwargs['api_mode'] = _rt.get('api_mode')", - "_agent_kwargs['acp_command'] = _rt.get('command')", - "_agent_kwargs['acp_args'] = _rt.get('args')", - "_agent_kwargs['credential_pool'] = _rt.get('credential_pool')", + "_agent_kwargs['api_mode'] = _runtime_bundle['api_mode']", + "_agent_kwargs['acp_command'] = _runtime_bundle['acp_command']", + "_agent_kwargs['acp_args'] = _runtime_bundle['acp_args']", + "_agent_kwargs['credential_pool'] = _runtime_bundle['credential_pool']", ): self.assertIn( snippet, diff --git a/tests/test_streaming_max_tokens_quota.py b/tests/test_streaming_max_tokens_quota.py index 2e37734d..ed352290 100644 --- a/tests/test_streaming_max_tokens_quota.py +++ b/tests/test_streaming_max_tokens_quota.py @@ -16,6 +16,156 @@ def _src() -> str: return STREAMING.read_text(encoding="utf-8") +def _compute_agent_cache_signature_source() -> str: + """Return the source of the `_compute_agent_cache_signature()` helper. + + The signature blob used to be inlined in the streaming send path; it now + lives in this helper so the initial send and both self-heal retry paths + derive the signature from the same final runtime bundle. + """ + src = _src() + start = src.index("def _compute_agent_cache_signature(") + end = src.index("\ndef ", start) + return src[start:end] + + +def _signature_blob() -> str: + """Return the `_json.dumps([...])` field list the signature hashes.""" + helper = _compute_agent_cache_signature_source() + blob_start = helper.index("_sig_blob = _json.dumps") + blob_end = helper.index("], sort_keys=True)", blob_start) + return helper[blob_start:blob_end] + + +def _production_signature_calls() -> list[tuple[int, str]]: + """Return `(offset, source)` for every production signature call site. + + Paren-balanced so the whole multi-line keyword-argument list is captured, + and every live call site is returned so a retry path cannot silently drop a + field that the initial send still passes. Commented-out occurrences are + skipped -- a disabled retry call must read as missing, not as present. + """ + src = _src() + marker = "_agent_sig = _compute_agent_cache_signature(" + calls: list[tuple[int, str]] = [] + pos = src.find(marker) + while pos != -1: + line_start = src.rfind("\n", 0, pos) + 1 + if src[line_start:pos].strip(): + # Commented-out (or otherwise non-statement) occurrence: it no + # longer runs, so it must not count as a live call site. + pos = src.find(marker, pos + 1) + continue + depth = 0 + for idx in range(pos + len(marker) - 1, len(src)): + char = src[idx] + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + calls.append((pos, src[pos:idx + 1])) + break + else: + raise AssertionError("unterminated _compute_agent_cache_signature( call") + pos = src.find(marker, pos + 1) + return calls + + +# Lifecycle anchors for the streaming send path. Pinning one signature call to +# each region keeps the oracle honest: a retry call that silently disappears +# fails the region check instead of hiding behind the calls that remain. +INITIAL_SEND_MARKER = "# ── Agent cache: reuse across messages in the same session ──" +RETURNED_ERROR_SELF_HEAL_MARKER = ( + "logger.info('[webui] self-heal: retrying stream after credential refresh')" +) +RAISED_EXCEPTION_SELF_HEAL_MARKER = ( + "logger.info('[webui] self-heal (except path): " + "retrying stream after credential refresh')" +) + +# Each region ends at the cache write that stores the signature it just +# computed. Bounding the region at the write -- rather than at the next +# lifecycle anchor or at EOF -- is what proves the signature is recomputed +# *before* it is cached: a call moved below its own write lands outside the +# region and fails, instead of passing while the write consumes a stale +# `_agent_sig`. +INITIAL_SEND_CACHE_WRITE_MARKER = ( + "SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig)" +) +RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER = ( + "_SAC[session_id] = (agent, _agent_sig)" +) +RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER = ( + "_SAC2[session_id] = (_heal_agent, _agent_sig)" +) + + +def _marker_offset(marker: str) -> int: + """Return the single offset of a lifecycle marker in streaming.py.""" + src = _src() + offset = src.index(marker) + assert src.find(marker, offset + 1) == -1, ( + "lifecycle marker is no longer unique in streaming.py:\n" + marker + ) + return offset + + +def _signature_calls_by_region( + calls: list[tuple[int, str]], +) -> dict[str, tuple[int, str]]: + """Map each lifecycle region to the single signature call inside it. + + The regions are the initial send, the returned-error self-heal retry and + the raised-exception self-heal retry; each runs from its lifecycle anchor + to the cache write that consumes `_agent_sig`. Requiring exactly one call + inside those bounds means neither a dropped retry call (masked by its + surviving siblings) nor a call recomputed after its own cache write (which + would leave the write storing a stale signature) can read as correct. + """ + initial = _marker_offset(INITIAL_SEND_MARKER) + initial_cache_write = _marker_offset(INITIAL_SEND_CACHE_WRITE_MARKER) + returned_error = _marker_offset(RETURNED_ERROR_SELF_HEAL_MARKER) + returned_error_cache_write = _marker_offset( + RETURNED_ERROR_SELF_HEAL_CACHE_WRITE_MARKER + ) + raised_exception = _marker_offset(RAISED_EXCEPTION_SELF_HEAL_MARKER) + raised_exception_cache_write = _marker_offset( + RAISED_EXCEPTION_SELF_HEAL_CACHE_WRITE_MARKER + ) + assert ( + initial + < initial_cache_write + < returned_error + < returned_error_cache_write + < raised_exception + < raised_exception_cache_write + ), ( + "streaming.py lifecycle regions are out of order; these anchors no " + "longer describe the send path" + ) + + bounds = { + "initial send": (initial, initial_cache_write), + "returned-error self-heal": (returned_error, returned_error_cache_write), + "raised-exception self-heal": ( + raised_exception, + raised_exception_cache_write, + ), + } + by_region: dict[str, tuple[int, str]] = {} + for region, (start, end) in bounds.items(): + found = [(offset, call) for offset, call in calls if start < offset < end] + assert len(found) == 1, ( + "expected exactly one _compute_agent_cache_signature() call in the " + f"{region} region of streaming.py -- between its lifecycle anchor " + f"and the cache write that stores `_agent_sig` -- found " + f"{len(found)}" + ) + by_region[region] = found[0] + return by_region + + def test_streaming_passes_configured_max_tokens_to_agent(): src = _src() assert "_raw_max_tokens = _cfg.get('max_tokens')" in src @@ -24,9 +174,31 @@ def test_streaming_passes_configured_max_tokens_to_agent(): def test_streaming_agent_cache_signature_includes_max_tokens_and_fallback(): - src = _src() - assert "_max_tokens_cfg or ''" in src - assert "_fallback_resolved or {}" in src + blob = _signature_blob() + assert "max_tokens_cfg or ''" in blob, ( + "_compute_agent_cache_signature() must hash max_tokens_cfg, or a " + "max_tokens change reuses the agent built on the old output ceiling." + ) + assert "fallback_resolved or {}" in blob, ( + "_compute_agent_cache_signature() must hash the resolved fallback " + "chain so a fallback edit mints a new agent." + ) + + calls = _production_signature_calls() + assert len(calls) == 3, ( + "expected exactly three _compute_agent_cache_signature() call sites " + "(initial send plus both self-heal retries), found " + f"{len(calls)}" + ) + for region, (_offset, call) in _signature_calls_by_region(calls).items(): + assert "max_tokens_cfg=_max_tokens_cfg" in call, ( + f"the {region} signature call must pass the resolved " + "max_tokens:\n" + call + ) + assert "fallback_resolved=_fallback_resolved" in call, ( + f"the {region} signature call must pass the resolved fallback " + "chain:\n" + call + ) def test_openrouter_more_credits_error_is_classified_as_quota(): From 8d628f4eaeeabb03b89a458c8d9dfca1d6eb748a Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 15 Sep 2026 20:10:06 -0700 Subject: [PATCH 061/446] Release: route @custom: models to their own base_url (#7319) (#7592) Co-authored-by: n --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5643e9a5..0534116c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### Fixed +- **Models picked from a second custom provider group route to that provider's own endpoint instead of failing with a 400.** A model selected from a custom provider registered only under `custom_providers:` (picker value like `@custom:omni:antigravity/gemini-3.7-flash-tiered`) was resolved against the active provider's credentials, so it returned `HTTP 400 Invalid model format or no credentials for provider: `. Provider-qualified `@custom:` ids now resolve to the owning provider's own `base_url` and credentials. A `providers.` record with no endpoint fails closed with a terminal `CUSTOM_ROUTE_NO_ENDPOINT` (clearing any foreign connection bundle before construction or cache publication) rather than leaking another provider's endpoint, and cross-provider credential/base-URL isolation plus per-profile agent-cache signatures are preserved. Thanks @b3nw. (#7319) + - **Structured (multimodal) message content stored in `state.db` renders as its real content instead of a placeholder, without dropping or misattributing turns.** When the session projection decodes the `state.db` content sentinel, list-valued content now gets an out-of-band identity — a `("structured_content", canonical)` tuple that no scalar string can compare equal to — shared across the merge, dedup, content, and visible reconciliation keys, so an in-band string can't collide with a rich row and an image-only row can no longer vanish from the projection. The expensive canonical serialisation is memoised per content object for the duration of a single `merge_session_messages_append_only()` call (scoped through a `ContextVar`, keyed by object identity with the object held alive so an id can't be reused for a different list mid-call), restoring one serialisation per list per call. Empty/falsy content keys exactly as before. Thanks @totalitarian. (#7492) - **An empty composer no longer inflates to fit its own placeholder on the JavaScript sizing path.** The fallback resizer measured `scrollHeight` for an empty textarea, which reflects the *placeholder* — so a long busy or compression hint (which wraps to two or three lines) grew the empty composer to roughly 71px, and only after a resize that happened while empty, making the resting height depend on history rather than content. The inline height is now cleared when the value is empty so CSS `min-height` defines the resting size, matching the native path. (#6760) From d43edcf35239e110170a7149d90aef70b9820996 Mon Sep 17 00:00:00 2001 From: Steve Frost Date: Tue, 15 Sep 2026 23:10:34 -0400 Subject: [PATCH 062/446] docs(agents): scope documentation pointers by task and state completion criteria --- AGENTS.md | 84 +++++++++++++++++++++++++++---------------------------- 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5d094ee3..d1b785bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,25 +5,23 @@ repository. Keep it project-specific and safe to publish. Do not put personal machine setup, private network details, credentials, tokens, or local-only workflow notes here. -## Read first +## Documentation by task -Before making changes, read: - -1. `README.md` -2. `CONTRIBUTING.md` -3. `docs/CONTRACTS.md` -4. `CHANGELOG.md` - -For architecture, testing, or setup work, also read the matching reference: +Use the references that match the change; there is no blanket reading list for +unrelated work. Keep subsystem-specific safety and contract requirements below. +- `README.md` for product behavior, setup, usage, and the docs index +- `CONTRIBUTING.md` for contribution scope, PR format, and review requirements +- `docs/CONTRACTS.md` to find the contract/RFC for the subsystem being changed +- `docs/GUIDELINES.md` for bug-class coverage, state ownership, and evidence standards +- `CHANGELOG.md` for release-history questions; ordinary PRs do not edit it - `ARCHITECTURE.md` for design constraints and current module layout -- `TESTING.md` for local verification commands and manual test guidance +- `TESTING.md` for local verification commands and relevant manual checks - `docs/onboarding.md` for first-run onboarding behavior - `docs/troubleshooting.md` for diagnostic flows - `docs/rfcs/README.md` for larger RFCs and state/durability contracts - -For UI or UX work, read `docs/UIUX-GUIDE.md` and `DESIGN.md` before -changing layout, interaction flow, themes, chat rendering, or composer chrome. +- `docs/UIUX-GUIDE.md` and `DESIGN.md` for layout, interaction flow, themes, + chat rendering, and composer chrome ## Onboarding and reinstall support @@ -73,39 +71,34 @@ Follow that checklist's safety rules: in both the `rsync` and `cp -a` paths — `/opt/hermes` may contain subdirectories with restricted permissions (e.g. `.playwright/`). -## Before you open a PR — the change guidelines +## Completion and verification -Read [`docs/GUIDELINES.md`](docs/GUIDELINES.md) in full before non-trivial work. It is the -distilled set of habits that get a change merged in one review round instead of several. The -compressed form: +A change is ready for review when: -1. **Fix the class, not the instance.** A bug usually has siblings — other call sites, backends, - companion endpoints, layouts, exit paths. Find them all and fix the shared chokepoint, or name - the ones you left out of scope. -2. **Trace one authoritative value end-to-end** (`input → normalize → decision → action → persist → - cleanup`); the code that *decides* and the code that *acts* must use the same resolved value. -3. **When you can't confirm something, fail closed and say so.** Never take the permissive branch on - uncertainty; never report a failure as success. "Unknown" is not "allowed." -4. **Enumerate the state-space before editing** — entry point, backend, item count (0/1/many), every - lifecycle exit (success/error/cancel/replace/teardown), auth on/off, concurrency, hostile input — - and cover each or mark it out of scope. Most redo rounds are one un-considered dimension. -5. **Assume inputs and check-then-use gaps are adversarial** — validate at the point of use (hold a - handle, don't re-resolve a path), scope caches by complete identity, handle crafted input. -6. **A test must fail before your fix and pass after it.** Assert observable behavior, not a source - string or a mock of the thing under test; use multiple items if selection is what's being tested. -7. **Name the owner of every piece of state and prove it's released on every exit** (success, error, - cancel, replace, shrink, teardown) — not just the happy path. -8. **Fallbacks/defaults are contracts — extend the mechanism, don't copy it.** Editing N parallel - blocks identically means you missed a chokepoint (e.g. new copy goes in the `en` locale only). -9. **The diff is the task and nothing else.** Extras go in the PR description, not the diff; run the - affected + neighboring tests before opening. -10. **A visible control costs attention on every visit** — place it by frequency of use and by where - mainstream chat apps put the equivalent, not by where your diff already is; verify with - before/after images at desktop and narrow widths. +- The requested scope is implemented, with related call sites and lifecycle + paths covered or explicitly identified as out of scope; unrelated cleanup is absent. +- Behavior changes have observable regression evidence where practical, including + proof that a bug-fix test fails before the fix and passes after it. Evidence covers + affected and neighboring tests, not only new tests. +- State changes identify the authoritative value and its owner, use that value + through decision, action, and persistence, and account for cleanup on every exit. + Fallbacks and defaults extend the existing mechanism rather than duplicate it. +- UI changes include before/after evidence at desktop and narrow widths and + cover relevant mobile interactions; controls fit their frequency of use. +- Relevant docs are current. The PR body follows `CONTRIBUTING.md`, includes + `Contract Routing` (and `Contract Change` for intentional contract changes) + where required by `docs/CONTRACTS.md`, and reports actual commands, outcomes, + assumptions, and anything not verified. Mocks are not proof of external behavior. -Show the work in the PR body: the siblings you found, proof the test failed before the fix, the -verification run, before/after images for visible changes, and an explicit list of what you could -not verify. +Within the requested scope, you may run local tests with disposable fixtures, +fix failures caused by the change, and rerun affected tests without asking for +approval at each step. Use `./scripts/test.sh` for pytest as specified above. +This permission applies only with confirmed isolated state and no live +credentials or services; the runner manages Python dependencies but is not a +network sandbox. It does not authorize modifying real state, handling credentials, +restarting existing services, or exposing the app beyond localhost. Those actions +require explicit human approval and the onboarding safety rules still apply. +If verification is blocked, report the blocker rather than claim completion. ## Local state and secrets @@ -113,6 +106,11 @@ Hermes WebUI can read and write real agent state, sessions, workspaces, credentials, and cron data. Treat local validation as potentially destructive unless you have confirmed the active state directories. +For authority, capability, identity, or containment checks, fail closed when +safety cannot be confirmed: unknown is not allowed. Validate adversarial inputs +at the point of use, account for check-then-use races, and scope caches by the +complete identity so profiles and sessions cannot leak into each other. + Prefer isolated trial state for experiments: ```bash From 66423ad2dca082e70abf0a5047ad5655c668ec65 Mon Sep 17 00:00:00 2001 From: Rodrigo Gomes da Silva Date: Wed, 16 Sep 2026 01:25:27 -0300 Subject: [PATCH 063/446] fix(sessions): stop one oversized metadata field from re-parsing the whole sidecar (#7573) Read the metadata prefix in staged geometric reads so one oversized metadata field no longer forces a full re-parse of the whole sidecar. Co-authored-by: rodrigogs --- api/models.py | 89 ++++++- tests/test_issue5854_anchor_scene_split.py | 11 +- .../test_oversized_metadata_field_reparse.py | 241 ++++++++++++++++++ 3 files changed, 327 insertions(+), 14 deletions(-) create mode 100644 tests/test_oversized_metadata_field_reparse.py diff --git a/api/models.py b/api/models.py index 22c22128..eca27ab5 100644 --- a/api/models.py +++ b/api/models.py @@ -1085,7 +1085,26 @@ def _read_file_head(path: Path, max_prefix_bytes: int = 4096) -> str: return fp.read(max_prefix_bytes).decode('utf-8', errors='ignore') -def _read_metadata_json_prefix(path, max_prefix_bytes=65536): +#: Budget for the cheap metadata read. Sized as a BACKSTOP, not a target: the +#: prefix normally stops at ``messages`` after a few KB, and the write-side field +#: order keeps it there. The cap only matters for a sidecar already on disk whose +#: heavy metadata blobs serialize BEFORE ``messages`` — where the old 64 KB cap +#: turned one oversized field into a full multi-MB parse on EVERY poll (#4633). +#: Measured case: a 73,192-byte ``compression_anchor_summary`` pushed the +#: ``messages`` key to offset 76,603, so the read gave up 11 KB short of it. +_METADATA_PREFIX_MAX_BYTES = 1024 * 1024 + +#: Size of the FIRST stage of the cheap metadata read. A healthy sidecar carries +#: its stop key (`messages`) within a few KB — the write-side field order in +#: ``save()`` guarantees it — so every ordinary poll pays this read and stops +#: there. The 1 MiB backstop above is only reached by a sidecar already on disk +#: whose heavy metadata blobs really do precede ``messages`` in bulk. Without a +#: small first stage the cheap read pulls the whole budget on the polling path +#: even when the stop key sits at 2 KB. +_METADATA_PREFIX_FIRST_STAGE_BYTES = 64 * 1024 + + +def _read_metadata_json_prefix(path, max_prefix_bytes=_METADATA_PREFIX_MAX_BYTES): """Read only the metadata portion before the large arrays. #5854: stop at the top-level ``messages`` key OR the top-level @@ -1098,24 +1117,58 @@ def _read_metadata_json_prefix(path, max_prefix_bytes=65536): the scenes-stop a legacy large-scene sidecar overflows ``max_prefix_bytes`` and forces a full multi-MB parse on every poll (the #4633 churn). """ + if max_prefix_bytes <= 0: + return None + # Geometric staged reads: 64 KiB, 128 KiB, 256 KiB, ... up to the backstop. + # + # The original loop read 4096 bytes at a time and re-scanned the WHOLE + # accumulated buffer after each chunk — two full `_find_top_level_json_key` + # passes (a pure-Python char loop) plus a decode per chunk. That is O(n^2) in + # the prefix size, and it is why the cap could not simply be raised. + # Measured on this function before the change: + # prefix 10 KB -> 19.6 ms + # prefix 60 KB -> 87.3 ms <- already paid on EVERY poll + # prefix 200 KB -> 768.9 ms + # prefix 500 KB -> 4,634.7 ms + # prefix 900 KB -> 14,957.1 ms <- slower than the full parse + # + # A single `f.read(max_prefix_bytes)` fixed that complexity but pulled the + # whole 1 MiB backstop on every poll, even when the stop key sat at 2 KB — + # read amplification on a path that runs once per sidebar poll. Doubling + # stages keep both properties: the COMMON case stops inside the first small + # read, and re-scanning each accumulated stage stays linear because the stage + # sizes form a bounded geometric series (total work < 2x the bytes actually + # read, worst case). `_find_top_level_json_key` returns AT the key, so the + # final scan costs O(offset of the stop key), not O(stage). + stage = min(_METADATA_PREFIX_FIRST_STAGE_BYTES, max_prefix_bytes) + raw = b'' buf = '' - with open(path, 'r', encoding='utf-8') as f: - while len(buf.encode('utf-8')) < max_prefix_bytes: - chunk = f.read(4096) + stop_pos = None + with open(path, 'rb') as f: + while len(raw) < max_prefix_bytes: + chunk = f.read(min(stage, max_prefix_bytes - len(raw))) if not chunk: + # EOF before the stop key: no metadata prefix to serve, so the + # caller falls back to a full parse (same answer as a budget miss). return None - buf += chunk + raw += chunk + # A byte budget can truncate a multi-byte character at the boundary; + # dropping it is safe because a stop key straddling the cap is a miss + # either way. + buf = raw.decode('utf-8', errors='ignore') stop_pos = _find_top_level_json_key(buf, 'messages') scenes_pos = _find_top_level_json_key(buf, 'anchor_activity_scenes') if scenes_pos is not None and (stop_pos is None or scenes_pos < stop_pos): stop_pos = scenes_pos - if stop_pos is None: - continue - prefix = buf[:stop_pos].rstrip() - if prefix.endswith(','): - prefix = prefix[:-1].rstrip() - return f'{prefix}\n}}' - return None + if stop_pos is not None: + break + stage *= 2 + if stop_pos is None: + return None + prefix = buf[:stop_pos].rstrip() + if prefix.endswith(','): + prefix = prefix[:-1].rstrip() + return f'{prefix}\n}}' def _load_session_from_path(path: Path) -> "Session | None": @@ -1605,6 +1658,18 @@ class Session: # expected_sig guards against an atomic replace during the read. # (When _collapsed_partials fired, save() above already rewrote the # modern layout, so no legacy caching is needed.) + # + # WHY STILL GATED, and not "obviously" broadened to modern files: the + # cache is only READ from the two sites that reach it when the cheap + # prefix carried NEITHER message_count NOR anchor_scene_index — i.e. + # legacy layouts (see the `if ... and 'anchor_scene_index' not in + # parsed` guards in load_metadata_only() and the eviction-count + # helper). A modern sidecar that overflows the budget therefore + # never consults an entry written for it, so populating one is dead + # work that also stores entries no consumer can use. Making the + # modern case benefit needs the READ side redesigned to build a + # metadata stub from cached facts — a separate change; until then + # this stays legacy-only. if 'anchor_scene_index' not in data: try: _legacy_sidecar_facts_put( diff --git a/tests/test_issue5854_anchor_scene_split.py b/tests/test_issue5854_anchor_scene_split.py index 11da496e..d5ef5147 100644 --- a/tests/test_issue5854_anchor_scene_split.py +++ b/tests/test_issue5854_anchor_scene_split.py @@ -163,13 +163,20 @@ def test_parity_cache_not_forced_reload_when_prefix_read_fails(session_store): Simulate an oversized metadata prefix by giving the session a large `compression_anchor_summary` so `_read_metadata_json_prefix` overflows and the slow path (Session.load_metadata_only) is taken. + + Sized off `_METADATA_PREFIX_MAX_BYTES` rather than a literal: this test needs + the prefix read to FAIL, so hardcoding a number that happens to exceed + today's budget makes it silently stop exercising the slow path the next time + the budget moves. (It did: the budget went 64 KB -> 1 MiB and the old + `"Z" * 80000` no longer overflowed.) """ + oversized = "Z" * (M._METADATA_PREFIX_MAX_BYTES + 100_000) scenes = {"scene0": {"updated_at": 1000.0, "scene": {}}} s = M.Session(session_id="p1", title="T", workspace=str(session_store.parent), model="glm", messages=[{"role": "user", "content": "hi"}, {"role": "assistant", "content": "yo"}]) s.anchor_activity_scenes = scenes - s.compression_anchor_summary = "Z" * 80000 # push metadata prefix > 64KB + s.compression_anchor_summary = oversized s.save() # Sanity: the cheap prefix genuinely fails for this file (slow path taken). assert M._read_metadata_json_prefix(session_store / "p1.json") is None @@ -180,7 +187,7 @@ def test_parity_cache_not_forced_reload_when_prefix_read_fails(session_store): {"role": "assistant", "content": "yo"}]) cached_parity.anchor_activity_scenes = {"scene0": {"updated_at": 1000.0, "scene": {}}} cached_parity._anchor_scene_index = {} # stale/empty load-time fingerprint - cached_parity.compression_anchor_summary = "Z" * 80000 + cached_parity.compression_anchor_summary = oversized assert M._cached_session_lags_disk(cached_parity) is False # But a genuinely-behind cache (disk has a newer scene) still reloads. cached_behind = M.Session(session_id="p1", title="T", workspace=str(session_store.parent), diff --git a/tests/test_oversized_metadata_field_reparse.py b/tests/test_oversized_metadata_field_reparse.py new file mode 100644 index 00000000..98040cb8 --- /dev/null +++ b/tests/test_oversized_metadata_field_reparse.py @@ -0,0 +1,241 @@ +"""The #4633 churn returns through a field #5854 did not move. + +#5854 stopped `anchor_activity_scenes` from overflowing the 64 KB metadata +prefix, and cached the authoritative facts of a LEGACY sidecar so an unchanged +one is full-parsed at most once. Both halves are gated on the file being legacy +(`'anchor_scene_index' not in data`). A MODERN sidecar that overflows the prefix +for any OTHER reason therefore gets neither protection: the cheap read fails, +`load_metadata_only()` full-parses, and nothing is cached — so it re-parses on +every poll. That is exactly the #4633 allocation churn the earlier fix removed. + +Measured in production on 2026-09-14 (Mac docker stack, 5.77 GiB colima VM): + + /data/hermes/webui/sessions/fa3bca34a0c6.json 111,857,574 bytes, 135,634 msgs + "compression_anchor_summary" offset 1,007 (73,192 bytes long) + "message_count" offset 75,446 + "anchor_scene_index" offset 75,473 <- file IS modern + "messages" offset 76,603 <- past the 65,536 budget + +`_read_metadata_json_prefix` scanned to `messages`, exhausted its budget first +and returned None; `load_metadata_only` fell back to `json.loads` of all 112 MB; +the legacy-facts cache was skipped because `anchor_scene_index` was present. +`/api/session/status` averaged 1,172 ms over 6,441 calls, one `messages=0` +request took 48.1 s, the server reached 4.0 GB RSS and the guest OOM killer +killed it 7 times. + +The fix covers the measured case: the prefix read is linear and STAGED (a 64 KiB +first stage, doubling to a 1 MiB backstop), so the stop key at offset 76,603 is +now reached within the second stage instead of failing just short of it. The +cache-write half deliberately stays legacy-gated — broadening it to modern files +writes entries neither read site can reach (both are guarded on +`anchor_scene_index` being absent), so the >1 MiB case below remains an explicit +gap rather than fixed by accident. + +These tests lock: + * the cheap prefix survives one oversized metadata field, + * the cheap read stops in the first stage for an ordinary small prefix, + * an unchanged modern sidecar is full-parsed at most once regardless (xfail). +""" +import builtins +import json + +import pytest + +import api.models as M + + +@pytest.fixture +def session_store(tmp_path, monkeypatch): + sdir = tmp_path / "sessions" + monkeypatch.setattr(M, "SESSION_DIR", sdir) + sdir.mkdir(parents=True, exist_ok=True) + return sdir + + +def _make_modern_oversized(session_store, sid, n_msgs=5, summary_bytes=80000): + """A MODERN sidecar (carries anchor_scene_index) whose metadata prefix + overflows because of one oversized field, mirroring production. + + Scenes are deliberately tiny: the point is that the overflow comes from a + field #5854 never moved, not from the scene bodies it did move. + """ + s = M.Session( + session_id=sid, + title="Oversized", + workspace=str(session_store.parent), + model="glm", + messages=[{"role": "user", "content": f"m{i}"} for i in range(n_msgs)], + ) + s.anchor_activity_scenes = {"scene0": {"version": 1, "updated_at": 1000.0, + "scene": {"activity_rows": []}}} + s.compression_anchor_summary = "Z" * summary_bytes + s.save() + return s + + +def test_sidecar_is_modern_and_overflows_the_budget(session_store): + """Guard the premise: the fixture really is the production shape — a modern + file (anchor_scene_index present, scenes after messages) whose "messages" + key sits beyond the 64 KB budget. + + If this ever fails, the two tests below stop covering what they claim to. + """ + _make_modern_oversized(session_store, "mod1") + raw = (session_store / "mod1.json").read_text(encoding="utf-8") + assert '"anchor_scene_index"' in raw, "fixture must be MODERN, not legacy" + ci = raw.find('"compression_anchor_summary"') + xi = raw.find('"anchor_scene_index"') + mi = raw.find('"messages"') + si = raw.find('"anchor_activity_scenes"') + assert -1 < ci < xi < mi < si, "modern layout: summary < scene_index < messages < scenes" + assert mi > M._METADATA_PREFIX_FIRST_STAGE_BYTES, ( + f'"messages" at {mi} must exceed the first read stage ' + f'({M._METADATA_PREFIX_FIRST_STAGE_BYTES}) to reproduce #4633' + ) + + +def _make_small_prefix_large_body(session_store, sid, n_msgs=40): + """A healthy sidecar: tiny metadata prefix, bulky body AFTER ``messages``. + + The shape every ordinary sidebar poll sees — the stop key lands a few KB in, + while the file itself is comfortably larger than the first read stage, so a + full-budget read is distinguishable from a staged one. + """ + s = M.Session( + session_id=sid, + title="Ordinary", + workspace=str(session_store.parent), + model="glm", + messages=[{"role": "user", "content": "B" * 4000} for _ in range(n_msgs)], + ) + s.save() + return s + + +def test_cheap_read_stops_in_the_first_stage_for_a_normal_sidecar(session_store, monkeypatch): + """The polling path must not pull the whole budget for a sidecar whose stop + key sits after a few KB. + + This is the read amplification the geometric stages exist to prevent: with a + single fixed-budget read, every uncached ordinary metadata lookup consumed + ``_METADATA_PREFIX_MAX_BYTES`` even when ``messages`` appeared 2 KB in. + Instrumented against the real reader (through the module's ``open``) rather + than asserted on constants, so it fails if the staged loop regresses to one + full-budget read. + """ + _make_small_prefix_large_body(session_store, "normal") + path = session_store / "normal.json" + assert path.stat().st_size > M._METADATA_PREFIX_FIRST_STAGE_BYTES, ( + "the file must be larger than the first stage, otherwise a single " + "full-budget read would be indistinguishable from a staged one" + ) + bytes_read = {"n": 0} + real_open = builtins.open + + class _CountingFile: + def __init__(self, fh): + self._fh = fh + + def read(self, n=-1): + data = self._fh.read(n) + bytes_read["n"] += len(data) + return data + + def __enter__(self): + self._fh.__enter__() + return self + + def __exit__(self, *exc): + return self._fh.__exit__(*exc) + + def _counting_open(*args, **kwargs): + return _CountingFile(real_open(*args, **kwargs)) + + monkeypatch.setattr(M, "open", _counting_open, raising=False) + prefix = M._read_metadata_json_prefix(path) + assert prefix is not None + assert json.loads(prefix)["message_count"] == 40 + assert bytes_read["n"] <= M._METADATA_PREFIX_FIRST_STAGE_BYTES, ( + f"cheap read consumed {bytes_read['n']} bytes for a prefix whose stop key " + f"is inside the first stage ({M._METADATA_PREFIX_FIRST_STAGE_BYTES})" + ) + assert bytes_read["n"] < M._METADATA_PREFIX_MAX_BYTES + + +def test_cheap_prefix_survives_an_oversized_metadata_field(session_store): + """The cheap read must stop once it holds the metadata its callers need, + instead of scanning all the way to "messages" and overflowing on whatever + large field happens to precede it. + + Scanning-to-"messages" makes every future large metadata field a repeat of + #4633; stopping at "enough" does not. + """ + _make_modern_oversized(session_store, "mod2", n_msgs=7) + prefix = M._read_metadata_json_prefix(session_store / "mod2.json") + assert prefix is not None, "cheap prefix must succeed, not fall back to a full parse" + parsed = json.loads(prefix) + assert {"session_id", "title", "created_at", "updated_at"}.issubset(parsed.keys()) + assert parsed["message_count"] == 7 + assert "messages" not in parsed, "the messages array must not be in the cheap prefix" + + +@pytest.mark.xfail( + strict=True, + reason=( + "KNOWN GAP, not a flake: a sidecar whose metadata blob exceeds the prefix " + "budget still re-parses per read. A failed prefix read yields no metadata at " + "all, and the facts cache holds only counts (no title/created_at), so no stub " + "can be built from it. The durable cure is write-side -- serialize the " + "unbounded blobs AFTER `messages`, as #5854 did for scene bodies. Broadening " + "the facts-cache WRITE to modern files does NOT close this: both read sites " + "reach the cache only when the prefix carried neither message_count nor " + "anchor_scene_index (see the guards in load_metadata_only() and the " + "eviction-count helper), so a modern entry is never consulted -- the write " + "side must stay legacy-gated until the READ side is redesigned to build a " + "metadata stub from cached facts. strict=True so this fails loudly once that " + "lands, instead of quietly passing and leaving a stale marker behind." + ), +) +def test_modern_oversized_sidecar_not_reparsed_on_every_read(session_store, monkeypatch): + """An unchanged sidecar must be full-parsed at most once, whether it is + legacy or modern. + + This is the production failure: 6,441 polls of one session, each paying a + full 112 MB `json.loads`, because the facts cache is reached only when + `anchor_scene_index` is ABSENT. The counterpart legacy assertion already + exists (test_issue5854_anchor_scene_split.py:: + test_legacy_large_scene_not_reparsed_on_every_read); the modern one does not. + + The field here deliberately exceeds the prefix budget so the fallback is + genuinely taken. A budget bump alone must NOT be able to satisfy this test — + otherwise it stops covering the caching hole and silently passes for the + wrong reason. + """ + M._LEGACY_SIDECAR_FACTS.clear() + _make_modern_oversized(session_store, "mod3", n_msgs=5, + summary_bytes=M._METADATA_PREFIX_MAX_BYTES + 200_000) + # Sanity: the cheap read genuinely fails here, so the slow path IS exercised. + assert M._read_metadata_json_prefix(session_store / "mod3.json") is None, ( + "fixture must overflow the prefix budget; otherwise this test does not " + "cover the fallback caching at all" + ) + + calls = {"n": 0} + real_load = M.Session.load.__func__ + + def _counting_load(cls, sid, *a, **k): + if sid == "mod3": + calls["n"] += 1 + return real_load(cls, sid, *a, **k) + + monkeypatch.setattr(M.Session, "load", classmethod(_counting_load)) + + stubs = [M.Session.load_metadata_only("mod3") for _ in range(3)] + for stub in stubs: + assert stub is not None + assert (stub._metadata_message_count or len(stub.messages)) == 5 + + assert calls["n"] <= 1, ( + f"modern oversized sidecar full-loaded {calls['n']}x across 3 metadata reads; " + "an unchanged file must be parsed at most once" + ) From d98c9be39e5affbf1e1eb75640360ce3c826e58d Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 15 Sep 2026 21:29:34 -0700 Subject: [PATCH 064/446] Release: staged prefix reads for oversized metadata fields (#7573) (#7597) Co-authored-by: n --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0534116c..ab25c5ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### Fixed +- **Opening a session with one oversized metadata field no longer re-parses the entire sidecar.** #5854 kept `anchor_activity_scenes` from overflowing the 64 KB metadata prefix and cached a legacy sidecar's authoritative facts so an unchanged one is fully parsed at most once — but a *different* oversized field still pushed the prefix past the fast-read window, forcing a full parse of the whole file on every open. The prefix is now read in staged geometric reads, so an oversized field is recovered from the prefix instead of triggering a whole-file parse; a field beyond 1 MiB still falls back to the full parse rather than risking a truncated read. Legacy-sidecar caching remains gated on the file being legacy (its key includes `ctime_ns`, so a same-size rewrite still invalidates), modern sidecars are unaffected, and message/scene projections are byte-identical to before for normal, oversized-summary, oversized-scenes, and both-oversized sidecars. Thanks @rodrigogs. (#7573) + - **Models picked from a second custom provider group route to that provider's own endpoint instead of failing with a 400.** A model selected from a custom provider registered only under `custom_providers:` (picker value like `@custom:omni:antigravity/gemini-3.7-flash-tiered`) was resolved against the active provider's credentials, so it returned `HTTP 400 Invalid model format or no credentials for provider: `. Provider-qualified `@custom:` ids now resolve to the owning provider's own `base_url` and credentials. A `providers.` record with no endpoint fails closed with a terminal `CUSTOM_ROUTE_NO_ENDPOINT` (clearing any foreign connection bundle before construction or cache publication) rather than leaking another provider's endpoint, and cross-provider credential/base-URL isolation plus per-profile agent-cache signatures are preserved. Thanks @b3nw. (#7319) - **Structured (multimodal) message content stored in `state.db` renders as its real content instead of a placeholder, without dropping or misattributing turns.** When the session projection decodes the `state.db` content sentinel, list-valued content now gets an out-of-band identity — a `("structured_content", canonical)` tuple that no scalar string can compare equal to — shared across the merge, dedup, content, and visible reconciliation keys, so an in-band string can't collide with a rich row and an image-only row can no longer vanish from the projection. The expensive canonical serialisation is memoised per content object for the duration of a single `merge_session_messages_append_only()` call (scoped through a `ContextVar`, keyed by object identity with the object held alive so an id can't be reused for a different list mid-call), restoring one serialisation per list per call. Empty/falsy content keys exactly as before. Thanks @totalitarian. (#7492) From 81f18a1f726befc7d453c12ee5de3cd611832bb0 Mon Sep 17 00:00:00 2001 From: leosiedler Date: Wed, 16 Sep 2026 01:14:09 -0400 Subject: [PATCH 065/446] fix(agent_runtime): fail closed when Git identity disappears (#7407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fail closed when the Agent Git identity disappears or is unreadable, including when the revision read itself raises — a missing revision is indistinguishable from changed source and must never be treated as proof the loaded code is current. Co-authored-by: LeonardoLGDS --- api/agent_runtime.py | 33 +++++--- tests/test_agent_runtime_identity_loss.py | 91 +++++++++++++++++++++++ 2 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 tests/test_agent_runtime_identity_loss.py diff --git a/api/agent_runtime.py b/api/agent_runtime.py index c8f7a242..25e0129a 100644 --- a/api/agent_runtime.py +++ b/api/agent_runtime.py @@ -397,18 +397,29 @@ def ensure_agent_runtime_current() -> None: """Reject a known Git checkout change instead of mixing Python modules.""" if _AGENT_REVISION is None: return - if ( - _read_agent_revision(_AGENT_SOURCE_DIR, module_path=_AGENT_MODULE_PATH) - != _AGENT_REVISION - ): - # Automatic restart needs an Agent-owned success receipt bound to this - # transaction, final revision and healthy environment, plus an atomic - # handoff excluding mutations across replacement. Marker polling and a - # final revision read supply neither contract. Keep this path manual. - raise AgentRuntimeChangedError( - _RESTART_REQUIRED_MESSAGE, - agent_update_state=_agent_update_transaction_state(), + fresh_revision = None + try: + fresh_revision = _read_agent_revision( + _AGENT_SOURCE_DIR, module_path=_AGENT_MODULE_PATH ) + except Exception: + # An unreadable revision is indistinguishable from a changed one, so a + # failed read must fail CLOSED like every other identity-loss shape + # (deleted, permission-denied, empty, corrupt, removed directory). + # Letting the raw exception escape would surface as an HTTP 500 instead + # of the typed stale-runtime response the barrier is meant to produce. + fresh_revision = None + if fresh_revision == _AGENT_REVISION: + return + + # Automatic restart needs an Agent-owned success receipt bound to this + # transaction, final revision and healthy environment, plus an atomic + # handoff excluding mutations across replacement. Marker polling and a + # final revision read supply neither contract. Keep this path manual. + raise AgentRuntimeChangedError( + _RESTART_REQUIRED_MESSAGE, + agent_update_state=_agent_update_transaction_state(), + ) def require_ai_agent_class(): diff --git a/tests/test_agent_runtime_identity_loss.py b/tests/test_agent_runtime_identity_loss.py new file mode 100644 index 00000000..155cd92e --- /dev/null +++ b/tests/test_agent_runtime_identity_loss.py @@ -0,0 +1,91 @@ +"""Git identity loss must not fall back to forgeable source-file metadata.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +import types + +import pytest + + +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def test_git_identity_loss_rejects_changed_source_with_preserved_metadata( + monkeypatch, tmp_path: Path +): + """A known Git identity cannot fall back to forgeable file metadata.""" + from api import agent_runtime + + source_dir = tmp_path / "loaded-agent" + source_dir.mkdir() + module_file = source_dir / "run_agent.py" + module_file.write_bytes(b"class AIAgent: pass\n") + _git(source_dir, "init", "-q") + _git(source_dir, "add", "run_agent.py") + _git(source_dir, "commit", "-qm", "loaded agent") + + loaded_module = types.ModuleType("run_agent") + loaded_module.__file__ = str(module_file) + monkeypatch.setitem(sys.modules, "run_agent", loaded_module) + revision = _git(source_dir, "rev-parse", "HEAD") + original_stat = module_file.stat() + monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", source_dir.resolve()) + monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", module_file.resolve()) + monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", revision) + monkeypatch.setattr( + agent_runtime, "_AGENT_MODULE_MTIME_NS", original_stat.st_mtime_ns, + raising=False, + ) + + (source_dir / ".git").rename(tmp_path / "hidden-agent-git") + module_file.write_bytes(b"class AIAgent: gasp\n") + os.utime(module_file, ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns)) + fresh_stat = module_file.stat() + assert fresh_stat.st_size == original_stat.st_size + assert fresh_stat.st_mtime_ns == original_stat.st_mtime_ns + + with pytest.raises(agent_runtime.AgentRuntimeChangedError): + agent_runtime.ensure_agent_runtime_current() + + +def test_raising_revision_reader_fails_closed(monkeypatch, tmp_path: Path): + """A revision read that RAISES must fail closed, not escape as a 500. + + Every other identity-loss shape (deleted, permission-denied, empty, + whitespace-only, corrupt, removed directory) already raises + AgentRuntimeChangedError. A reader that throws is the same situation -- + the revision is unreadable, which is indistinguishable from changed -- + so it must produce the same typed error rather than propagating a raw + OSError that the request layer would surface as an HTTP 500 instead of + the intended stale-runtime response. + """ + from api import agent_runtime + + source_dir = tmp_path / "raising-agent" + source_dir.mkdir() + module_file = source_dir / "run_agent.py" + module_file.write_bytes(b"class AIAgent: pass\n") + + monkeypatch.setattr(agent_runtime, "_AGENT_SOURCE_DIR", source_dir.resolve()) + monkeypatch.setattr(agent_runtime, "_AGENT_MODULE_PATH", module_file.resolve()) + monkeypatch.setattr(agent_runtime, "_AGENT_REVISION", "0" * 40) + + def _boom(*_args, **_kwargs): + raise PermissionError("revision unreadable") + + monkeypatch.setattr(agent_runtime, "_read_agent_revision", _boom) + + with pytest.raises(agent_runtime.AgentRuntimeChangedError): + agent_runtime.ensure_agent_runtime_current() From 310265c5699f2df4f8d50e0726d5774b946c4ce4 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 15 Sep 2026 22:18:23 -0700 Subject: [PATCH 066/446] Release: fail closed on missing/unreadable Agent Git identity (#7407) (#7599) Co-authored-by: n --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab25c5ed..46f7e1ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### Fixed +- **The WebUI fails closed when the Agent's Git identity disappears, instead of risking a run on mixed module state.** `ensure_agent_runtime_current()` guards against executing a mix of old and new Python modules across an Agent update by comparing the on-disk Git revision to the one loaded in memory. When that revision became unreadable — the file deleted, permission-denied, empty, corrupt, the source directory removed, or the read itself raising — a missing revision is indistinguishable from a changed one and must not be treated as proof the loaded code is current. The guard now raises the typed stale-runtime error for every unreadable-identity case (surfacing as an HTTP 409, not a 500), with no forgeable file-metadata (`mtime`/size) fallback authorizing execution. `KeyboardInterrupt`/`SystemExit` still propagate, the matching-revision happy path is unchanged, and initially non-Git runtimes are unaffected. Thanks @LeonardoLGDS. (#7407) + - **Opening a session with one oversized metadata field no longer re-parses the entire sidecar.** #5854 kept `anchor_activity_scenes` from overflowing the 64 KB metadata prefix and cached a legacy sidecar's authoritative facts so an unchanged one is fully parsed at most once — but a *different* oversized field still pushed the prefix past the fast-read window, forcing a full parse of the whole file on every open. The prefix is now read in staged geometric reads, so an oversized field is recovered from the prefix instead of triggering a whole-file parse; a field beyond 1 MiB still falls back to the full parse rather than risking a truncated read. Legacy-sidecar caching remains gated on the file being legacy (its key includes `ctime_ns`, so a same-size rewrite still invalidates), modern sidecars are unaffected, and message/scene projections are byte-identical to before for normal, oversized-summary, oversized-scenes, and both-oversized sidecars. Thanks @rodrigogs. (#7573) - **Models picked from a second custom provider group route to that provider's own endpoint instead of failing with a 400.** A model selected from a custom provider registered only under `custom_providers:` (picker value like `@custom:omni:antigravity/gemini-3.7-flash-tiered`) was resolved against the active provider's credentials, so it returned `HTTP 400 Invalid model format or no credentials for provider: `. Provider-qualified `@custom:` ids now resolve to the owning provider's own `base_url` and credentials. A `providers.` record with no endpoint fails closed with a terminal `CUSTOM_ROUTE_NO_ENDPOINT` (clearing any foreign connection bundle before construction or cache publication) rather than leaking another provider's endpoint, and cross-provider credential/base-URL isolation plus per-profile agent-cache signatures are preserved. Thanks @b3nw. (#7319) From 723146cbfeda7e983d3dfd20770f06cbd09ac184 Mon Sep 17 00:00:00 2001 From: Skawsk59 <96987968+Skawsk59@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:24:09 +0400 Subject: [PATCH 067/446] fix: serve SPA for /sessions route (404 after login with auth enabled) (#7598) Serve the SPA shell for /sessions so the post-login redirect (when password auth is enabled) no longer 404s. Co-authored-by: Skawsk59 --- api/routes.py | 2 +- docs/troubleshooting.md | 10 ++++ tests/test_sessions_route.py | 91 ++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 tests/test_sessions_route.py diff --git a/api/routes.py b/api/routes.py index 85b404ae..4cdb91c5 100644 --- a/api/routes.py +++ b/api/routes.py @@ -13574,7 +13574,7 @@ def handle_get(handler, parsed) -> bool: if parsed.path in ("/session/manifest.json", "/session/manifest.webmanifest"): return _serve_manifest(handler) - if parsed.path in ("/", "/index.html") or parsed.path.startswith("/session/"): + if parsed.path in ("/", "/index.html", "/sessions") or parsed.path.startswith("/session/"): try: from api.extensions import inject_extension_tags diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5840a449..f3335f98 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -211,6 +211,16 @@ For a foreground `python3 bootstrap.py`, stop it with Ctrl-C and start it again. --- +## 404 after login when password auth is enabled + +**Symptom.** After enabling password authentication (`HERMES_WEBUI_PASSWORD`), logging in redirects to `/sessions` and the browser shows a `404 not found` error instead of the chat interface. + +**Why.** The server-side redirect after login targets `/sessions` (plural), but that path was missing from the explicit SPA-shell allowlist in `handle_get()`. Without auth the bug is invisible because the SPA handles `/sessions` client-side and the server route is never hit — only the server-side post-login redirect exposes it. + +**Fix.** `/sessions` is now included alongside `/` and `/index.html` in the set of paths that serve the SPA shell. No configuration change is needed. + +--- + ## Other troubleshooting This document grows over time. If a recurring failure mode isn't covered here yet, add it via PR. The format for each entry: **Symptom → Why → Diagnostic commands → Fix → When to file a bug**. diff --git a/tests/test_sessions_route.py b/tests/test_sessions_route.py new file mode 100644 index 00000000..efec3f65 --- /dev/null +++ b/tests/test_sessions_route.py @@ -0,0 +1,91 @@ +"""Regression test for PR #7598 — /sessions must serve the SPA shell, not 404. + +Bug shape (pre-fix): + When password auth is enabled, the server redirects to /sessions after a successful + login. However, /sessions (plural) was missing from the explicit path allowlist in + handle_get(), so the server returned a 404 "not found" instead of serving index.html. + + The bug was invisible without auth because the SPA handles /sessions client-side and + the server route was never hit — only the server-side redirect after login exposed it. + +Fix: handle_get() now includes "/sessions" alongside "/" and "/index.html" in the set +of paths that serve the SPA shell. +""" + +from urllib.parse import urlparse + + +class _FakeHandler: + def __init__(self): + self.status = None + self.sent_headers = [] + self.body = bytearray() + self.wfile = self + self.headers = {} + + def send_response(self, status): + self.status = status + + def send_header(self, name, value): + self.sent_headers.append((name, value)) + + def end_headers(self): + pass + + def write(self, data): + self.body.extend(data) + + def header(self, name): + for key, value in self.sent_headers: + if key.lower() == name.lower(): + return value + return None + + +def test_sessions_route_serves_spa_shell(): + """/sessions (plural) must serve the HTML index, not a 404. + + This is the exact failure mode PR #7598 fixes: after login with auth enabled, + the server redirects to /sessions, which was missing from the SPA-shell + allowlist in handle_get(). + """ + from api.routes import handle_get + + handler = _FakeHandler() + parsed = urlparse("http://example.com/sessions") + assert handle_get(handler, parsed) is not False + assert handler.status == 200 + ct = handler.header("Content-Type") or "" + assert ct.startswith("text/html"), f"expected text/html, got {ct!r}" + assert b"" in bytes(handler.body[:200]).lower() + + +def test_sessions_route_with_query_string_serves_spa_shell(): + """/sessions?foo=bar must still serve the HTML index. + + Ensures query-string variants of /sessions are handled correctly because + matching uses the parsed path (without query string). + """ + from api.routes import handle_get + + handler = _FakeHandler() + parsed = urlparse("http://example.com/sessions?limit=50&offset=0") + assert handle_get(handler, parsed) is not False + assert handler.status == 200 + ct = handler.header("Content-Type") or "" + assert ct.startswith("text/html"), f"expected text/html, got {ct!r}" + + +def test_sessions_route_does_not_return_404(): + """Regression guard: /sessions must never return 404. + + Before the fix, handle_get() returned False for /sessions, which the + caller converted into a 404 'not found' JSON response. + """ + from api.routes import handle_get + + handler = _FakeHandler() + parsed = urlparse("http://example.com/sessions") + result = handle_get(handler, parsed) + assert result is not False, "/sessions must be handled (not 404)" + assert handler.status == 200 From 5e5b1178af630a9bf5cf64b3b967741725400fca Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 16 Sep 2026 00:31:24 -0700 Subject: [PATCH 068/446] Release: serve SPA for /sessions route (#7598) (#7601) Co-authored-by: n --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46f7e1ee..8185c56e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### Fixed +- **Logging in with password auth enabled no longer lands on a 404.** After a successful login the server redirects to `/sessions`, but `/sessions` (plural) was missing from the SPA-shell route allowlist in `handle_get()` — only `/`, `/index.html`, and `/session/*` (singular) served the app shell — so an authenticated user was met with a `404 not found` immediately after logging in. `/sessions` (and `/sessions?query`) now serves the app shell like the other SPA entry points. Authentication is unchanged: `check_auth()` still runs before the route handler, so an unauthenticated `GET /sessions` still redirects to the login page with `next=/sessions`. The bug was invisible without auth because the SPA handles `/sessions` client-side and the server route was never hit. Thanks @Skawsk59. (#7598) + - **The WebUI fails closed when the Agent's Git identity disappears, instead of risking a run on mixed module state.** `ensure_agent_runtime_current()` guards against executing a mix of old and new Python modules across an Agent update by comparing the on-disk Git revision to the one loaded in memory. When that revision became unreadable — the file deleted, permission-denied, empty, corrupt, the source directory removed, or the read itself raising — a missing revision is indistinguishable from a changed one and must not be treated as proof the loaded code is current. The guard now raises the typed stale-runtime error for every unreadable-identity case (surfacing as an HTTP 409, not a 500), with no forgeable file-metadata (`mtime`/size) fallback authorizing execution. `KeyboardInterrupt`/`SystemExit` still propagate, the matching-revision happy path is unchanged, and initially non-Git runtimes are unaffected. Thanks @LeonardoLGDS. (#7407) - **Opening a session with one oversized metadata field no longer re-parses the entire sidecar.** #5854 kept `anchor_activity_scenes` from overflowing the 64 KB metadata prefix and cached a legacy sidecar's authoritative facts so an unchanged one is fully parsed at most once — but a *different* oversized field still pushed the prefix past the fast-read window, forcing a full parse of the whole file on every open. The prefix is now read in staged geometric reads, so an oversized field is recovered from the prefix instead of triggering a whole-file parse; a field beyond 1 MiB still falls back to the full parse rather than risking a truncated read. Legacy-sidecar caching remains gated on the file being legacy (its key includes `ctime_ns`, so a same-size rewrite still invalidates), modern sidecars are unaffected, and message/scene projections are byte-identical to before for normal, oversized-summary, oversized-scenes, and both-oversized sidecars. Thanks @rodrigogs. (#7573) From 2ed0c4db274bf3d75d09627db6eb5869a83f029d Mon Sep 17 00:00:00 2001 From: Yularzhi <121986377+Yularzhi@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:39:58 +0300 Subject: [PATCH 069/446] fix(i18n): route sidebar source-tab labels through t() (#7580) (#7602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(i18n): route sidebar source labels through t() (#7580) _sessionSourceLabel() hardcoded the two visible tab labels as English template literals and assigned the result straight to textContent, so non-English locales rendered untranslated sidebar tabs. Route both filters through t() with the established {0}-interpolation pattern (same shape as kanban_comments_count) and add explicit en + ru keys so English remains the fallback for locales without a translation. - static/sessions.js: return t("sessions_source_cli", n) / t("sessions_source_webui", n) instead of template literals - static/i18n.js: add the two keys to the en and ru bundles - tests/test_issue2351_cli_session_source_filter.py: replace the hardcoded-English regressions with locale-key assertions and a new interpolation test; verify the template-literal fallback is gone so a regression can no longer ro-introduce it silently * i18n(sessions-source): add source-tab labels to the remaining 13 locales Codex gate: the runtime fallback is non-regressive, but the repo enforces a locale key-parity contract — 9 parity tests failed because the two new keys existed only in EN+RU. Add sessions_source_webui / sessions_source_cli to it, ja, es, de, zh, zh-Hant, pt, ko, fr, cs, tr, pl, vi, matching each locale's established translation of 'session' and preserving the {0} count placeholder. Placed after each locale's _label line so the {0} literal does not fall inside test_japanese_locale.py's brace-bounded _label regex. Co-authored-by: Yularzhi --------- Co-authored-by: nesquena-hermes Co-authored-by: Yularzhi --- static/i18n.js | 30 +++++++++++++++++++ static/sessions.js | 2 +- ...est_issue2351_cli_session_source_filter.py | 20 +++++++++++-- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/static/i18n.js b/static/i18n.js index a5b7f9ea..373e5454 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -39,6 +39,8 @@ const LOCALES = { voice_mode_active: 'Voice mode on', voice_mode_off: 'Voice mode off', session_imported: 'Session imported', + sessions_source_webui: 'WebUI sessions ({0})', + sessions_source_cli: 'CLI sessions ({0})', import_failed: 'Import failed: ', import_invalid_json: 'Invalid JSON', image_pasted: 'Image pasted: ', @@ -1791,6 +1793,8 @@ const LOCALES = { offline_stream_waiting: 'Connessione persa. In attesa di aggiornare…', _lang: 'it', _label: 'Italiano', + sessions_source_webui: 'Sessioni WebUI ({0})', + sessions_source_cli: 'Sessioni CLI ({0})', _speech: 'it-IT', // boot.js cancelling: 'Annullamento\u2026', @@ -3550,6 +3554,8 @@ const LOCALES = { offline_stream_waiting: '接続が切断されました。更新を待っています…', _lang: 'ja', _label: '日本語', + sessions_source_webui: 'WebUIセッション ({0})', + sessions_source_cli: 'CLIセッション ({0})', _speech: 'ja-JP', // boot.js cancelling: 'キャンセル中…', @@ -5337,6 +5343,8 @@ const LOCALES = { voice_mode_active: 'Голосовой режим включён', voice_mode_off: 'Голосовой режим выключен', session_imported: 'Сеанс импортирован', + sessions_source_webui: 'Сеансы WebUI ({0})', + sessions_source_cli: 'Сеансы CLI ({0})', import_failed: 'Не удалось импортировать: ', import_invalid_json: 'Неверный JSON', image_pasted: 'Изображение вставлено: ', @@ -7052,6 +7060,8 @@ const LOCALES = { offline_stream_waiting: 'Conexión perdida. Esperando para actualizar…', _lang: 'es', _label: 'Español', + sessions_source_webui: 'Sesiones WebUI ({0})', + sessions_source_cli: 'Sesiones CLI ({0})', _speech: 'es-ES', // boot.js cancelling: 'Cancelando…', @@ -8757,6 +8767,8 @@ const LOCALES = { offline_stream_waiting: 'Verbindung verloren. Warte auf Aktualisierung…', _lang: 'de', _label: 'Deutsch', + sessions_source_webui: 'WebUI-Sitzungen ({0})', + sessions_source_cli: 'CLI-Sitzungen ({0})', _speech: 'de-DE', // boot.js cancelling: 'Wird abgebrochen\u2026', @@ -10456,6 +10468,8 @@ const LOCALES = { offline_stream_waiting: '连接已断开。正在等待刷新…', _lang: 'zh', _label: '\u7b80\u4f53\u4e2d\u6587', + sessions_source_webui: 'WebUI 会话 ({0})', + sessions_source_cli: 'CLI 会话 ({0})', _speech: 'zh-CN', // boot.js cancelling: '正在取消...', @@ -12150,6 +12164,8 @@ const LOCALES = { offline_stream_waiting: '連線中斷。等待重新整理…', _lang: 'zh-Hant', _label: '繁體中文', + sessions_source_webui: 'WebUI 對話 ({0})', + sessions_source_cli: 'CLI 對話 ({0})', _speech: 'zh-TW', // boot.js cancelling: '正在取消……', @@ -13911,6 +13927,8 @@ const LOCALES = { offline_stream_waiting: 'Conexão perdida. Aguardando para atualizar…', _lang: 'pt', _label: 'Português', + sessions_source_webui: 'Sessões WebUI ({0})', + sessions_source_cli: 'Sessões CLI ({0})', _speech: 'pt-BR', // boot.js cancelling: 'Cancelando…', @@ -15489,6 +15507,8 @@ const LOCALES = { offline_stream_waiting: '연결이 끊겼습니다. 새로고침을 기다리는 중…', _lang: 'ko', _label: '한국어', + sessions_source_webui: 'WebUI 세션 ({0})', + sessions_source_cli: 'CLI 세션 ({0})', _speech: 'ko-KR', // boot.js cancelling: '취소 중\u2026', @@ -17238,6 +17258,8 @@ const LOCALES = { offline_stream_waiting: 'Connexion perdue. En attente de rafraîchissement\u2026', _lang: 'fr', _label: 'Français', + sessions_source_webui: 'Sessions WebUI ({0})', + sessions_source_cli: 'Sessions CLI ({0})', _speech: 'fr-FR', cancelling: 'Annulation\u2026', cancel_failed: 'Échec de l\'annulation.', @@ -18962,6 +18984,8 @@ const LOCALES = { cs: { _label: 'Čeština', + sessions_source_webui: 'Relace WebUI ({0})', + sessions_source_cli: 'Relace CLI ({0})', _lang: 'cs', _speech: 'cs-CZ', copy_relative_path: 'Kopírovat relativní cestu', @@ -20692,6 +20716,8 @@ const LOCALES = { offline_stream_waiting: 'Bağlantı kesildi. Yenilenmesi bekleniyor\u2026', _lang: 'tr', _label: 'Türkçe', + sessions_source_webui: 'WebUI oturumları ({0})', + sessions_source_cli: 'CLI oturumları ({0})', _speech: 'tr-TR', // boot.js cancelling: 'İptal ediliyor\u2026', @@ -22442,6 +22468,8 @@ const LOCALES = { offline_stream_waiting: 'Połączenie utracone. Oczekiwanie na odświeżenie…', _lang: 'pl', _label: 'Polski', + sessions_source_webui: 'Sesje WebUI ({0})', + sessions_source_cli: 'Sesje CLI ({0})', _speech: 'pl-PL', // boot.js cancelling: 'Anulowanie…', @@ -24196,6 +24224,8 @@ const LOCALES = { offline_stream_waiting: 'Mất kết nối. Đang chờ làm mới…', _lang: 'vi', _label: 'Tiếng Việt', + sessions_source_webui: 'Phiên WebUI ({0})', + sessions_source_cli: 'Phiên CLI ({0})', _speech: 'vi-VN', // boot.js cancelling: 'Đang hủy…', diff --git a/static/sessions.js b/static/sessions.js index bb7c49fb..7be28623 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2556,7 +2556,7 @@ function _isCliSession(session) { function _sessionSourceLabel(filter, count) { const n = Number(count) || 0; - return filter === 'cli' ? `CLI sessions (${n})` : `WebUI sessions (${n})`; + return filter === 'cli' ? t('sessions_source_cli', n) : t('sessions_source_webui', n); } function _clearSessionSourceTabCounts() { diff --git a/tests/test_issue2351_cli_session_source_filter.py b/tests/test_issue2351_cli_session_source_filter.py index 438386c7..bb441bf4 100644 --- a/tests/test_issue2351_cli_session_source_filter.py +++ b/tests/test_issue2351_cli_session_source_filter.py @@ -11,11 +11,27 @@ def test_sidebar_has_separate_webui_and_cli_session_source_tabs(): assert "let _sessionSourceFilter = 'webui'" in src assert "hermes-session-source-filter" in src assert "session-source-tabs" in src - assert "WebUI sessions" in src - assert "CLI sessions" in src + assert "t('sessions_source_webui', n)" in src + assert "t('sessions_source_cli', n)" in src assert "_sessionSourceFilter==='cli'" in src +def test_session_source_labels_are_locale_keys_with_number_placeholder(): + """Issue #7580: source tab labels must come from t() with {0}-interpolated + counts, not hardcoded English template literals.""" + i18n = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") + assert "sessions_source_webui: 'WebUI sessions ({0})'" in i18n # en bundle + assert "sessions_source_cli: 'CLI sessions ({0})'" in i18n # en bundle + assert "sessions_source_webui: 'Сеансы WebUI ({0})'" in i18n # ru bundle + assert "sessions_source_cli: 'Сеансы CLI ({0})'" in i18n # ru bundle + sessions_src = SESSIONS_JS.read_text(encoding="utf-8") + assert "t('sessions_source_cli', n)" in sessions_src + assert "t('sessions_source_webui', n)" in sessions_src + # The template-literal fallback must be gone: labels now come from the locale. + assert "`CLI sessions (${n})`" not in sessions_src + assert "`WebUI sessions (${n})`" not in sessions_src + + def test_cli_filter_keeps_cli_rows_out_of_default_webui_list(): src = SESSIONS_JS.read_text(encoding="utf-8") assert "function _partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in src From 345ce8baea5fe991c9b527238d55f1c5a68e5816 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 16 Sep 2026 02:43:58 -0700 Subject: [PATCH 070/446] Release: localize sidebar source-filter tabs (#7602) (#7603) Co-authored-by: n --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8185c56e..bbeff937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- **Sidebar source-filter tabs are now localized.** The WebUI / CLI session-source tabs in the sidebar were hardcoded English (`WebUI sessions (N)` / `CLI sessions (N)`) and assigned straight to `textContent`, so they stayed English under every non-English locale. They now route through `t()` with the standard `{0}` count placeholder and are translated across all shipped locales. Thanks @Yularzhi. (#7602, #7580) - **Logging in with password auth enabled no longer lands on a 404.** After a successful login the server redirects to `/sessions`, but `/sessions` (plural) was missing from the SPA-shell route allowlist in `handle_get()` — only `/`, `/index.html`, and `/session/*` (singular) served the app shell — so an authenticated user was met with a `404 not found` immediately after logging in. `/sessions` (and `/sessions?query`) now serves the app shell like the other SPA entry points. Authentication is unchanged: `check_auth()` still runs before the route handler, so an unauthenticated `GET /sessions` still redirects to the login page with `next=/sessions`. The bug was invisible without auth because the SPA handles `/sessions` client-side and the server route was never hit. Thanks @Skawsk59. (#7598) - **The WebUI fails closed when the Agent's Git identity disappears, instead of risking a run on mixed module state.** `ensure_agent_runtime_current()` guards against executing a mix of old and new Python modules across an Agent update by comparing the on-disk Git revision to the one loaded in memory. When that revision became unreadable — the file deleted, permission-denied, empty, corrupt, the source directory removed, or the read itself raising — a missing revision is indistinguishable from a changed one and must not be treated as proof the loaded code is current. The guard now raises the typed stale-runtime error for every unreadable-identity case (surfacing as an HTTP 409, not a 500), with no forgeable file-metadata (`mtime`/size) fallback authorizing execution. `KeyboardInterrupt`/`SystemExit` still propagate, the matching-revision happy path is unchanged, and initially non-Git runtimes are unaffected. Thanks @LeonardoLGDS. (#7407) From 906aebefa9bdd03d9dc5bc42cc097cf52fea15fd Mon Sep 17 00:00:00 2001 From: Kevin Meinon <3797846+KevinMeinon@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:03:36 +0200 Subject: [PATCH 071/446] fix(scroll): do not unpin a pinned tail reader on an above-tail collapse clamp (#7605) --- static/ui.js | 13 +- tests/test_issue1731_upward_scroll_unpins.py | 5 +- ...est_issue4856_android_scroll_regression.py | 2 +- ...test_scroll_collapse_clamp_keeps_follow.py | 240 ++++++++++++++++++ 4 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 tests/test_scroll_collapse_clamp_keeps_follow.py diff --git a/static/ui.js b/static/ui.js index a174c632..4a631482 100644 --- a/static/ui.js +++ b/static/ui.js @@ -6457,7 +6457,18 @@ if(typeof window!=='undefined'){ return; } _lastScrollTop=top; - if(movedUp){ + if(movedUp&&bottomDistance>1){ + // Only a real scroll-away unpins. A collapse ABOVE the tail (worklog + // "Done" fold, thinking/tool card collapse, interim-note collapse) shrinks + // scrollHeight while the reader is still flush at the tail, so the browser + // clamps scrollTop DOWN by the collapsed height and fires a scroll event: + // movedUp is true while bottomDistance stays ~0. Reading that as user + // intent killed live-follow mid-stream on a reader who never scrolled. + // The render-artifact suppression below cannot cover it: it needs a + // renderMessages() within the last 1400ms, and the collapse paths above run + // from the streaming handlers, which update the DOM incrementally and never + // stamp _lastMessageRenderAt. A genuine upward scroll always leaves the true + // bottom first, so it still has bottomDistance>1 here. _cancelBottomSettle(); _nearBottomCount=0; _scrollPinned=false; diff --git a/tests/test_issue1731_upward_scroll_unpins.py b/tests/test_issue1731_upward_scroll_unpins.py index ffd62f83..8138a261 100644 --- a/tests/test_issue1731_upward_scroll_unpins.py +++ b/tests/test_issue1731_upward_scroll_unpins.py @@ -71,7 +71,10 @@ def test_scroll_listener_detects_upward_motion(): def test_upward_scroll_unpins_immediately_without_hysteresis(): """Upward motion sets _scrollPinned=false and resets the counter, no count needed.""" block = _scroll_listener_block() - if_idx = block.index("if(movedUp)") + # The upward branch is the clamped one: it only fires once the reader has left + # the true bottom (bottomDistance>1), so an above-tail collapse clamp cannot + # unpin a reader who is still flush at the tail. + if_idx = block.index("if(movedUp&&bottomDistance>1){") else_idx = block.find("else", if_idx) assert else_idx > if_idx, "upward / downward branches not found (#1731)" upward_branch = block[if_idx:else_idx] diff --git a/tests/test_issue4856_android_scroll_regression.py b/tests/test_issue4856_android_scroll_regression.py index 52a46a9e..3aa24d82 100644 --- a/tests/test_issue4856_android_scroll_regression.py +++ b/tests/test_issue4856_android_scroll_regression.py @@ -133,7 +133,7 @@ def test_recent_render_scroll_artifact_window_suppresses_upward_unpin(): "low-delta message-pane wheel intent so a gentle trackpad scroll-up is " "not swallowed." ) - assert listener.find("return;") < listener.find("if(movedUp){"), ( + assert listener.find("return;") < listener.find("if(movedUp&&bottomDistance>1){"), ( "recent render artifact scrolls must return before the movedUp branch " "can mark the reader unpinned." ) diff --git a/tests/test_scroll_collapse_clamp_keeps_follow.py b/tests/test_scroll_collapse_clamp_keeps_follow.py new file mode 100644 index 00000000..68b258d6 --- /dev/null +++ b/tests/test_scroll_collapse_clamp_keeps_follow.py @@ -0,0 +1,240 @@ +"""A collapse above the tail must not unpin a reader who is flush at the tail. + +Geometry: the reader is pinned, flush at the tail (bottomDistance ~= 0). A block +ABOVE the tail collapses - the settled worklog fold, a thinking/tool card +collapse, or the interim-note collapse. scrollHeight shrinks by the collapsed +height, the browser clamps scrollTop DOWN by the same amount, and a scroll event +fires with movedUp=true while bottomDistance is still ~0. + +The post-render artifact suppression does not cover this: it requires a +renderMessages() within the last 1400ms, and those collapse paths run from the +streaming handlers (closeCurrentLiveActivityGroup / interim collapse), which +patch the DOM incrementally and never stamp _lastMessageRenderAt. So the +upward-unpin branch itself has to require that the reader actually left the +bottom before it treats an upward delta as scroll-away intent. + +These tests drive the real rAF callback body extracted from static/ui.js in node +with controllable geometry, the same way the #4970 harness does. +""" + +import json # noqa: E402 +import shutil # noqa: E402 +import subprocess # noqa: E402 +from pathlib import Path # noqa: E402 + +import pytest # noqa: E402 + +REPO = Path(__file__).resolve().parents[1] +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + + +def _balanced_block(src: str, brace_start: int) -> str: + depth = 0 + for i in range(brace_start, len(src)): + ch = src[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[brace_start + 1 : i] + raise AssertionError("balanced block not found") + + +def _scroll_listener_raf_body() -> str: + listener_start = UI_JS.index("el.addEventListener('scroll'") + raf_start = UI_JS.index("requestAnimationFrame(()=>", listener_start) + brace_start = UI_JS.index("{", raf_start) + return _balanced_block(UI_JS, brace_start) + + +def _run_listener( + samples, + *, + render_artifact=False, + wheel_intent=False, + touch_intent=False, + key_scroll=False, + scrollbar_drag=False, + start_scroll_top=5703, + start_client_height=474, + auto_follow=True, +): + """Run the extracted scroll-listener rAF body over geometry samples.""" + payload = { + "body": _scroll_listener_raf_body(), + "samples": samples, + "renderArtifact": bool(render_artifact), + "wheelIntent": bool(wheel_intent), + "touchIntent": bool(touch_intent), + "keyScroll": bool(key_scroll), + "scrollbarDrag": bool(scrollbar_drag), + "startTop": start_scroll_top, + "startClientHeight": start_client_height, + "autoFollow": bool(auto_follow), + } + script = ( + "const payload = " + json.dumps(payload) + ";\n" + + r""" +global.window = { _autoScrollFollow: payload.autoFollow, _showThinking: true }; +const step = new Function( + 'el', + '_lastScrollTop', + '_lastMessageClientHeight', + '_nearBottomCount', + '_scrollPinned', + '_messageUserUnpinned', + '_newMessageCueVisible', + '_cancelBottomSettle', + '_setMessageScrollToBottom', + '_clearNewMessageScrollCue', + '_syncScrollToBottomCue', + '_updateSessionStartJumpButton', + '_isSessionEndlessScrollEnabled', + '_messagesTruncated', + '_loadOlderMessages', + '_recentMessageRenderArtifactWindow', + '_recentMessageTouchScrollIntent', + '_recentNonMessageScrollIntent', + '_recentMessageWheelIntent', + '_recentMessageKeyScrollIntent', + '_recentMessageScrollIntent', + '_scheduleDeferredOlderMessagesLoad', + '_scrollbarDragActive', + '(()=>{' + payload.body + `})(); +return { + _lastScrollTop, + _lastMessageClientHeight, + _nearBottomCount, + _scrollPinned, + _messageUserUnpinned, +}; +` +); + +let state = { + _lastScrollTop: payload.startTop, + _lastMessageClientHeight: payload.startClientHeight, + _nearBottomCount: 0, + _scrollPinned: true, + _messageUserUnpinned: false, +}; + +const noop = () => {}; +const yes = () => true; +const no = () => false; + +for (const sample of payload.samples) { + const el = { + scrollTop: sample.scrollTop, + scrollHeight: sample.scrollHeight, + clientHeight: sample.clientHeight, + }; + state = step( + el, + state._lastScrollTop, + state._lastMessageClientHeight, + state._nearBottomCount, + state._scrollPinned, + state._messageUserUnpinned, + false, + noop, + noop, + noop, + noop, + noop, + no, + false, + noop, + () => payload.renderArtifact, + () => payload.touchIntent, + no, + () => payload.wheelIntent, + () => payload.keyScroll, + no, + noop, + payload.scrollbarDrag + ); +} + +console.log(JSON.stringify(state)); +""" + ) + result = subprocess.run( + [NODE, "-e", script], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + return json.loads(result.stdout.strip()) + + +# The clamp sample, verbatim from a headless-Chrome run against the real app +# (kanban t_f4eb293b): a 519px block above the tail collapses while the reader is +# flush at the bottom. scrollTop 5703 -> 5184, scrollHeight 6177 -> 5658, +# bottomDistance stays 0 in both samples. +_CLAMP_AFTER = {"scrollTop": 5184, "scrollHeight": 5658, "clientHeight": 474} +_CLAMP_BEFORE_TOP = 5703 +# A genuine scroll-away from the same start: the reader moves up and is no longer +# flush at the tail. +_SCROLL_AWAY = {"scrollTop": 5000, "scrollHeight": 6177, "clientHeight": 474} + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +class TestCollapseClampKeepsFollow: + def test_above_tail_collapse_clamp_does_not_unpin(self): + state = _run_listener([_CLAMP_AFTER], start_scroll_top=_CLAMP_BEFORE_TOP) + assert state["_messageUserUnpinned"] is False, ( + "An above-tail collapse clamps scrollTop down while the reader is still " + "flush at the tail (bottomDistance 0). That is a layout clamp, not " + "scroll-away intent: it must not set the sticky manual-unpin flag, or " + "live-follow dies mid-stream for a reader who never scrolled." + ) + assert state["_scrollPinned"] is True + + def test_real_upward_scroll_still_unpins(self): + state = _run_listener( + [_SCROLL_AWAY], start_scroll_top=_CLAMP_BEFORE_TOP + ) + assert state["_messageUserUnpinned"] is True, ( + "A real upward scroll leaves the true bottom (bottomDistance>1) and must " + "still unpin immediately - #1731 behaviour is unchanged." + ) + assert state["_scrollPinned"] is False + + def test_clamp_during_open_artifact_window_stays_pinned(self): + # Same clamp geometry with a renderMessages() in the last 1400ms: that path + # was already covered before this fix and must keep behaving the same way. + state = _run_listener( + [_CLAMP_AFTER], + start_scroll_top=_CLAMP_BEFORE_TOP, + render_artifact=True, + ) + assert state["_messageUserUnpinned"] is False + assert state["_scrollPinned"] is True + + def test_upward_delta_just_above_the_bottom_still_unpins(self): + # Boundary: a few px off the true bottom (bottomDistance=4) is a real, if + # tiny, scroll-away and is still honoured; only the 0/1px clamp case is + # treated as a layout artifact. + state = _run_listener( + [{"scrollTop": 5699, "scrollHeight": 6177, "clientHeight": 474}], + start_scroll_top=_CLAMP_BEFORE_TOP, + ) + assert state["_messageUserUnpinned"] is True + assert state["_scrollPinned"] is False + + +def test_upward_unpin_branch_requires_leaving_the_bottom(): + # Source pin: the upward branch must not fire on a pure clamp. Keeps a future + # refactor from dropping the bottom-distance check while the behavioural tests + # above still exercise the real callback body. + listener_start = UI_JS.index("el.addEventListener('scroll'") + listener = UI_JS[listener_start : listener_start + 4000] + assert "if(movedUp&&bottomDistance>1){" in listener, ( + "The movedUp branch must require the reader to have left the true bottom " + "(bottomDistance>1) before it unpins; an above-tail collapse clamp fires " + "movedUp with bottomDistance~=0." + ) From 03b2be0d386e830299a76c79145257c5a89a4e99 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 16 Sep 2026 07:07:39 -0700 Subject: [PATCH 072/446] Release: keep live-follow pinned through above-tail collapse (#7605) (#7606) Co-authored-by: n --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbeff937..345df148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- **Live-follow no longer breaks mid-stream when content above the tail collapses.** A reader pinned flush at the bottom of the transcript was being unpinned whenever a section *above* the tail collapsed — a worklog "Done" fold, a thinking/tool card collapse, an interim-note collapse. The collapse shrinks the transcript height, the browser clamps the scroll position downward and fires a scroll event, and that was misread as the user scrolling away, killing live-follow while a response was still streaming. The scroll listener now only treats an upward scroll as intent when the reader has actually left the bottom, so an above-tail collapse keeps following the stream. Genuine upward scrolls still unpin immediately. Thanks @KevinMeinon. (#7605) - **Sidebar source-filter tabs are now localized.** The WebUI / CLI session-source tabs in the sidebar were hardcoded English (`WebUI sessions (N)` / `CLI sessions (N)`) and assigned straight to `textContent`, so they stayed English under every non-English locale. They now route through `t()` with the standard `{0}` count placeholder and are translated across all shipped locales. Thanks @Yularzhi. (#7602, #7580) - **Logging in with password auth enabled no longer lands on a 404.** After a successful login the server redirects to `/sessions`, but `/sessions` (plural) was missing from the SPA-shell route allowlist in `handle_get()` — only `/`, `/index.html`, and `/session/*` (singular) served the app shell — so an authenticated user was met with a `404 not found` immediately after logging in. `/sessions` (and `/sessions?query`) now serves the app shell like the other SPA entry points. Authentication is unchanged: `check_auth()` still runs before the route handler, so an unauthenticated `GET /sessions` still redirects to the login page with `next=/sessions`. The bug was invisible without auth because the SPA handles `/sessions` client-side and the server route was never hit. Thanks @Skawsk59. (#7598) From be5ee8f5030ba6940c1d040f0e68d97bafeaa7be Mon Sep 17 00:00:00 2001 From: Kevin Meinon <3797846+KevinMeinon@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:38:43 +0200 Subject: [PATCH 073/446] perf(composer): make the single-row autoResize fast path reachable (long-session typing lag) (#7604) * perf(composer): make the single-row autoResize fast path reachable autoResize() skips the height:'auto' -> scrollHeight -> restore round trip only when the composer is already at its one-row height, but the guard compared el.offsetHeight against the CSS min-height (44px) while the composer's natural one-row border box is line-height + vertical padding + borders (~48px at the stock font). 48 <= ceil(44)+1 is false, so the skip was unreachable in a real browser: every append keystroke wrote style.height twice, and the scrollHeight read that follows forces a synchronous layout of the whole document, transcript included. The cost therefore grows with the rendered transcript, which is the "typing gets laggy in long sessions" report. Accept the natural one-row height as the skip ceiling, computed from the same already-read getComputedStyle. The strict finite-pixel gate is kept for every value involved, so a percentage / calc / normal line-height, padding or min-height still fails closed to the full resize (the #6349 re-gate property is preserved, and test_issue5514_composer_grow_scroll_pin.py still pins it). Measured on a 758-message / 5 MB session (31k DOM nodes rendered) with the browser Event Timing API (input -> next paint) at 6x CPU throttle: a keystroke went from 168ms to ~136ms median echo latency (72ms with the transcript detached), 96ms to ~72ms in the windowed state, and the style writes per 8 keystrokes from 16 to 2. The #5514 invariant holds: an oversized composer still takes the full resize because its offsetHeight is far above the one-row ceiling. Tests: - new tests/test_long_session_composer_typing_latency.py runs the real autoResize() body from messages.js in a node sandbox whose getComputedStyle returns the composer's computed styles; it is red before this change (2 failed, 27 passed) and green after (29 passed). - tests/test_issue5514_composer_grow_scroll_pin.py source pins updated to the new guard shape. * test(docs): address #7604 review - behavior-only tests + composer sizing docs Review feedback on the PR flagged two repo-rule items: tests that pin source strings, and an undocumented runtime behavior change. Both are addressed here. - tests/test_long_session_composer_typing_latency.py: delete test_fix_is_pinned_in_source. It asserted exact JS declarations, which the repo testing guidance rejects and which the harness in this module already covers by executing the REAL autoResize() body. In its place add test_natural_row_skip_survives_every_append_of_a_typed_word, which drives the actual typing loop ('' -> 'h' -> ... -> 'hello') and asserts zero height writes on every keystroke. Red on the pre-fix tree, green with the fix. - tests/test_issue5514_composer_grow_scroll_pin.py: convert test_single_line_growth_skips_the_height_round_trip from source-string pins to a behavior fixture whose getComputedStyle returns the composer's real computed styles. It asserts the observable outcome: a one-row append at the natural 48px row takes no height write, while an oversized 176px composer still remeasures back to one row. Red before the fix (2 writes), green after. - docs/UIUX-GUIDE.md: document the composer sizing invariants in the composer section - the single-row skip is a hot path and must stay reachable, its ceiling is the natural one-row height or the CSS min-height (whichever is larger), everything else still remeasures, and non-px computed values fail closed. ./scripts/test.sh on the two touched modules: 29 passed (3 failed on the pre-fix tree). Neighbour sweep over the 51 composer/scroll/typing modules: 386 passed. ruff check clean. * fix(tests,docs): derive composer test dimensions from style.css (#7604 review) Greptile's re-review is right: the fixtures hardcoded the 18px `data-font-size=large` composer (line-height 29.7px -> a 47.7px one-row box) as if it were the stylesheet's 16px default (44.4px). At the default the pre-fix ceiling (ceil(44)+1 = 45px) DID clear the one-row box, so the regression is font-size dependent - the 16px default reaches the skip, the 18px and 20px appearance settings do not - and not a stock-configuration bug. The runtime change is unaffected and unchanged. - new `tests/_composer_metrics.py`: parses `static/style.css` (the base `textarea#msg` rule, the `data-font-size` overrides, the skin overrides) and exposes each configuration's box metrics - natural one-row height, the integer offsetHeight a browser reports, and both the pre-fix and post-fix ceilings - so a fixture cannot encode the wrong font size again. - `tests/test_long_session_composer_typing_latency.py`: parameterized over every configuration the stylesheet can produce, with `test_the_regression_was_font_size_dependent_not_stock` pinning the grounding. - `tests/test_issue5514_composer_grow_scroll_pin.py`: the converted single-row test uses the same derived metrics. - `docs/UIUX-GUIDE.md`: the sizing section now states that the natural row tracks the appearance font size (44 / 48 / 51px) while `min-height` stays 44px, which is why a min-height-only ceiling silently disables the skip. Pre-fix tree: `4 failed, 31 passed` - exactly the large-18px and xlarge-20px parameterizations plus the two tests that assert the reported configuration. Post-fix tree: `35 passed`. Neighbour sweep over the 51 composer/scroll/typing modules: `392 passed`. ruff clean. --- docs/UIUX-GUIDE.md | 27 ++ static/messages.js | 25 +- tests/_composer_metrics.py | 210 ++++++++++++++ ...test_issue5514_composer_grow_scroll_pin.py | 95 ++++++- ...st_long_session_composer_typing_latency.py | 262 ++++++++++++++++++ 5 files changed, 604 insertions(+), 15 deletions(-) create mode 100644 tests/_composer_metrics.py create mode 100644 tests/test_long_session_composer_typing_latency.py diff --git a/docs/UIUX-GUIDE.md b/docs/UIUX-GUIDE.md index 0fe44958..6c38b4b3 100644 --- a/docs/UIUX-GUIDE.md +++ b/docs/UIUX-GUIDE.md @@ -146,6 +146,33 @@ When adding a control, consider where users will find it on both wide desktop an mobile. If a setting or quota/control surface does not fit in the composer, route it through the appropriate Control Center panel instead of squeezing the footer. +### Composer sizing + +The composer grows with its content up to a 200px cap. Where the browser supports +`field-sizing: content` (the stylesheet sets it on `textarea#msg`, with +`field-sizing: fixed` while the placeholder shows) CSS owns that; everywhere else +the JavaScript fallback (`autoResize()` in `static/messages.js`) measures, and it +runs on every keystroke - so treat it as a hot path and keep these invariants when +touching it (regression coverage: +`tests/test_long_session_composer_typing_latency.py`, which derives every +dimension from `static/style.css`, and +`tests/test_issue5514_composer_grow_scroll_pin.py`): + +- A single-row append that already fits its box skips the height round trip. That + round trip reads `scrollHeight`, which forces a synchronous layout of the whole + document, so its cost grows with the rendered transcript - this is the + long-session typing-lag class. Do not remove the skip. +- The skip's ceiling is the textarea's natural ONE-ROW height (`line-height` + + vertical padding + borders) or the CSS `min-height`, whichever is larger. + Compare against that natural row, never against `min-height` alone: the natural + row follows the appearance font size (44px at the 16px default, 48px at + `data-font-size=large`, 51px at `xlarge`), while `min-height` stays 44px, so a + min-height-only ceiling silently disables the skip for the larger sizes. +- Everything else still fully remeasures: an oversized composer, a replacement, a + shrink, a multi-line append, and session/draft restore. +- Non-pixel computed values (a percentage, `calc()`, `auto`) fail closed to the + full resize rather than enabling the skip from a bogus pixel parse. + ## Responsive behavior Mobile is not an afterthought. The repository documents a responsive layout with diff --git a/static/messages.js b/static/messages.js index 50f62a69..92a10712 100644 --- a/static/messages.js +++ b/static/messages.js @@ -7267,9 +7267,28 @@ function autoResize(){ // read as a bogus pixel number (parseFloat("50%")===50), which would wrongly // enable the fast path and leave the composer stuck tall. Reject anything that // is not exactly "px" so those cases fail closed to the full resize. - const _minHeightRaw=_isAppendOnly&&_fitsCurrentHeight?getComputedStyle(el).minHeight:''; - const _minHeight=/^(?:\d+(?:\.\d+)?|\.\d+)px$/.test(_minHeightRaw)?parseFloat(_minHeightRaw):NaN; - const _isAtMinimumHeight=Number.isFinite(_minHeight)&&el.offsetHeight<=Math.ceil(_minHeight)+1; + const _composerStyle=_isAppendOnly&&_fitsCurrentHeight?getComputedStyle(el):null; + const _composerPx=(raw)=>/^(?:\d+(?:\.\d+)?|\.\d+)px$/.test(raw==null?'':String(raw))?parseFloat(raw):NaN; + const _minHeightRaw=_composerStyle?_composerStyle.minHeight:''; + const _minHeight=_composerPx(_minHeightRaw); + // The ONE-ROW height the composer naturally settles at is line-height + + // vertical padding + borders (~48px at the stock font), which is TALLER than + // the CSS min-height (44px). Comparing el.offsetHeight against min-height + // alone therefore never matched in a real browser, so this skip was dead code + // and EVERY append keystroke paid the height:'auto' round trip below — and + // that scrollHeight read forces a synchronous layout of the whole document, + // transcript included. The cost grows with the rendered transcript: on a + // 758-message transcript (31k DOM nodes) a keystroke measured ~168ms median + // echo latency at 6x CPU throttle vs 72ms with the transcript detached. + // Accept the natural one-row height too; an oversized composer still takes the + // full resize because its offsetHeight exceeds that ceiling by far. + const _lineHeight=_composerStyle?_composerPx(_composerStyle.lineHeight):NaN; + const _naturalRowHeight=Number.isFinite(_lineHeight) + ?_lineHeight+(_composerPx(_composerStyle.paddingTop)||0)+(_composerPx(_composerStyle.paddingBottom)||0) + +(_composerPx(_composerStyle.borderTopWidth)||0)+(_composerPx(_composerStyle.borderBottomWidth)||0) + :NaN; + const _rowCeiling=Number.isFinite(_naturalRowHeight)&&Number.isFinite(_minHeight)?Math.max(_minHeight,_naturalRowHeight):_minHeight; + const _isAtMinimumHeight=Number.isFinite(_rowCeiling)&&el.offsetHeight<=Math.ceil(_rowCeiling)+1; if(_isAppendOnly&&_fitsCurrentHeight&&_isAtMinimumHeight){ _composerLastResizeValue=_nextValue; updateSendBtn(); diff --git a/tests/_composer_metrics.py b/tests/_composer_metrics.py new file mode 100644 index 00000000..44aef2d8 --- /dev/null +++ b/tests/_composer_metrics.py @@ -0,0 +1,210 @@ +"""Composer box metrics derived from the REAL stylesheet. + +``autoResize()`` in ``static/messages.js`` decides between skipping the height +round trip and doing a full remeasure from *computed* styles: the composer's +natural one-row border box is ``line-height + vertical padding + borders`` and it +is compared against the CSS ``min-height`` (see the guard discussed in +``docs/UIUX-GUIDE.md``, "Composer sizing"). + +Hardcoding those numbers in a test fixture is how the first #7604 fixture went +wrong: it used the 18px ``data-font-size="large"`` composer (29.7px line-height, +47.7px natural row) as if it were the stylesheet's 16px default. Parse +``static/style.css`` here instead and derive every dimension from it, so the +fixtures follow the stylesheet and cannot quietly encode the wrong font size. + +Usage:: + + from tests._composer_metrics import CONFIG_DEFAULT, CONFIG_LARGE, composer_metrics + + m = composer_metrics(CONFIG_LARGE) + m.natural_row # 47.7: what one row of text wants (border box) + m.offset_height # 48: what a browser reports (integer, min-height floored) + m.old_ceiling # ceil(min-height) + 1: the pre-#7604 skip ceiling + m.new_ceiling # ceil(max(min-height, natural row)) + 1: with the fix + m.computed # {"lineHeight": "29.7px", ...} for a getComputedStyle stub +""" +from __future__ import annotations + +import math +import re +from dataclasses import dataclass +from pathlib import Path + +STYLE_CSS = (Path(__file__).parents[1] / "static" / "style.css").read_text(encoding="utf-8") + + +def _declarations(rules: list[str]) -> dict[str, str]: + out: dict[str, str] = {} + for rule in rules: + for decl in rule.split(";"): + if ":" not in decl: + continue + prop, _, value = decl.partition(":") + out[prop.strip().lower()] = value.strip() + return out + + +def _rule(pattern: str, *, flags: int = re.M) -> str | None: + m = re.search(pattern, STYLE_CSS, flags) + return m.group(1) if m else None + + +def _px(value: str) -> float: + return float(value.replace("px", "").strip()) + + +def _padding_shorthand(value: str) -> tuple[float, float]: + """(padding-top, padding-bottom) from a CSS padding shorthand.""" + parts = value.split() + if len(parts) == 1: # all sides + return _px(parts[0]), _px(parts[0]) + if len(parts) == 2: # vertical horizontal + return _px(parts[0]), _px(parts[0]) + if len(parts) == 3: # top horizontal bottom + return _px(parts[0]), _px(parts[2]) + return _px(parts[0]), _px(parts[2]) + + +# The base composer rule: `textarea#msg{...}` (skin rules are prefixed with +# `:root[data-skin=...]`, so anchor the match at the start of a line). +_BASE = _declarations([_rule(r"^\s*textarea#msg\{([^}]*)\}") or ""]) +_BASE_FONT_SIZE = _px(_BASE["font-size"]) +_BASE_LINE_RATIO = float(_BASE["line-height"]) +_BASE_PAD_TOP, _BASE_PAD_BOTTOM = _padding_shorthand(_BASE["padding"]) +_MIN_HEIGHT = _px(_BASE["min-height"]) +# `border:none` / unset borders contribute nothing to the computed border box. +_BORDER = _px(_BASE.get("border-width", "0px")) if _BASE.get("border-width") else 0.0 + +# `:root[data-font-size="large"] #msg { font-size: 18px; }` overrides, i.e. the +# appearance setting a real user can switch on. +_FONT_SIZE_OVERRIDES = { + name: _px(size) + for name, size in re.findall( + r':root\[data-font-size="(\w+)"\]\s*#msg\s*\{\s*font-size:\s*([\d.]+px)', STYLE_CSS + ) +} + +# Skins that restyle the composer's font metrics, e.g. +# `:root[data-skin="graphite"] textarea#msg{...font-size:14px;...line-height:1.45;}`. +_SKIN_OVERRIDES = { + name: _declarations([body]) + for name, body in re.findall( + r':root\[data-skin="([\w-]+)"\]\s*textarea#msg\{([^}]*)\}', STYLE_CSS + ) +} + + +@dataclass(frozen=True) +class ComposerMetrics: + """One composer configuration, all values derived from ``static/style.css``.""" + + name: str + font_size: float + line_height: float + padding_top: float + padding_bottom: float + border_top: float + border_bottom: float + min_height: float + + @property + def natural_row(self) -> float: + """The border box one row of text wants (line-height + padding + borders).""" + return ( + self.line_height + + self.padding_top + + self.padding_bottom + + self.border_top + + self.border_bottom + ) + + @property + def offset_height(self) -> int: + """What a browser reports for a one-row composer: an integer, min-height floored.""" + return round(max(self.natural_row, self.min_height)) + + @property + def old_ceiling(self) -> int: + """The pre-#7604 skip ceiling: ``ceil(min-height) + 1`` (min-height only).""" + return math.ceil(self.min_height) + 1 + + @property + def new_ceiling(self) -> int: + """The skip ceiling with the natural one-row height considered.""" + return math.ceil(max(self.min_height, self.natural_row)) + 1 + + @property + def skip_was_reachable(self) -> bool: + return self.offset_height <= self.old_ceiling + + @property + def skip_is_reachable(self) -> bool: + return self.offset_height <= self.new_ceiling + + @property + def content_two_rows(self) -> int: + """scrollHeight for a two-row value in the same box.""" + return round(2 * self.line_height + self.padding_top + self.padding_bottom) + + @property + def oversized_box(self) -> int: + """A composer several rows tall that must still remeasure back down.""" + return round(self.natural_row + 3 * self.line_height) + + @property + def computed(self) -> dict[str, str]: + """The ``getComputedStyle`` payload the JS guard reads.""" + + def px(value: float) -> str: + return f"{value:g}px" + + return { + "minHeight": px(self.min_height), + "lineHeight": px(self.line_height), + "paddingTop": px(self.padding_top), + "paddingBottom": px(self.padding_bottom), + "borderTopWidth": px(self.border_top), + "borderBottomWidth": px(self.border_bottom), + "fontSize": px(self.font_size), + } + + +def composer_metrics( + font_size: float | None = None, + line_height_ratio: float | None = None, + *, + name: str = "custom", +) -> ComposerMetrics: + """Metrics for the stylesheet's composer at ``font_size`` (default: 16px).""" + size = _BASE_FONT_SIZE if font_size is None else font_size + ratio = _BASE_LINE_RATIO if line_height_ratio is None else line_height_ratio + return ComposerMetrics( + name=name, + font_size=size, + line_height=size * ratio, + padding_top=_BASE_PAD_TOP, + padding_bottom=_BASE_PAD_BOTTOM, + border_top=_BORDER, + border_bottom=_BORDER, + min_height=_MIN_HEIGHT, + ) + + +#: The stylesheet default (16px), i.e. `textarea#msg` with no appearance override. +CONFIG_DEFAULT = composer_metrics(name="default-16px") + +#: The `data-font-size` appearance overrides. +CONFIG_SMALL = composer_metrics(_FONT_SIZE_OVERRIDES.get("small"), name="small-14px") +CONFIG_LARGE = composer_metrics(_FONT_SIZE_OVERRIDES.get("large"), name="large-18px") +CONFIG_XLARGE = composer_metrics(_FONT_SIZE_OVERRIDES.get("xlarge"), name="xlarge-20px") + +#: A skin that restyles the composer metrics (14px / 1.45). +_SKIN = _SKIN_OVERRIDES.get("graphite", {}) +CONFIG_SKIN = composer_metrics( + _px(_SKIN["font-size"]) if "font-size" in _SKIN else None, + float(_SKIN["line-height"]) if "line-height" in _SKIN else None, + name="skin-graphite-14px", +) + +#: Every composer configuration the stylesheet can produce. +COMPOSER_CONFIGS = (CONFIG_DEFAULT, CONFIG_SMALL, CONFIG_LARGE, CONFIG_XLARGE, CONFIG_SKIN) diff --git a/tests/test_issue5514_composer_grow_scroll_pin.py b/tests/test_issue5514_composer_grow_scroll_pin.py index 6532f002..a90a5cf4 100644 --- a/tests/test_issue5514_composer_grow_scroll_pin.py +++ b/tests/test_issue5514_composer_grow_scroll_pin.py @@ -35,6 +35,8 @@ from pathlib import Path import pytest +from tests._composer_metrics import CONFIG_LARGE + ROOT = Path(__file__).parents[1] UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") @@ -409,19 +411,88 @@ def test_resize_observer_installed_on_composer(): assert "can't strand" in BOOT_JS -def test_single_line_growth_skips_the_height_round_trip(): +def _run_composer_single_row_fixture(*, value: str, previous_value: str, + offset_height: int, content_height: int | None = None, + config=None): + """Run the REAL autoResize() body against a faithful textarea stub whose + ``getComputedStyle`` returns a real composer's computed styles, and report the + observable resize behaviour (height writes, settled height, repins). + + The configuration (and therefore every dimension) is derived from + ``static/style.css`` by ``tests/_composer_metrics.py`` instead of being typed + in here: the composer's box depends on the appearance font-size setting, and + hardcoding one configuration is exactly how the first revision of this test + encoded the 18px ``data-font-size=large`` composer as "stock". + + ``content_height`` is the height the content WANTS (default: one row); a real + textarea reports its box height in scrollHeight while the box is taller than + the content and the content height once the box collapses to ``height:'auto'``. + """ + node = shutil.which("node") + if not node: # pragma: no cover + pytest.skip("node not available") + config = CONFIG_LARGE if config is None else config + if content_height is None: + content_height = config.offset_height body = _autoresize_body() - assert "let _composerLastResizeValue='';" in MESSAGES_JS - assert "const _isAppendOnly=_nextValue.length>_composerLastResizeValue.length&&_nextValue.startsWith(_composerLastResizeValue);" in body - assert "const _fitsCurrentHeight=el.scrollHeight<=el.offsetHeight;" in body - assert "const _minHeightRaw=_isAppendOnly&&_fitsCurrentHeight?getComputedStyle(el).minHeight:'';" in body - assert "const _minHeight=/^(?:\\d+(?:\\.\\d+)?|\\.\\d+)px$/.test(_minHeightRaw)?parseFloat(_minHeightRaw):NaN;" in body - # The strict finite-pixel guard rejects a percentage/auto/calc min-height so a - # bogus parseFloat("50%")===50 can't wrongly enable the fast path (Codex #6349 re-gate). - assert "parseFloat(getComputedStyle(el).minHeight)" not in body # old lax parse is gone - assert "const _isAtMinimumHeight=Number.isFinite(_minHeight)&&el.offsetHeight<=Math.ceil(_minHeight)+1;" in body - assert "if(_isAppendOnly&&_fitsCurrentHeight&&_isAtMinimumHeight){" in body - assert "el.style.height='auto'" in body + harness = textwrap.dedent( + """ + let _composerAutoResizeRaf = 0; + let _composerLastResizeValue = %(previous_value)r; + let writes = 0, height = %(offset_height)s, repins = 0; + const NATURAL_ROW = %(natural_row)s; + const CONTENT_H = %(content_height)s; + const msg = { + value: %(value)r, + get offsetHeight() { return height; }, + get scrollHeight() { return height > NATURAL_ROW ? height : CONTENT_H; }, + style: { + set height(value) { writes += 1; height = value === 'auto' ? NATURAL_ROW : parseInt(value, 10); }, + get height() { return height + 'px'; }, + }, + }; + const messages = { scrollTop: 0 }; + const $ = (id) => id === 'msg' ? msg : id === 'messages' ? messages : null; + const COMPUTED = %(computed)s; + function getComputedStyle() { return COMPUTED; } + function updateSendBtn() {} + function _repinMessagesAfterComposerResize() { repins += 1; } + %(autoresize)s + autoResize(); + console.log(JSON.stringify({ writes, height, lastValue: _composerLastResizeValue, repins })); + """ + ) % { + "previous_value": previous_value, + "offset_height": offset_height, + "content_height": content_height, + "natural_row": config.offset_height, + "value": value, + "computed": json.dumps(config.computed), + "autoresize": body, + } + proc = subprocess.run([node, "-e", harness], capture_output=True, text=True, timeout=30) + assert proc.returncode == 0, f"node harness failed: {proc.stderr}" + return json.loads(proc.stdout) + + +def test_single_line_growth_skips_the_height_round_trip(): + # The one-row fast path must be REACHABLE. It gates on el.offsetHeight against + # the min-height ceiling, and the composer's natural one-row height + # (line-height + vertical padding + borders) is 47.7px in the reported + # configuration (the 18px `data-font-size=large` composer) against a pre-fix + # ceiling of ceil(44px)+1 = 45px - so every append at rest missed the skip and + # paid the height:'auto' round trip, whose scrollHeight read forces a + # synchronous full-document reflow (typing lag that grows with the rendered + # transcript). Behaviour, not source: the fixture runs the real autoResize(). + one_row = CONFIG_LARGE.offset_height + out = _run_composer_single_row_fixture(value="a", previous_value="", offset_height=one_row) + assert out == {"writes": 0, "height": one_row, "lastValue": "a", "repins": 0}, out + # ...and the widened ceiling stays BOUNDED: an oversized composer is far above + # one row, so it still remeasures back down (the #5514 shrink-back invariant). + oversized = _run_composer_single_row_fixture( + value="a", previous_value="", offset_height=CONFIG_LARGE.oversized_box + ) + assert oversized == {"writes": 2, "height": one_row, "lastValue": "a", "repins": 0}, oversized # --------------------------------------------------------------------------- def _run(scenario): diff --git a/tests/test_long_session_composer_typing_latency.py b/tests/test_long_session_composer_typing_latency.py new file mode 100644 index 00000000..7be3a81d --- /dev/null +++ b/tests/test_long_session_composer_typing_latency.py @@ -0,0 +1,262 @@ +"""Regression coverage for composer typing lag in long chat sessions. + +Symptom: in a long session, typing becomes laggy - keystrokes echo with a +visible delay. Measured on a 758-message / 5 MB session (31k DOM nodes +rendered), with the browser's own Event Timing API (input -> next paint) at 6x +CPU throttle: + + transcript rendered keystroke -> echo (median) + full (36 rows) @6x 168ms -> 136ms with this fix + windowed (11 rows) @6x 96ms -> 72ms with this fix + floor (transcript detached) 72ms + +Root cause: ``autoResize()`` in ``static/messages.js`` has a single-row fast path +that must skip the ``height:'auto'`` -> read ``scrollHeight`` -> restore round +trip. That round trip forces a SYNCHRONOUS layout of the whole document (the +transcript included), so its cost grows with the rendered transcript - exactly +the reported symptom. The guard that gates the skip compared ``el.offsetHeight`` +against the CSS ``min-height`` (44px -> a ceiling of ``ceil(44)+1 = 45px``) +instead of the composer's natural ONE-ROW height - ``line-height + vertical +padding + borders``. + +Which configurations that broke is a property of the stylesheet, so the numbers +below are DERIVED from ``static/style.css`` by ``tests/_composer_metrics.py`` +rather than typed into a fixture (an earlier revision of this module hardcoded +the 18px numbers and mislabelled them as the stock font size): + + composer config one-row box pre-fix ceiling skip fired? + default 16px 44px 45px yes (0.6px margin) + data-font-size=large (18px) 48px 45px NO - dead code + data-font-size=xlarge (20px) 51px 45px NO - dead code + skin graphite (14px/1.45) 44px 45px yes + +So the fix is font-size independent: it derives the skip ceiling from the same +computed styles the guard already reads, and therefore also covers the smaller +configurations (which only cleared the old ceiling by 0.6px, or purely because +``min-height`` floored the box). + +Fix: accept the natural one-row height (computed from line-height + padding + +borders) as the skip ceiling, failing closed to the old min-height-only +behaviour whenever those computed values are not strict px values (so a +percentage / ``normal`` line-height cannot wrongly enable the skip). + +These tests exercise the REAL ``autoResize()`` body extracted from messages.js in +a node sandbox whose ``getComputedStyle`` returns the requested composer's real +computed styles. ``test_skip_is_reachable_in_every_composer_configuration`` is +red on the pre-fix tree for the 18px/20px configurations and green after it. +""" +import json +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +from tests._composer_metrics import ( + COMPOSER_CONFIGS, + CONFIG_DEFAULT, + CONFIG_LARGE, + CONFIG_SKIN, + CONFIG_SMALL, + CONFIG_XLARGE, +) + +ROOT = Path(__file__).parents[1] +MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") + + +def _autoresize_body() -> str: + start = MESSAGES_JS.find("function autoResize(") + assert start != -1 + end = MESSAGES_JS.find("function scheduleComposerAutoResize(", start) + assert end > start + return MESSAGES_JS[start:end] + + +def _run_autoresize(config, *, value: str, previous_value: str, box_height: int, + content_height: int, computed=None): + """Run the real autoResize() against a faithful textarea stub. + + ``box_height`` is the composer's current offsetHeight and ``content_height`` + the height its content WANTS (one row = the config's one-row box, two rows = + ``config.content_two_rows``); the stub models a real textarea, whose + scrollHeight is the box height while the box is taller than the content and + the content height once the box collapses to ``height:'auto'``. + """ + node = shutil.which("node") + assert node, "node is required for the autoResize harness" + body = _autoresize_body() + harness = textwrap.dedent( + """ + let _composerAutoResizeRaf = 0; + let _composerLastResizeValue = %(previous_value)r; + let writes = 0, height = %(box_height)s; + const NATURAL_ROW = %(natural_row)s; + const CONTENT_H = %(content_height)s; + const msg = { + value: %(value)r, + get offsetHeight() { return height; }, + get scrollHeight() { return height > NATURAL_ROW ? height : CONTENT_H; }, + style: { + set height(v) { writes += 1; height = v === 'auto' ? NATURAL_ROW : parseInt(v, 10); }, + get height() { return height + 'px'; }, + }, + }; + const messages = { scrollTop: 0 }; + const $ = (id) => id === 'msg' ? msg : id === 'messages' ? messages : null; + const COMPUTED = %(computed)s; + function getComputedStyle() { return COMPUTED; } + let sendUpdates = 0; + function updateSendBtn() { sendUpdates += 1; } + function _repinMessagesAfterComposerResize() {} + %(autoresize)s + autoResize(); + console.log(JSON.stringify({ writes, height, lastValue: _composerLastResizeValue, sendUpdates })); + """ + ) % { + "previous_value": previous_value, + "value": value, + "box_height": box_height, + "content_height": content_height, + "natural_row": config.offset_height, + "computed": json.dumps(config.computed if computed is None else computed), + "autoresize": body, + } + proc = subprocess.run([node, "-e", harness], capture_output=True, text=True, timeout=30) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.mark.parametrize("config", COMPOSER_CONFIGS, ids=lambda c: c.name) +def test_skip_is_reachable_in_every_composer_configuration(config): + """THE typing-lag regression: at its natural one-row height an append + keystroke must NOT run the height round trip, in every configuration the + stylesheet can produce. + + Pre-fix the one-row box was compared against the 44px CSS min-height (a + 45px ceiling). That still cleared the 16px default (44px) by 0.6px, but the + 18px/20px ``data-font-size`` composers sit at 48px/51px, where the skip was + dead code and every keystroke forced a synchronous full-document reflow. + """ + out = _run_autoresize( + config, value="a", previous_value="", box_height=config.offset_height, + content_height=config.offset_height, + ) + assert out["writes"] == 0, f"one-row append must skip the resize round trip; got {out}" + assert out["height"] == config.offset_height, out + assert out["lastValue"] == "a", out + assert out["sendUpdates"] == 1, "the primary-button refresh must still run" + + +def test_the_regression_was_font_size_dependent_not_stock(): + """Grounding for this fix, straight from the stylesheet. + + This is the check the first revision of this module failed: it asserted a + 48px "stock" composer, which is the 18px ``data-font-size=large`` box. The + real default (16px) already reached the old ceiling, so the fix is about + making the skip font-size independent - not about a default the reporter + never ran. + """ + assert CONFIG_DEFAULT.offset_height <= CONFIG_DEFAULT.old_ceiling, ( + "the stylesheet default is expected to have reached the pre-fix ceiling" + ) + assert CONFIG_LARGE.offset_height > CONFIG_LARGE.old_ceiling + assert CONFIG_XLARGE.offset_height > CONFIG_XLARGE.old_ceiling + # ...and after the fix every configuration's one-row box clears the ceiling, + # including the two that were dead and the two that were already fine. + for config in COMPOSER_CONFIGS: + assert config.skip_is_reachable, f"{config.name} must reach the skip after the fix" + assert CONFIG_SMALL.offset_height == CONFIG_SMALL.min_height, ( + "the small configuration's box is floored by min-height, not by its text" + ) + assert CONFIG_SKIN.offset_height == CONFIG_SKIN.min_height + + +def test_typed_word_appends_skip_in_the_reported_configuration(): + """The reported lag configuration (18px composer) must skip on EVERY append + keystroke, not just the first: the real typing loop is '' -> 'h' -> 'he' ... + """ + previous = "" + for letter in "hello": + value = previous + letter + out = _run_autoresize( + CONFIG_LARGE, value=value, previous_value=previous, + box_height=CONFIG_LARGE.offset_height, + content_height=CONFIG_LARGE.offset_height, + ) + assert out["writes"] == 0, f"append {value!r} must skip; got {out}" + assert out["lastValue"] == value, out + previous = value + + +def test_oversized_composer_still_remeasures_to_its_natural_height(): + """The skip must not preserve an oversized composer (the #5514 invariant). + + A composer several rows tall holding a one-line value remeasures down to one + row: its offsetHeight is far above the natural one-row ceiling. + """ + out = _run_autoresize( + CONFIG_LARGE, value="short prefix", previous_value="short prefi", + box_height=CONFIG_LARGE.oversized_box, content_height=CONFIG_LARGE.offset_height, + ) + assert out["writes"] == 2, f"an oversized composer must remeasure; got {out}" + assert out["height"] == CONFIG_LARGE.offset_height, out + + +def test_single_line_delete_still_runs_the_height_round_trip(): + """Shrinking values are not append-only and must remeasure.""" + out = _run_autoresize( + CONFIG_LARGE, value="a", previous_value="hello", + box_height=CONFIG_LARGE.oversized_box, content_height=CONFIG_LARGE.offset_height, + ) + assert out["writes"] == 2, f"a delete must remeasure; got {out}" + assert out["height"] == CONFIG_LARGE.offset_height, out + + +def test_multi_line_append_still_runs_the_height_round_trip(): + """A newline append is not a one-row append: it must measure and grow.""" + out = _run_autoresize( + CONFIG_LARGE, value="a\nb", previous_value="a", + box_height=CONFIG_LARGE.offset_height, + content_height=CONFIG_LARGE.content_two_rows, + ) + assert out["writes"] == 2, f"newline growth must remeasure; got {out}" + assert out["height"] == CONFIG_LARGE.content_two_rows, out + + +def test_non_px_computed_values_fail_closed_to_min_height_only(): + """Without strict px padding/line-height values the skip falls back to the + pre-fix min-height-only semantics, i.e. the 18px one-row box remeasures.""" + out = _run_autoresize( + CONFIG_LARGE, value="a", previous_value="", + box_height=CONFIG_LARGE.offset_height, + content_height=CONFIG_LARGE.offset_height, + computed={"minHeight": "44px"}, + ) + assert out["writes"] == 2, f"must fail closed to the full resize; got {out}" + + +def test_percentage_line_height_fails_closed(): + """A percentage line-height is not a strict px value and must not enable the + skip (mirrors the #6349 min-height gate).""" + out = _run_autoresize( + CONFIG_LARGE, value="a", previous_value="", + box_height=CONFIG_LARGE.offset_height, + content_height=CONFIG_LARGE.offset_height, + computed={**CONFIG_LARGE.computed, "lineHeight": "165%"}, + ) + assert out["writes"] == 2, f"percentage line-height must fail closed; got {out}" + + +def test_percentage_min_height_fails_closed(): + """A percentage min-height must not be read as a pixel number by + ``parseFloat`` (``parseFloat('50%') === 50``) - the #6349 property, still in + force after this change: no strict px min-height means no reachable ceiling.""" + out = _run_autoresize( + CONFIG_DEFAULT, value="a", previous_value="", + box_height=CONFIG_DEFAULT.offset_height, + content_height=CONFIG_DEFAULT.offset_height, + computed={**CONFIG_DEFAULT.computed, "minHeight": "50%"}, + ) + assert out["writes"] == 2, f"percentage min-height must fail closed; got {out}" From a375aa9356a249d26b51574d429fcdaa8a4ef1c6 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 1 Sep 2026 03:51:56 -0700 Subject: [PATCH 074/446] fix: honor the persisted TTS engine when requests omit it Fixes #7391 --- api/routes.py | 17 +- tests/test_issue7391_tts_default_engine.py | 214 +++++++++++++++++++++ 2 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue7391_tts_default_engine.py diff --git a/api/routes.py b/api/routes.py index 4cdb91c5..ff187fac 100644 --- a/api/routes.py +++ b/api/routes.py @@ -20076,7 +20076,22 @@ def _handle_tts(handler, parsed): voice = data.get("voice") or voice rate_str = _normalize_tts_prosody(data.get("rate"), unit="%") pitch_str = _normalize_tts_prosody(data.get("pitch"), unit="Hz") - engine = (data.get("engine") or "edge").strip().lower() + request_engine = data.get("engine") + if "engine" not in data or ( + isinstance(request_engine, str) and not request_engine.strip() + ): + persisted_engine = (load_settings() or {}).get("tts_engine") + if isinstance(persisted_engine, str): + persisted_engine = persisted_engine.strip().lower() + else: + persisted_engine = "" + engine = ( + persisted_engine + if persisted_engine in {"edge", "elevenlabs", "openai"} + else "edge" + ) + else: + engine = (request_engine or "edge").strip().lower() except Exception: from api.helpers import bad as _bad return _bad(handler, "invalid request body", 400) diff --git a/tests/test_issue7391_tts_default_engine.py b/tests/test_issue7391_tts_default_engine.py new file mode 100644 index 00000000..24d71cea --- /dev/null +++ b/tests/test_issue7391_tts_default_engine.py @@ -0,0 +1,214 @@ +"""Persisted server TTS engine routing coverage for #7391.""" + +import io +import json +import sys +from types import SimpleNamespace + +import pytest + +import api.routes as routes + + +_MISSING = object() + + +class _FakeHandler: + def __init__(self, body: bytes, client="10.73.91.1"): + self.command = "POST" + self.rfile = io.BytesIO(body) + self.wfile = io.BytesIO() + self.headers = {"Content-Length": str(len(body))} + self.client_address = (client, 12345) + self.status = None + self.sent_headers = {} + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.sent_headers[key] = value + + def end_headers(self): + pass + + def payload(self): + try: + return json.loads(self.wfile.getvalue().decode("utf-8")) + except Exception: + return None + + +class _AudioResponse: + headers = {"Content-Type": "audio/mpeg"} + + def __init__(self, audio): + self.audio = audio + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self, _size=-1): + audio, self.audio = self.audio, b"" + return audio + + +@pytest.fixture(autouse=True) +def _isolated_tts(monkeypatch): + import api.auth as auth + import api.config as config + + monkeypatch.setattr(auth, "is_auth_enabled", lambda: False) + monkeypatch.setattr(routes, "is_auth_enabled", lambda: False, raising=False) + monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False) + monkeypatch.setenv("ELEVENLABS_API_KEY", "sk-elevenlabs") + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setattr(config, "get_config", lambda: { + "tts": { + "elevenlabs": { + "voice_id": "persistedElevenVoice", + "model": "eleven_persisted_model", + }, + "openai": { + "base_url": "https://tts.example.com/v1/", + "model": "persisted-openai-model", + "voice": "persisted-openai-voice", + }, + } + }) + if hasattr(routes._handle_tts, "_tts_limiter"): + del routes._handle_tts._tts_limiter + yield + if hasattr(routes._handle_tts, "_tts_limiter"): + del routes._handle_tts._tts_limiter + + +def _request(monkeypatch, persisted_engine, request_engine=_MISSING): + captured = {} + settings = {} if persisted_engine is _MISSING else {"tts_engine": persisted_engine} + monkeypatch.setattr(routes, "load_settings", lambda: settings) + + class _FakeCommunicate: + def __init__(self, text, voice, **kwargs): + captured["edge"] = {"text": text, "voice": voice, "kwargs": kwargs} + + def stream_sync(self): + yield {"type": "audio", "data": b"edge-audio"} + + def _fake_tts_open(req, **_kwargs): + body = json.loads(req.data.decode("utf-8")) + if "api.elevenlabs.io" in req.full_url: + captured["elevenlabs"] = {"url": req.full_url, "body": body} + return _AudioResponse(b"elevenlabs-audio") + captured["openai"] = {"url": req.full_url, "body": body} + return _AudioResponse(b"openai-audio") + + monkeypatch.setitem(sys.modules, "edge_tts", SimpleNamespace(Communicate=_FakeCommunicate)) + monkeypatch.setattr(routes, "_tts_open", _fake_tts_open) + + body = {"text": "Hello", "voice": "en-US-AriaNeural"} + if request_engine is not _MISSING: + body["engine"] = request_engine + handler = _FakeHandler(json.dumps(body).encode("utf-8")) + routes._handle_tts(handler, None) + return handler, captured + + +@pytest.mark.parametrize( + ("persisted_engine", "expected_audio"), + [ + ("edge", b"edge-audio"), + ("elevenlabs", b"elevenlabs-audio"), + ("openai", b"openai-audio"), + ], +) +@pytest.mark.parametrize("request_engine", [_MISSING, ""]) +def test_missing_or_empty_engine_uses_persisted_server_engine( + monkeypatch, persisted_engine, expected_audio, request_engine +): + handler, captured = _request(monkeypatch, persisted_engine, request_engine) + + assert handler.status == 200 + assert handler.wfile.getvalue() == expected_audio + if persisted_engine == "openai": + assert captured["openai"] == { + "url": "https://tts.example.com/v1/audio/speech", + "body": { + "model": "persisted-openai-model", + "input": "Hello", + "voice": "persisted-openai-voice", + }, + } + + +@pytest.mark.parametrize("request_engine", [_MISSING, ""]) +@pytest.mark.parametrize( + "persisted_engine", [_MISSING, "", 7, [], {}, "browser", "voicevox_local"] +) +def test_unusable_persisted_engine_defaults_to_edge(monkeypatch, request_engine, persisted_engine): + handler, captured = _request(monkeypatch, persisted_engine, request_engine) + + assert handler.status == 200 + assert handler.wfile.getvalue() == b"edge-audio" + assert captured["edge"]["voice"] == "en-US-AriaNeural" + + +@pytest.mark.parametrize( + ("persisted_engine", "request_engine", "expected_audio"), + [ + ("edge", "elevenlabs", b"elevenlabs-audio"), + ("elevenlabs", "openai", b"openai-audio"), + ("openai", "edge", b"edge-audio"), + ], +) +def test_explicit_engine_overrides_each_persisted_server_engine( + monkeypatch, persisted_engine, request_engine, expected_audio +): + handler, _captured = _request(monkeypatch, persisted_engine, request_engine) + + assert handler.status == 200 + assert handler.wfile.getvalue() == expected_audio + + +def test_explicit_engine_still_normalizes_whitespace_and_case(monkeypatch): + handler, _captured = _request(monkeypatch, "edge", " OpEnAi ") + + assert handler.status == 200 + assert handler.wfile.getvalue() == b"openai-audio" + + +def test_persisted_server_engine_normalizes_whitespace_and_case(monkeypatch): + handler, _captured = _request(monkeypatch, " ElEvEnLaBs ") + + assert handler.status == 200 + assert handler.wfile.getvalue() == b"elevenlabs-audio" + + +def test_explicit_unknown_engine_keeps_edge_compatibility_behavior(monkeypatch): + handler, _captured = _request(monkeypatch, "openai", "unknown-engine") + + assert handler.status == 200 + assert handler.wfile.getvalue() == b"edge-audio" + + +@pytest.mark.parametrize("request_engine", [7, {"engine": "openai"}]) +def test_explicit_truthy_non_string_engine_keeps_invalid_body_response( + monkeypatch, request_engine +): + handler, _captured = _request(monkeypatch, "openai", request_engine) + + assert handler.status == 400 + assert "invalid request body" in (handler.payload() or {}).get("error", "") + + +@pytest.mark.parametrize("request_engine", [None, 0, False]) +def test_explicit_falsy_non_string_engine_keeps_edge_compatibility_behavior( + monkeypatch, request_engine +): + handler, _captured = _request(monkeypatch, "openai", request_engine) + + assert handler.status == 200 + assert handler.wfile.getvalue() == b"edge-audio" From 06d28c08cb0df88fa3603c105a8f21bd204a29e2 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 16 Sep 2026 11:17:36 -0700 Subject: [PATCH 075/446] Release: make composer single-row autoResize fast path reachable (#7604) (#7607) Co-authored-by: n --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 345df148..74c6fb52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- **Typing in a long session no longer lags.** The composer's `autoResize()` has a fast path meant to skip an expensive measure-and-restore layout on every keystroke, but the guard enabling it compared the composer height against its CSS `min-height` (44px) rather than the height a single row actually settles at (line-height + padding + borders, ~48px), so the skip never fired in a real browser. Every keystroke then forced a full-document synchronous layout whose cost grows with the rendered transcript — on a ~750-message session this was ~168ms of echo latency per key versus ~72ms with the transcript detached. The fast path now compares against the real one-row height (tracking the appearance font size), so single-row typing skips the layout while any append that genuinely needs a taller composer still triggers a full resize. Thanks @KevinMeinon. (#7604) - **Live-follow no longer breaks mid-stream when content above the tail collapses.** A reader pinned flush at the bottom of the transcript was being unpinned whenever a section *above* the tail collapsed — a worklog "Done" fold, a thinking/tool card collapse, an interim-note collapse. The collapse shrinks the transcript height, the browser clamps the scroll position downward and fires a scroll event, and that was misread as the user scrolling away, killing live-follow while a response was still streaming. The scroll listener now only treats an upward scroll as intent when the reader has actually left the bottom, so an above-tail collapse keeps following the stream. Genuine upward scrolls still unpin immediately. Thanks @KevinMeinon. (#7605) - **Sidebar source-filter tabs are now localized.** The WebUI / CLI session-source tabs in the sidebar were hardcoded English (`WebUI sessions (N)` / `CLI sessions (N)`) and assigned straight to `textContent`, so they stayed English under every non-English locale. They now route through `t()` with the standard `{0}` count placeholder and are translated across all shipped locales. Thanks @Yularzhi. (#7602, #7580) - **Logging in with password auth enabled no longer lands on a 404.** After a successful login the server redirects to `/sessions`, but `/sessions` (plural) was missing from the SPA-shell route allowlist in `handle_get()` — only `/`, `/index.html`, and `/session/*` (singular) served the app shell — so an authenticated user was met with a `404 not found` immediately after logging in. `/sessions` (and `/sessions?query`) now serves the app shell like the other SPA entry points. Authentication is unchanged: `check_auth()` still runs before the route handler, so an unauthenticated `GET /sessions` still redirects to the login page with `next=/sessions`. The bug was invisible without auth because the SPA handles `/sessions` client-side and the server route was never hit. Thanks @Skawsk59. (#7598) From 4a3c55fdb089bf974a9958c0b00ffa1545c87946 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 16 Sep 2026 16:11:25 -0300 Subject: [PATCH 076/446] 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 077/446] 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 cf62bb87bf0b9a6d19924de36a50a526491344c1 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 16 Sep 2026 12:24:30 -0700 Subject: [PATCH 078/446] fix: send engine=edge from the first-party Edge callers The settings UI writes localStorage immediately but persists the server engine through a debounced save, so a first-party Edge selection could fall through the new server default to a stale ElevenLabs/OpenAI engine. Both WebUI Edge callers now pass engine explicitly; regressions cover both call sites. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CNmJZVV9u6pBhcwVhwkPp6 --- static/boot.js | 2 +- static/ui.js | 2 +- ...t_issue7391_first_party_edge_tts_engine.py | 143 ++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 tests/test_issue7391_first_party_edge_tts_engine.py diff --git a/static/boot.js b/static/boot.js index 526caa24..d6476b60 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1901,7 +1901,7 @@ window.renderTranscript=function(container, messages, opts){ fetch(new URL('api/tts', document.baseURI || location.href).href, { method: 'POST', headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({text: clean, voice, rate, pitch}) + body: JSON.stringify({text: clean, voice, rate, pitch, engine: 'edge'}) }) .then(r => { if(!r.ok) throw new Error('TTS request failed: ' + r.status); diff --git a/static/ui.js b/static/ui.js index 4a631482..bb006d78 100644 --- a/static/ui.js +++ b/static/ui.js @@ -9168,7 +9168,7 @@ function _playEdgeTtsChunked(text, btn){ fetch(new URL('api/tts', document.baseURI || location.href).href, { method:'POST', headers:{'Content-Type':'application/json'}, - body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch}) + body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch, engine:'edge'}) }) .then(function(r){ if(!r.ok){ diff --git a/tests/test_issue7391_first_party_edge_tts_engine.py b/tests/test_issue7391_first_party_edge_tts_engine.py new file mode 100644 index 00000000..6f74e01d --- /dev/null +++ b/tests/test_issue7391_first_party_edge_tts_engine.py @@ -0,0 +1,143 @@ +"""G7394-1: first-party Edge callers must send engine:'edge' explicitly. + +The Settings selector writes localStorage immediately, then saves the server +setting through a 350ms debounced autosave. During that window (or if the +save fails) the WebUI dispatches the Edge branch while the server still holds +ElevenLabs/OpenAI. Omitting engine lets the persisted-engine fallback silently +play the stale provider. + +These tests execute the production ui.js / boot.js callers with a local Edge +selection and assert the /api/tts request body carries the explicit override. +""" + +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] +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +def _run_node(script: str) -> list[dict]: + result = subprocess.run( + [NODE, "-e", script], + cwd=ROOT, + capture_output=True, + text=True, + timeout=30, + ) + 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]) + + +_UI_HARNESS = r""" +const requests = []; +const store = { + 'hermes-tts-engine': 'edge', + 'hermes-tts-voice': 'en-US-AriaNeural', +}; +globalThis.window = globalThis; +globalThis.speechSynthesis = { cancel() {}, getVoices() { return []; } }; +globalThis.localStorage = { + getItem: (k) => Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null, +}; +globalThis.document = { + baseURI: 'http://localhost:8787/', + querySelectorAll: () => [], +}; +globalThis.location = { href: 'http://localhost:8787/' }; +globalThis.fetch = (url, opts) => { + requests.push({ url: String(url), body: JSON.parse(opts.body) }); + return new Promise(() => {}); +}; +let _ttsSpeaking = false; +let _playingEdgeAudio = null; +let _ttsCurrentUtterance = null; +let _ttsChunkQueue = []; +let _ttsChunkIndex = 0; +let _ttsActiveBtn = null; +__UI_FNS__ +const row = { dataset: { rawText: 'Hello from Edge' } }; +const btn = { dataset: { speaking: '0' }, closest: () => row }; +speakMessage(btn); +console.log(JSON.stringify(requests)); +""" + + +_BOOT_HARNESS = r""" +const requests = []; +const store = { + 'hermes-tts-engine': 'edge', + 'hermes-tts-voice': 'en-US-AriaNeural', +}; +globalThis.window = globalThis; +globalThis.localStorage = { + getItem: (k) => Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null, +}; +globalThis.document = { + baseURI: 'http://localhost:8787/', + querySelectorAll: () => [{ dataset: { rawText: 'Hello from Edge' } }], +}; +globalThis.location = { href: 'http://localhost:8787/' }; +globalThis.fetch = (url, opts) => { + requests.push({ url: String(url), body: JSON.parse(opts.body) }); + return new Promise(() => {}); +}; +const S = { session: { session_id: 'sid-edge' } }; +let _voiceModeActive = true; +let _voiceModeThinkingSid = null; +let _ttsSpeaking = false; +let _playingEdgeAudio = null; +function _setState() {} +function _startListening() {} +__BOOT_FNS__ +_speakResponse(); +console.log(JSON.stringify(requests)); +""" + + +def test_play_edge_tts_chunked_sends_explicit_engine_when_server_still_elevenlabs(): + """Local Edge selection (Listen button) must override persisted ElevenLabs.""" + fns = "\n".join( + [ + extract_function(UI_JS, "_stripForTTS"), + extract_function(UI_JS, "_splitForTTS"), + extract_function(UI_JS, "_playEdgeTtsChunked"), + extract_function(UI_JS, "stopTTS"), + extract_function(UI_JS, "speakMessage"), + ] + ) + requests = _run_node(_UI_HARNESS.replace("__UI_FNS__", fns)) + + assert len(requests) == 1 + assert requests[0]["url"].endswith("/api/tts") + assert requests[0]["body"]["engine"] == "edge" + assert requests[0]["body"]["text"] == "Hello from Edge" + assert requests[0]["body"]["voice"] == "en-US-AriaNeural" + + +def test_voice_mode_edge_branch_sends_explicit_engine_when_server_still_openai(): + """Local Edge selection (voice-mode auto-read) must override persisted OpenAI.""" + fns = extract_function(BOOT_JS, "_speakResponse") + requests = _run_node(_BOOT_HARNESS.replace("__BOOT_FNS__", fns)) + + assert len(requests) == 1 + assert requests[0]["url"].endswith("/api/tts") + assert requests[0]["body"]["engine"] == "edge" + assert requests[0]["body"]["text"] == "Hello from Edge" + assert requests[0]["body"]["voice"] == "en-US-AriaNeural" From 9a6a9acffaa7c10caea54c2041ef2bdb1531c6b5 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 16 Sep 2026 18:32:45 -0300 Subject: [PATCH 079/446] 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 38ab9f51fafcbcd265d5205d091d337ac352f17c Mon Sep 17 00:00:00 2001 From: Jaya Megelar Cakrawarty Date: Thu, 17 Sep 2026 11:59:46 +0800 Subject: [PATCH 080/446] fix(renderer): preserve
in table cells and fix bullet list italic collision --- static/ui.js | 14 +++++++++++--- tests/test_renderer_js_behaviour.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/static/ui.js b/static/ui.js index 4a631482..6d47aacd 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7777,7 +7777,15 @@ function renderMd(raw){ s=s.replace(/([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*'); s=s.replace(/([\s\S]*?)<\/i>/gi,(_,t)=>'*'+t+'*'); s=s.replace(/([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`'); + // Protect
inside markdown table rows so converting
to \n does not destroy the table + s=s.split('\n').map(line=>{ + if(/^\s*\|.*\|\s*$/.test(line)){ + return line.replace(//gi,'\x00BR\x00'); + } + return line; + }).join('\n'); s=s.replace(//gi,'\n'); + s=s.replace(/\x00BR\x00/g,'
'); // ── Glued-bold-heading lift (issue #1446) ──────────────────────────────── // LLMs in thinking/reasoning mode frequently emit a "section header" glued // to the end of the previous paragraph with no whitespace, like: @@ -7815,7 +7823,7 @@ function renderMd(raw){ t=t.replace(/`([^`\n]+)`/g,(_,x)=>{_code_stash.push(`${esc(x)}`);return `\x00C${_code_stash.length-1}\x00`;}); t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`${esc(x)}`); t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`${esc(x)}`); - t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`${esc(x)}`); + t=t.replace(/\*([^\s*](?:[^*\n]*?[^\s*])?)\*/g,(_,x)=>`${esc(x)}`); // Strikethrough: ~~text~~ → text t=t.replace(/~~(.+?)~~/g,(_,x)=>`${esc(x)}`); // #487: Image pass — runs while code stash is active so ![x](url) inside @@ -7835,7 +7843,7 @@ function renderMd(raw){ t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]); // Escape any plain text that isn't already wrapped in a tag we produced // by escaping bare < > that are not part of our own tags - const SAFE_INLINE=/^<\/?(strong|em|del|code|a|img)([\s>]|$)/i; + const SAFE_INLINE=/^<\/?(strong|em|del|code|a|img|br)([\s>]|$)/i; t=t.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_INLINE.test(tag)?tag:esc(tag)); return t; } @@ -7845,7 +7853,7 @@ function renderMd(raw){ s=s.replace(/(]*>[\s\S]*?<\/code>)/g,m=>{_ob_stash.push(m);return `\x00O${_ob_stash.length-1}\x00`;}); s=s.replace(/\*\*\*(.+?)\*\*\*/g,(_,t)=>`${esc(t)}`); s=s.replace(/\*\*(.+?)\*\*/g,(_,t)=>`${esc(t)}`); - s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`${esc(t)}`); + s=s.replace(/\*([^\s*](?:[^*\n]*?[^\s*])?)\*/g,(_,t)=>`${esc(t)}`); s=s.replace(/~~(.+?)~~/g,(_,t)=>`${esc(t)}`); s=s.replace(/\x00O(\d+)\x00/g,(_,i)=>_ob_stash[+i]); s=s.replace(/^###### (.+)$/gm,(_,t)=>`
${inlineMd(t)}
`).replace(/^##### (.+)$/gm,(_,t)=>`
${inlineMd(t)}
`).replace(/^#### (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`).replace(/^### (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`).replace(/^## (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`).replace(/^# (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`); diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py index 576da35a..61f919cf 100644 --- a/tests/test_renderer_js_behaviour.py +++ b/tests/test_renderer_js_behaviour.py @@ -898,3 +898,32 @@ class TestBareFileUrlMediaRendering: # Labeled anchors keep the normal link path (routed to /api/media as a link, # not auto-loaded as an ). assert " inside markdown table cells must be preserved and not split cells across lines.""" + + def test_table_cell_with_br_renders_intact(self, driver_path): + src = ( + "| Feature | Description |\n" + "| :--- | :--- |\n" + "| Item 1 | Line one
Line two |\n" + "| Item 2 | Another row |" + ) + out = _render(driver_path, src) + assert "" in out + assert "Line one
Line two" in out or "Line one
Line two" in out + assert "Item 2" in out + assert out.count("") == 3 # 1 header + 2 data rows + + +class TestBulletListItalicCollision: + """Asterisk bullet lists followed by italic words must not collide or escape tags.""" + + def test_bullet_list_with_italic_does_not_swallow_tags(self, driver_path): + src = "* **Label:** Normal text with *italic* words." + out = _render(driver_path, src) + assert "<strong>" not in out + assert "Label:" in out + assert "italic" in out + From eff4900fa8bf3e81a91c860006f287eab9a7cfaa Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 17 Sep 2026 05:14:47 +0000 Subject: [PATCH 081/446] test(issue342): widen renderMd body window so autolink assertion survives added lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structural test_autolink_regex_in_rendermd slices renderMd's body with a hardcoded 15000-char window. This PR's
-in-table protection adds ~315 chars near the top of renderMd, pushing the autolink pattern from offset 14785 (master) to 15100 — just past the window. The autolink regex is unchanged (12 occurrences remain in ui.js); the test window was simply too tight. Widen to 30000. Co-authored-by: JayaMegelar --- tests/test_issue342.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_issue342.py b/tests/test_issue342.py index 1f3cebf9..6ccae250 100644 --- a/tests/test_issue342.py +++ b/tests/test_issue342.py @@ -30,8 +30,11 @@ def test_autolink_regex_in_rendermd(): # Locate the renderMd function body rendermd_start = content.find('function renderMd(raw){') assert rendermd_start != -1, "renderMd function not found in ui.js" - # Find the closing brace after renderMd (look for the autolink pattern within it) - rendermd_body = content[rendermd_start:rendermd_start + 15000] + # Find the closing brace after renderMd (look for the autolink pattern within it). + # Window sized generously: renderMd has grown over time and the autolink pass + # sits well past the first 15k chars now — use a 30k window so a few added lines + # near the top of renderMd don't push the pattern out of a too-tight slice. + rendermd_body = content[rendermd_start:rendermd_start + 30000] assert 'https?:\\/\\/' in rendermd_body, ( "Autolink regex (https?:\\/\\/) not found inside renderMd() body." ) From 4d2b35b9431913d7653912c75e16aeb3dd493b37 Mon Sep 17 00:00:00 2001 From: Yularzhi Date: Thu, 17 Sep 2026 09:20:41 +0300 Subject: [PATCH 082/446] fix(i18n): route Extensions panel through t() and patch 15 locales (#7581) The Extensions settings pane (Gallery / Installed / Diagnostics) was the last large user-facing surface still rendering hardcoded English: - static/index.html carried the section meta, both loading placeholders, the Copy diagnostics button, and the whole Trust-model explainer block as plain HTML with no data-i18n attributes. - static/panels.js built the runtime / diagnostics cards from template literals with 14 untranslated row labels, a separate installed card, asset and warning card titles, sidecar card fields (Origin / Health path / Health URL / Proxy / Proxy path), the Enabled/Disabled and true/false badges, and every empty-state string (None, No warnings, No extension directory is configured., No manifest extensions are installed in the configured bundle., No loopback sidecars declared.) inline in JS. Every one of those call sites now routes through t() and the plain strings live in the locale bundles. data-i18n is used for the static HTML (the same mechanism the other selects already use, per #7582 triage), and esc(t(...)) for the template-literal DOM built in panels.js so no injected text can introduce markup. Locale keys are added to all 15 shipped locale bundles (en, ru, it, ja, es, de, zh, zh-Hant, pt, ko, fr, cs, tr, pl, vi), following the same key-parity contract that the #7602 triage enforced for the sessions_source keys: inserted right after each locale's identity (_label:_speech_) line so the brace-bounded locale extraction still finds the whole block, matching the same pattern upstream used to fix the sessions_source parity. No locale falls back to English at runtime because t() resolves every bundle. tests/test_extensions_settings_panel.py: assertions that used to match the inline English strings now match the corresponding locale keys, and the state_unknown_ids hint asserts both the key is used and the raw English is gone. Verified: - python3 -m pytest tests/ -k "i18n or extension or parity or japanese" -q -> 496 passed - node --check on the three touched static assets -> all valid Fixes #7581. Same surgical slice as #7602 (linked from #7579). --- static/i18n.js | 660 ++++++++++++++++++++++++ static/index.html | 12 +- static/panels.js | 78 +-- tests/test_extensions_settings_panel.py | 15 +- 4 files changed, 714 insertions(+), 51 deletions(-) diff --git a/static/i18n.js b/static/i18n.js index 373e5454..dcca45af 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -748,6 +748,50 @@ const LOCALES = { settings_extensions_gallery_empty: 'No extensions found in the registry.', settings_extensions_gallery_error: 'Failed to load gallery.', ext_gallery_install: 'Install', + settings_extensions_meta: 'Browse, install, and manage WebUI extensions.', + settings_extensions_installed_loading: 'Loading installed extensions…', + settings_extensions_copy_diagnostics: 'Copy diagnostics', + settings_extensions_trust_model_label: 'Trust model', + settings_extensions_trust_model_text: 'Extensions run in the WebUI browser origin and can access the same authenticated APIs as this session. Settings and extension-owned storage are browser-local and not for secrets. Only load trusted local extension directories; disabling an extension takes effect after reload for already-injected assets.', + settings_extensions_runtime_title: 'Extension runtime', + settings_extensions_runtime_status_from: 'Status from /api/extensions/status; toggles persist a local override for installed manifest entries.', + settings_extensions_diag_extension_dir_configured: 'Extension dir configured', + settings_extensions_diag_extension_dir_valid: 'Extension dir valid', + settings_extensions_diag_manifest_configured: 'Manifest configured', + settings_extensions_diag_manifest_loaded: 'Manifest loaded', + settings_extensions_diag_manifest_status: 'Manifest status', + settings_extensions_diag_manifest_entries: 'Manifest entries inspected', + settings_extensions_diag_manifest_scripts: 'Manifest script count', + settings_extensions_diag_manifest_stylesheets: 'Manifest stylesheet count', + settings_extensions_diag_manifest_sidecars: 'Manifest sidecar count', + settings_extensions_diag_final_scripts: 'Final script count', + settings_extensions_diag_final_stylesheets: 'Final stylesheet count', + settings_extensions_diag_loopback_sidecars: 'Loopback sidecar count', + settings_extensions_diag_installed_extensions: 'Installed manifest extensions', + settings_extensions_diag_user_disabled: 'User-disabled extensions', + settings_extensions_installed_section_title: 'Installed manifest extensions', + settings_extensions_installed_section_meta: 'Enable or disable already-present local extensions. Reload WebUI to apply injected asset changes to this browser tab.', + settings_extensions_installed_empty: 'No manifest extensions are installed in the configured bundle.', + settings_extensions_no_dir: 'No extension directory is configured.', + ext_none: 'None', + ext_no_warnings: 'No warnings.', + ext_state_unknown_ids_hint: 'Some saved disabled-extension overrides no longer match the current manifest; re-added extensions with the same id may stay disabled.', + ext_assets_title: 'Final public asset URLs', + ext_assets_meta: 'Same-origin URLs that may be injected into the app shell.', + ext_assets_scripts: 'Scripts', + ext_assets_styles: 'Stylesheets', + ext_warnings_title: 'Sanitized warnings', + ext_warnings_meta: 'Codes and coarse sources only; paths and rejected values are not shown.', + ext_sidecars_title: 'Loopback sidecars', + ext_sidecars_meta: 'Declared local companions; health is checked directly from this browser with WebUI credentials omitted.', + ext_sidecars_none: 'No loopback sidecars declared.', + ext_sidecar_origin: 'Origin', + ext_sidecar_health_path: 'Health path', + ext_sidecar_health_url: 'Health URL', + ext_sidecar_proxy: 'Proxy', + ext_sidecar_proxy_path: 'Proxy path', + ext_status_true: 'true', + ext_status_false: 'false', ext_gallery_installing: 'Installing…', ext_gallery_installed: 'Installed', ext_gallery_uninstall: 'Uninstall', @@ -1793,6 +1837,50 @@ const LOCALES = { offline_stream_waiting: 'Connessione persa. In attesa di aggiornare…', _lang: 'it', _label: 'Italiano', + settings_extensions_meta: 'Sfoglia, installa e gestisci le estensioni WebUI.', + settings_extensions_installed_loading: 'Caricamento estensioni installate…', + settings_extensions_copy_diagnostics: 'Copia diagnosi', + settings_extensions_trust_model_label: 'Modello di fiducia', + 'settings_extensions_trust_model_text': "Le estensioni girano nell'origine browser del WebUI e possono accedere alle stesse API autenticate di questa sessione. Impostazioni e storage delle estensioni vivono solo nel browser e non sono pensati per segreti. Caricare solo directory di estensioni locali fidate; la disabilitazione di un'estensione ha effetto dopo il ricaricamento per gli asset già iniettati.", + settings_extensions_runtime_title: 'Runtime delle estensioni', + settings_extensions_runtime_status_from: 'Stato da /api/extensions/status; i toggle persistono una sovrascrittura locale per le voci manifest installate.', + settings_extensions_diag_extension_dir_configured: 'Directory estensioni configurata', + settings_extensions_diag_extension_dir_valid: 'Directory estensioni valida', + settings_extensions_diag_manifest_configured: 'Manifest configurato', + settings_extensions_diag_manifest_loaded: 'Manifest caricato', + settings_extensions_diag_manifest_status: 'Stato manifest', + settings_extensions_diag_manifest_entries: 'Voci manifest ispezionate', + settings_extensions_diag_manifest_scripts: 'Script nel manifest', + settings_extensions_diag_manifest_stylesheets: 'Stylesheet nel manifest', + settings_extensions_diag_manifest_sidecars: 'Sidecar nel manifest', + settings_extensions_diag_final_scripts: 'Script totali', + settings_extensions_diag_final_stylesheets: 'Stylesheet totali', + settings_extensions_diag_loopback_sidecars: 'Sidecar loopback', + settings_extensions_diag_installed_extensions: 'Estensioni installate', + settings_extensions_diag_user_disabled: 'Disabilitate da utente', + settings_extensions_installed_section_title: 'Estensioni installate', + settings_extensions_installed_section_meta: 'Abilita o disabilita estensioni locali già presenti. Ricarica il WebUI per applicare le modifiche delle risorse iniettate in questa scheda.', + settings_extensions_installed_empty: 'Non ci sono estensioni manifest installate nel bundle configurato.', + settings_extensions_no_dir: 'Directory delle estensioni non configurata.', + ext_none: 'Nessuno', + ext_no_warnings: 'Nessun avviso.', + ext_state_unknown_ids_hint: 'Alcune sovrascritture di estensioni disabilitate non corrispondono più al manifest corrente; le estensioni riaggiunte con lo stesso id possono restare disabilitate.', + ext_assets_title: 'URL risorse pubbliche finali', + ext_assets_meta: "URL same-origin che possono essere iniettati nella shell dell'app.", + ext_assets_scripts: 'Script', + ext_assets_styles: 'Stylesheet', + ext_warnings_title: 'Avvisi filtrati', + ext_warnings_meta: 'Solo codici e fonti generali; percorsi e valori rifiutati non sono mostrati.', + ext_sidecars_title: 'Sidecar', + ext_sidecars_meta: "Compagni locali dichiarati; l'health check viene eseguito direttamente da questo browser senza inviare le credenziali WebUI.", + ext_sidecars_none: 'Nessun sidecar loopback dichiarato.', + ext_sidecar_origin: 'Origine', + ext_sidecar_health_path: 'Percorso health', + ext_sidecar_health_url: 'URL health', + ext_sidecar_proxy: 'Proxy', + ext_sidecar_proxy_path: 'Percorso proxy', + ext_status_true: 'true', + ext_status_false: 'false', sessions_source_webui: 'Sessioni WebUI ({0})', sessions_source_cli: 'Sessioni CLI ({0})', _speech: 'it-IT', @@ -3554,6 +3642,50 @@ const LOCALES = { offline_stream_waiting: '接続が切断されました。更新を待っています…', _lang: 'ja', _label: '日本語', + settings_extensions_meta: 'WebUI拡張機能の閲覧、インストール、管理。', + settings_extensions_installed_loading: 'インストール済み拡張機能を読み込み中…', + settings_extensions_copy_diagnostics: '診断情報をコピー', + settings_extensions_trust_model_label: '信頼モデル', + settings_extensions_trust_model_text: '拡張機能は WebUI ブラウザオリジンで動作し、このセッションと同じ認証済み API にアクセスできます。設定と拡張機能が所有するストレージはブラウザローカルであり、シークレットの保存には使用しません。信頼できるローカル拡張ディレクトリのみ読み込んでください。拡張機能の無効化は再読み込み後に、既に注入されたアセットへ適用されます。', + settings_extensions_runtime_title: '拡張機能ランタイム', + settings_extensions_runtime_status_from: '状態は /api/extensions/status から取得します。トグルの状態は、インストール済みマニフェストエントリに対するローカル上書きとして保存されます。', + settings_extensions_diag_extension_dir_configured: '拡張ディレクトリ設定済み', + settings_extensions_diag_extension_dir_valid: '拡張ディレクトリ有効', + settings_extensions_diag_manifest_configured: 'マニフェスト設定済み', + settings_extensions_diag_manifest_loaded: 'マニフェスト読み込み済み', + settings_extensions_diag_manifest_status: 'マニフェスト状態', + settings_extensions_diag_manifest_entries: '検査済みマニフェストエントリ数', + settings_extensions_diag_manifest_scripts: 'マニフェストスクリプト数', + settings_extensions_diag_manifest_stylesheets: 'マニフェストスタイルシート数', + settings_extensions_diag_manifest_sidecars: 'マニフェストサイドカー数', + settings_extensions_diag_final_scripts: '最終スクリプト数', + settings_extensions_diag_final_stylesheets: '最終スタイルシート数', + settings_extensions_diag_loopback_sidecars: 'ループバックサイドカー数', + settings_extensions_diag_installed_extensions: 'インストール済みマニフェスト拡張数', + settings_extensions_diag_user_disabled: 'ユーザー無効化拡張数', + settings_extensions_installed_section_title: 'インストール済みマニフェスト拡張', + settings_extensions_installed_section_meta: '既に存在するローカル拡張を有効/無効化します。注入済みアセットの変更をこのブラウザタブに反映するには WebUI を再読み込みしてください。', + settings_extensions_installed_empty: '設定されたバンドルにインストール済みマニフェスト拡張はありません。', + settings_extensions_no_dir: '拡張ディレクトリが設定されていません。', + ext_none: 'なし', + ext_no_warnings: '警告はありません。', + ext_state_unknown_ids_hint: '保存済みの無効化拡張設定の一部が現在のマニフェストと一致しなくなりました。同じ ID で再追加された拡張は無効のままになることがあります。', + ext_assets_title: '最終パブリックアセット URL', + ext_assets_meta: 'アプリシェルに注入される可能性のある同一オリジン URL。', + ext_assets_scripts: 'スクリプト', + ext_assets_styles: 'スタイルシート', + ext_warnings_title: '整形済み警告', + ext_warnings_meta: 'コードと大まかなソースのみ表示。パスと拒否された値は表示されません。', + ext_sidecars_title: 'ループバックサイドカー', + ext_sidecars_meta: '宣言されたローカル コンパニオン。WebUI の資格情報を送らずに、このブラウザから直接ヘルスチェックします。', + ext_sidecars_none: '宣言されたループバックサイドカーはありません。', + ext_sidecar_origin: 'オリジン', + ext_sidecar_health_path: 'ヘルスパス', + ext_sidecar_health_url: 'ヘルス URL', + ext_sidecar_proxy: 'プロキシ', + ext_sidecar_proxy_path: 'プロキシパス', + ext_status_true: 'true', + ext_status_false: 'false', sessions_source_webui: 'WebUIセッション ({0})', sessions_source_cli: 'CLIセッション ({0})', _speech: 'ja-JP', @@ -5343,6 +5475,50 @@ const LOCALES = { voice_mode_active: 'Голосовой режим включён', voice_mode_off: 'Голосовой режим выключен', session_imported: 'Сеанс импортирован', + settings_extensions_meta: 'Обзор, установка и управление расширениями WebUI.', + settings_extensions_installed_loading: 'Загрузка установленных расширений…', + settings_extensions_copy_diagnostics: 'Копировать диагностику', + settings_extensions_trust_model_label: 'Модель доверия', + settings_extensions_trust_model_text: 'Расширения выполняются в браузерном origin WebUI и имеют доступ к тем же аутентифицированным API, что и эта сессия. Настройки и хранилище расширений живут только в браузере и не предназначены для секретов. Загружайте только доверенные локальные каталоги расширений; отключение расширения вступает в силу после перезагрузки для уже внедрённых ресурсов.', + settings_extensions_runtime_title: 'Среда выполнения расширений', + settings_extensions_runtime_status_from: 'Статус из /api/extensions/status; переключатели сохраняют локальное переопределение для установленных манифестных записей.', + settings_extensions_diag_extension_dir_configured: 'Каталог расширений задан', + settings_extensions_diag_extension_dir_valid: 'Каталог расширений валиден', + settings_extensions_diag_manifest_configured: 'Манифест задан', + settings_extensions_diag_manifest_loaded: 'Манифест загружен', + settings_extensions_diag_manifest_status: 'Статус манифеста', + settings_extensions_diag_manifest_entries: 'Записей манифеста проверено', + settings_extensions_diag_manifest_scripts: 'Скриптов в манифесте', + settings_extensions_diag_manifest_stylesheets: 'Стилей в манифесте', + settings_extensions_diag_manifest_sidecars: 'Сайдкаров в манифесте', + settings_extensions_diag_final_scripts: 'Скриптов в итоге', + settings_extensions_diag_final_stylesheets: 'Стилей в итоге', + settings_extensions_diag_loopback_sidecars: 'Сайдкаров loopback', + settings_extensions_diag_installed_extensions: 'Установленных расширений', + settings_extensions_diag_user_disabled: 'Отключённых пользователем', + settings_extensions_installed_section_title: 'Установленные расширения', + settings_extensions_installed_section_meta: 'Включайте или отключайте уже присутствующие локальные расширения. Перезагрузите WebUI, чтобы применить изменения внедрённых ресурсов в этой вкладке.', + settings_extensions_installed_empty: 'В настроенном бандле нет установленных манифестных расширений.', + settings_extensions_no_dir: 'Каталог расширений не настроен.', + ext_none: 'Нет', + ext_no_warnings: 'Предупреждений нет.', + ext_state_unknown_ids_hint: 'Некоторые сохранённые переопределения отключённых расширений больше не совпадают с текущим манифестом; повторно добавленные расширения с тем же id могут остаться отключёнными.', + ext_assets_title: 'Итоговые публичные URL ресурсов', + ext_assets_meta: 'Same-origin URL, которые могут быть внедрены в оболочку приложения.', + ext_assets_scripts: 'Скрипты', + ext_assets_styles: 'Стили', + ext_warnings_title: 'Отфильтрованные предупреждения', + ext_warnings_meta: 'Только коды и общие источники; пути и отклонённые значения не показываются.', + ext_sidecars_title: 'Сайдкары расширений', + ext_sidecars_meta: 'Объявленные локальные компаньоны; проверка состояния выполняется прямо этим браузером без отправки учётных данных WebUI.', + ext_sidecars_none: 'Объявленных loopback-сайдкаров нет.', + ext_sidecar_origin: 'Origin', + ext_sidecar_health_path: 'Путь проверки', + ext_sidecar_health_url: 'URL проверки', + ext_sidecar_proxy: 'Прокси', + ext_sidecar_proxy_path: 'Путь прокси', + ext_status_true: 'да', + ext_status_false: 'нет', sessions_source_webui: 'Сеансы WebUI ({0})', sessions_source_cli: 'Сеансы CLI ({0})', import_failed: 'Не удалось импортировать: ', @@ -7060,6 +7236,50 @@ const LOCALES = { offline_stream_waiting: 'Conexión perdida. Esperando para actualizar…', _lang: 'es', _label: 'Español', + settings_extensions_meta: 'Explora, instala y gestiona las extensiones WebUI.', + settings_extensions_installed_loading: 'Cargando extensiones instaladas…', + settings_extensions_copy_diagnostics: 'Copiar diagnóstico', + settings_extensions_trust_model_label: 'Modelo de confianza', + settings_extensions_trust_model_text: 'Las extensiones se ejecutan en el origen de navegador del WebUI y pueden acceder a las mismas API autenticadas que esta sesión. Los ajustes y el almacenamiento de las extensiones viven solo en el navegador y no son para secretos. Carga solo directorios locales de extensiones de confianza; deshabilitar una extensión surte efecto tras recargar para los recursos ya inyectados.', + settings_extensions_runtime_title: 'Runtime de extensiones', + settings_extensions_runtime_status_from: 'Estado de /api/extensions/status; los interruptores persisten un reemplazo local para las entradas de manifest instaladas.', + settings_extensions_diag_extension_dir_configured: 'Directorio de extensiones configurado', + settings_extensions_diag_extension_dir_valid: 'Directorio de extensiones válido', + settings_extensions_diag_manifest_configured: 'Manifest configurado', + settings_extensions_diag_manifest_loaded: 'Manifest cargado', + settings_extensions_diag_manifest_status: 'Estado del manifest', + settings_extensions_diag_manifest_entries: 'Entradas de manifest inspeccionadas', + settings_extensions_diag_manifest_scripts: 'Scripts en manifest', + settings_extensions_diag_manifest_stylesheets: 'Stylesheets en manifest', + settings_extensions_diag_manifest_sidecars: 'Sidecars en manifest', + settings_extensions_diag_final_scripts: 'Scripts finales', + settings_extensions_diag_final_stylesheets: 'Stylesheets finales', + settings_extensions_diag_loopback_sidecars: 'Sidecars loopback', + settings_extensions_diag_installed_extensions: 'Extensiones instaladas', + settings_extensions_diag_user_disabled: 'Deshabilitadas por usuario', + settings_extensions_installed_section_title: 'Extensiones instaladas', + settings_extensions_installed_section_meta: 'Habilita o deshabilita extensiones locales ya presentes. Recarga el WebUI para aplicar los cambios de recursos inyectados en esta pestaña.', + settings_extensions_installed_empty: 'No hay extensiones manifest instaladas en el paquete configurado.', + settings_extensions_no_dir: 'No se ha configurado directorio de extensiones.', + ext_none: 'Ninguno', + ext_no_warnings: 'Sin avisos.', + ext_state_unknown_ids_hint: 'Algunas sobrescrituras de extensiones deshabilitadas ya no coinciden con el manifest actual; las extensiones re-agregadas con el mismo id pueden permanecer deshabilitadas.', + ext_assets_title: 'URLs finales de recursos públicos', + ext_assets_meta: 'URL same-origin que pueden inyectarse en el shell de la aplicación.', + ext_assets_scripts: 'Scripts', + ext_assets_styles: 'Stylesheets', + ext_warnings_title: 'Avisos filtrados', + ext_warnings_meta: 'Solo códigos y fuentes generales; las rutas y los valores rechazados no se muestran.', + ext_sidecars_title: 'Sidecars', + ext_sidecars_meta: 'Compañeros locales declarados; la verificación de estado se hace directamente desde este navegador sin credenciales WebUI.', + ext_sidecars_none: 'No hay sidecars loopback declarados.', + ext_sidecar_origin: 'Origen', + ext_sidecar_health_path: 'Ruta de health', + ext_sidecar_health_url: 'URL de health', + ext_sidecar_proxy: 'Proxy', + ext_sidecar_proxy_path: 'Ruta de proxy', + ext_status_true: 'true', + ext_status_false: 'false', sessions_source_webui: 'Sesiones WebUI ({0})', sessions_source_cli: 'Sesiones CLI ({0})', _speech: 'es-ES', @@ -8767,6 +8987,50 @@ const LOCALES = { offline_stream_waiting: 'Verbindung verloren. Warte auf Aktualisierung…', _lang: 'de', _label: 'Deutsch', + settings_extensions_meta: 'WebUI-Erweiterungen durchsehen, installieren und verwalten.', + settings_extensions_installed_loading: 'Installierte Erweiterungen werden geladen…', + settings_extensions_copy_diagnostics: 'Diagnose kopieren', + settings_extensions_trust_model_label: 'Vertrauensmodell', + settings_extensions_trust_model_text: 'Erweiterungen laufen im Browser-Origin des WebUI und haben Zugriff auf dieselben authentifizierten APIs wie diese Sitzung. Einstellungen und Erweiterungs-Speicher bleiben im Browser und sind nicht für Geheimnisse gedacht. Nur vertrauenswürdige lokale Erweiterungsverzeichnisse laden; das Deaktivieren einer Erweiterung greift nach dem Neuladen für bereits injizierte Assets.', + settings_extensions_runtime_title: 'Erweiterungs-Runtime', + settings_extensions_runtime_status_from: 'Status aus /api/extensions/status; Schalter speichern eine lokale Überschreibung für installierte Manifest-Einträge.', + settings_extensions_diag_extension_dir_configured: 'Erweiterungsverzeichnis konfiguriert', + settings_extensions_diag_extension_dir_valid: 'Erweiterungsverzeichnis gültig', + settings_extensions_diag_manifest_configured: 'Manifest konfiguriert', + settings_extensions_diag_manifest_loaded: 'Manifest geladen', + settings_extensions_diag_manifest_status: 'Manifest-Status', + settings_extensions_diag_manifest_entries: 'Geprüfte Manifest-Einträge', + settings_extensions_diag_manifest_scripts: 'Skripte im Manifest', + settings_extensions_diag_manifest_stylesheets: 'Stylesheets im Manifest', + settings_extensions_diag_manifest_sidecars: 'Sidecars im Manifest', + settings_extensions_diag_final_scripts: 'Skripte insgesamt', + settings_extensions_diag_final_stylesheets: 'Stylesheets insgesamt', + settings_extensions_diag_loopback_sidecars: 'Loopback-Sidecars', + settings_extensions_diag_installed_extensions: 'Installierte Erweiterungen', + settings_extensions_diag_user_disabled: 'Vom Nutzer deaktiviert', + settings_extensions_installed_section_title: 'Installierte Erweiterungen', + settings_extensions_installed_section_meta: 'Bereits vorhandene lokale Erweiterungen ein- oder ausschalten. WebUI neu laden, um Änderungen der injizierten Assets in diesem Tab anzuwenden.', + settings_extensions_installed_empty: 'Im konfigurierten Bundle sind keine installierten Manifest-Erweiterungen vorhanden.', + settings_extensions_no_dir: 'Kein Erweiterungsverzeichnis konfiguriert.', + ext_none: 'Keine', + ext_no_warnings: 'Keine Warnungen.', + ext_state_unknown_ids_hint: 'Einige gespeicherte Overrides für deaktivierte Erweiterungen passen nicht mehr zum aktuellen Manifest; erneut hinzugefügte Erweiterungen mit derselben ID können deaktiviert bleiben.', + ext_assets_title: 'Finale öffentliche Asset-URLs', + ext_assets_meta: 'Same-Origin-URLs, die in die App-Shell injiziert werden können.', + ext_assets_scripts: 'Skripte', + ext_assets_styles: 'Stylesheets', + ext_warnings_title: 'Gefilterte Warnungen', + ext_warnings_meta: 'Nur Codes und grobe Quellen; Pfade und abgelehnte Werte werden nicht angezeigt.', + ext_sidecars_title: 'Sidecars', + ext_sidecars_meta: 'Deklarierte lokale Begleiter; die Prüfung erfolgt direkt aus diesem Browser ohne WebUI-Anmeldeinformationen.', + ext_sidecars_none: 'Keine Loopback-Sidecars deklariert.', + ext_sidecar_origin: 'Origin', + ext_sidecar_health_path: 'Health-Pfad', + ext_sidecar_health_url: 'Health-URL', + ext_sidecar_proxy: 'Proxy', + ext_sidecar_proxy_path: 'Proxy-Pfad', + ext_status_true: 'true', + ext_status_false: 'false', sessions_source_webui: 'WebUI-Sitzungen ({0})', sessions_source_cli: 'CLI-Sitzungen ({0})', _speech: 'de-DE', @@ -10468,6 +10732,50 @@ const LOCALES = { offline_stream_waiting: '连接已断开。正在等待刷新…', _lang: 'zh', _label: '\u7b80\u4f53\u4e2d\u6587', + settings_extensions_meta: '浏览、安装并管理 WebUI 扩展。', + settings_extensions_installed_loading: '正在加载已安装的扩展…', + settings_extensions_copy_diagnostics: '复制诊断', + settings_extensions_trust_model_label: '信任模型', + settings_extensions_trust_model_text: '扩展运行在 WebUI 浏览器源中,并可以访问与本会话相同的已认证 API。设置与扩展自有的存储仅存在于浏览器内,不应存放机密。只加载可信的本地扩展目录;禁用扩展会在重新加载后对已注入的资源生效。', + settings_extensions_runtime_title: '扩展运行时', + settings_extensions_runtime_status_from: '状态来自 /api/extensions/status;开关会为已安装的 manifest 项保存本地覆盖。', + settings_extensions_diag_extension_dir_configured: '已配置扩展目录', + settings_extensions_diag_extension_dir_valid: '扩展目录有效', + settings_extensions_diag_manifest_configured: '已配置 manifest', + settings_extensions_diag_manifest_loaded: '已加载 manifest', + settings_extensions_diag_manifest_status: 'manifest 状态', + settings_extensions_diag_manifest_entries: '已检查的 manifest 条目', + settings_extensions_diag_manifest_scripts: 'manifest 脚本数', + settings_extensions_diag_manifest_stylesheets: 'manifest 样式表数', + settings_extensions_diag_manifest_sidecars: 'manifest sidecar 数', + settings_extensions_diag_final_scripts: '最终脚本数', + settings_extensions_diag_final_stylesheets: '最终样式表数', + settings_extensions_diag_loopback_sidecars: 'Loopback sidecar 数', + settings_extensions_diag_installed_extensions: '已安装扩展数', + settings_extensions_diag_user_disabled: '用户禁用数', + settings_extensions_installed_section_title: '已安装的 manifest 扩展', + settings_extensions_installed_section_meta: '启用或禁用已存在的本地扩展。重新加载 WebUI 以把已注入资源的变化应用到本浏览器标签。', + settings_extensions_installed_empty: '已配置的 bundle 中未安装任何 manifest 扩展。', + settings_extensions_no_dir: '未配置扩展目录。', + ext_none: '无', + ext_no_warnings: '没有警告。', + ext_state_unknown_ids_hint: '某些保存的已禁用扩展覆盖不再匹配当前 manifest;以相同 ID 重新添加的扩展可能仍保持禁用。', + ext_assets_title: '最终公共资源 URL', + ext_assets_meta: '可注入应用外壳的同源 URL。', + ext_assets_scripts: '脚本', + ext_assets_styles: '样式表', + ext_warnings_title: '已过滤的警告', + ext_warnings_meta: '仅显示代码和大致来源;不会显示路径或被拒绝的具体值。', + ext_sidecars_title: 'Sidecar', + ext_sidecars_meta: '已声明的本地伴侣;此浏览器会直接检查其健康状态且不带 WebUI 凭据。', + ext_sidecars_none: '没有声明任何 loopback sidecar。', + ext_sidecar_origin: '源', + ext_sidecar_health_path: '健康路径', + ext_sidecar_health_url: '健康 URL', + ext_sidecar_proxy: '代理', + ext_sidecar_proxy_path: '代理路径', + ext_status_true: '是', + ext_status_false: '否', sessions_source_webui: 'WebUI 会话 ({0})', sessions_source_cli: 'CLI 会话 ({0})', _speech: 'zh-CN', @@ -12164,6 +12472,50 @@ const LOCALES = { offline_stream_waiting: '連線中斷。等待重新整理…', _lang: 'zh-Hant', _label: '繁體中文', + settings_extensions_meta: '瀏覽、安裝並管理 WebUI 擴充功能。', + settings_extensions_installed_loading: '正在載入已安裝的擴充功能…', + settings_extensions_copy_diagnostics: '複製診斷', + settings_extensions_trust_model_label: '信任模型', + settings_extensions_trust_model_text: '擴充功能執行在 WebUI 瀏覽器源,可存取與本會話相同的已驗證 API。設定與擴充功能自有的儲存僅存在於瀏覽器內,不應存放機密。僅載入可信的本地擴充功能目錄;停用擴充功能會在重新載入後對已注入的資源生效。', + settings_extensions_runtime_title: '擴充功能執行時', + settings_extensions_runtime_status_from: '狀態來自 /api/extensions/status;切換會為已安裝的 manifest 項保存本地覆蓋。', + settings_extensions_diag_extension_dir_configured: '已設定擴充功能目錄', + settings_extensions_diag_extension_dir_valid: '擴充功能目錄有效', + settings_extensions_diag_manifest_configured: '已設定 manifest', + settings_extensions_diag_manifest_loaded: '已載入 manifest', + settings_extensions_diag_manifest_status: 'manifest 狀態', + settings_extensions_diag_manifest_entries: '已檢查的 manifest 項目', + settings_extensions_diag_manifest_scripts: 'manifest 腳本數', + settings_extensions_diag_manifest_stylesheets: 'manifest 樣式表數', + settings_extensions_diag_manifest_sidecars: 'manifest sidecar 數', + settings_extensions_diag_final_scripts: '最終腳本數', + settings_extensions_diag_final_stylesheets: '最終樣式表數', + settings_extensions_diag_loopback_sidecars: 'Loopback sidecar 數', + settings_extensions_diag_installed_extensions: '已安裝擴充功能數', + settings_extensions_diag_user_disabled: '使用者停用數', + settings_extensions_installed_section_title: '已安裝的 manifest 擴充功能', + settings_extensions_installed_section_meta: '啟用或停用既有的本機擴充功能。重新載入 WebUI 以將注入資源的變更套用到此瀏覽器分頁。', + settings_extensions_installed_empty: '已設定的 bundle 中未安裝任何 manifest 擴充功能。', + settings_extensions_no_dir: '未設定擴充功能目錄。', + ext_none: '無', + ext_no_warnings: '沒有警告。', + ext_state_unknown_ids_hint: '某些已保存的停用擴充功能覆蓋不再符合目前 manifest;以相同 ID 重新加入的擴充功能可能仍保持停用。', + ext_assets_title: '最終公開資源 URL', + ext_assets_meta: '可注入應用程式外殼的 same-origin URL。', + ext_assets_scripts: '腳本', + ext_assets_styles: '樣式表', + ext_warnings_title: '已過濾的警告', + ext_warnings_meta: '僅顯示代碼與粗略來源;路徑與被拒絕的具體值不會顯示。', + ext_sidecars_title: 'Sidecar', + ext_sidecars_meta: '已宣告的本機伴生程序;健康檢查由這個瀏覽器直接發送,不會附帶 WebUI 憑證。', + ext_sidecars_none: '沒有宣告任何 loopback sidecar。', + ext_sidecar_origin: '源', + ext_sidecar_health_path: '健康路徑', + ext_sidecar_health_url: '健康 URL', + ext_sidecar_proxy: '代理', + ext_sidecar_proxy_path: '代理路徑', + ext_status_true: '是', + ext_status_false: '否', sessions_source_webui: 'WebUI 對話 ({0})', sessions_source_cli: 'CLI 對話 ({0})', _speech: 'zh-TW', @@ -13927,6 +14279,50 @@ const LOCALES = { offline_stream_waiting: 'Conexão perdida. Aguardando para atualizar…', _lang: 'pt', _label: 'Português', + settings_extensions_meta: 'Explorar, instalar e gerenciar extensões WebUI.', + settings_extensions_installed_loading: 'Carregando extensões instaladas…', + settings_extensions_copy_diagnostics: 'Copiar diagnósticos', + settings_extensions_trust_model_label: 'Modelo de confiança', + settings_extensions_trust_model_text: 'As extensões rodam no browser origin do WebUI e podem acessar as mesmas APIs autenticadas desta sessão. Configurações e armazenamento das extensões vivem apenas no navegador e não são para segredos. Carigue somente diretórios locais de extensões confiáveis; desabilitar uma extensão só tem efeito após recarregar para assets já injetados.', + settings_extensions_runtime_title: 'Runtime de extensões', + settings_extensions_runtime_status_from: 'Status de /api/extensions/status; os toggles persistem um override local para entradas de manifest instaladas.', + settings_extensions_diag_extension_dir_configured: 'Diretório de extensões configurado', + settings_extensions_diag_extension_dir_valid: 'Diretório de extensões válido', + settings_extensions_diag_manifest_configured: 'Manifest configurado', + settings_extensions_diag_manifest_loaded: 'Manifest carregado', + settings_extensions_diag_manifest_status: 'Status do manifest', + settings_extensions_diag_manifest_entries: 'Entradas de manifest inspecionadas', + settings_extensions_diag_manifest_scripts: 'Scripts no manifest', + settings_extensions_diag_manifest_stylesheets: 'Stylesheets no manifest', + settings_extensions_diag_manifest_sidecars: 'Sidecars no manifest', + settings_extensions_diag_final_scripts: 'Scripts finais', + settings_extensions_diag_final_stylesheets: 'Stylesheets finais', + settings_extensions_diag_loopback_sidecars: 'Sidecars loopback', + settings_extensions_diag_installed_extensions: 'Extensões instaladas', + settings_extensions_diag_user_disabled: 'Desabilitadas pelo usuário', + settings_extensions_installed_section_title: 'Extensões instaladas', + settings_extensions_installed_section_meta: 'Habilita ou desabilita extensões locais já presentes. Recarregue o WebUI para aplicar as alterações de assets injetados nesta aba do navegador.', + settings_extensions_installed_empty: 'Não há extensões manifest instaladas no bundle configurado.', + settings_extensions_no_dir: 'Nenhum diretório de extensões configurado.', + ext_none: 'Nenhum', + ext_no_warnings: 'Nenhum aviso.', + ext_state_unknown_ids_hint: 'Algumas overrides salvas de extensões desabilitadas não correspondem mais ao manifest atual; extensões re-adicionadas com o mesmo id podem permanecer desabilitadas.', + ext_assets_title: 'URLs finais de assets públicos', + ext_assets_meta: 'URLs same-origin que podem ser injetados no app shell.', + ext_assets_scripts: 'Scripts', + ext_assets_styles: 'Stylesheets', + ext_warnings_title: 'Avisos filtrados', + ext_warnings_meta: 'Apenas códigos e fontes gerais; caminhos e valores rejeitados não são mostrados.', + ext_sidecars_title: 'Sidecars', + ext_sidecars_meta: 'Companheiros locais declarados; a checagem de saúde é feita diretamente deste navegador sem credenciais do WebUI.', + ext_sidecars_none: 'Nenhum sidecar loopback declarado.', + ext_sidecar_origin: 'Origem', + ext_sidecar_health_path: 'Caminho de health', + ext_sidecar_health_url: 'URL de health', + ext_sidecar_proxy: 'Proxy', + ext_sidecar_proxy_path: 'Caminho do proxy', + ext_status_true: 'true', + ext_status_false: 'false', sessions_source_webui: 'Sessões WebUI ({0})', sessions_source_cli: 'Sessões CLI ({0})', _speech: 'pt-BR', @@ -15507,6 +15903,50 @@ const LOCALES = { offline_stream_waiting: '연결이 끊겼습니다. 새로고침을 기다리는 중…', _lang: 'ko', _label: '한국어', + settings_extensions_meta: 'WebUI 확장 기능을 탐색, 설치하고 관리합니다.', + settings_extensions_installed_loading: '설치된 확장 기능 불러오는 중…', + settings_extensions_copy_diagnostics: '진단 정보 복사', + settings_extensions_trust_model_label: '신뢰 모델', + settings_extensions_trust_model_text: '확장 기능은 WebUI 브라우저 origin에서 실행되며 이 세션과 동일한 인증된 API에 접근할 수 있습니다. 설정과 확장 기능 소유 저장소는 브라우저 로컬에만 존재하며 비밀 정보용이 아닙니다. 신뢰할 수 있는 로컬 확장 디렉터리만 불러오세요. 확장 기능 비활성화는 새로고침 후 이미 삽입된 자산에 적용됩니다.', + settings_extensions_runtime_title: '확장 런타임', + settings_extensions_runtime_status_from: '상태는 /api/extensions/status에서 가져오며, 토글은 설치된 manifest 항목에 대한 로컬 재정의를 유지합니다.', + settings_extensions_diag_extension_dir_configured: '확장 디렉터리 설정됨', + settings_extensions_diag_extension_dir_valid: '확장 디렉터리 유효함', + settings_extensions_diag_manifest_configured: '매니페스트 설정됨', + settings_extensions_diag_manifest_loaded: '매니페스트 로드됨', + settings_extensions_diag_manifest_status: '매니페스트 상태', + settings_extensions_diag_manifest_entries: '검사한 매니페스트 항목 수', + settings_extensions_diag_manifest_scripts: '매니페스트 스크립트 수', + settings_extensions_diag_manifest_stylesheets: '매니페스트 스타일시트 수', + settings_extensions_diag_manifest_sidecars: '매니페스트 사이드카 수', + settings_extensions_diag_final_scripts: '최종 스크립트 수', + settings_extensions_diag_final_stylesheets: '최종 스타일시트 수', + settings_extensions_diag_loopback_sidecars: '루프백 사이드카 수', + settings_extensions_diag_installed_extensions: '설치된 확장 수', + settings_extensions_diag_user_disabled: '사용자가 비활성화한 확장 수', + settings_extensions_installed_section_title: '설치된 매니페스트 확장', + settings_extensions_installed_section_meta: '이미 존재하는 로컬 확장을 사용하거나 중지합니다. 이 브라우저 탭에 삽입된 자산 변경을 적용하려면 WebUI를 다시 불러오세요.', + settings_extensions_installed_empty: '구성된 번들에 설치된 매니페스트 확장이 없습니다.', + settings_extensions_no_dir: '확장 디렉터리가 설정되어 있지 않습니다.', + ext_none: '없음', + ext_no_warnings: '경고가 없습니다.', + ext_state_unknown_ids_hint: '일부 저장된 비활성화 확장 오버라이드가 현재 매니페스트와 더 이상 일치하지 않습니다. 동일한 ID로 다시 추가한 확장은 비활성화 상태로 남을 수 있습니다.', + ext_assets_title: '최종 공개 자산 URL', + ext_assets_meta: '앱 셸에 삽입될 수 있는 same-origin URL.', + ext_assets_scripts: '스크립트', + ext_assets_styles: '스타일시트', + ext_warnings_title: '필터링된 경고', + ext_warnings_meta: '코드와 대략적 출처만 표시하며, 경로와 거부된 값은 보여주지 않습니다.', + ext_sidecars_title: '사이드카', + ext_sidecars_meta: '선언된 로컬 컴패니언. 이 브라우저가 WebUI 자격 증명 없이 직접 상태를 확인합니다.', + ext_sidecars_none: '선언된 루프백 사이드카가 없습니다.', + ext_sidecar_origin: 'Origin', + ext_sidecar_health_path: '헬스 경로', + ext_sidecar_health_url: '헬스 URL', + ext_sidecar_proxy: '프록시', + ext_sidecar_proxy_path: '프록시 경로', + ext_status_true: 'true', + ext_status_false: 'false', sessions_source_webui: 'WebUI 세션 ({0})', sessions_source_cli: 'CLI 세션 ({0})', _speech: 'ko-KR', @@ -17258,6 +17698,50 @@ const LOCALES = { offline_stream_waiting: 'Connexion perdue. En attente de rafraîchissement\u2026', _lang: 'fr', _label: 'Français', + settings_extensions_meta: 'Parcourir, installer et gérer les extensions WebUI.', + settings_extensions_installed_loading: 'Chargement des extensions installées…', + settings_extensions_copy_diagnostics: 'Copier le diagnostic', + settings_extensions_trust_model_label: 'Modèle de confiance', + settings_extensions_trust_model_text: 'Les extensions s’exécutent dans l’origine navigateur du WebUI et peuvent accéder aux mêmes API authentifiées que cette session. Les réglages et le stockage des extensions restent locaux au navigateur et ne sont pas destinés aux secrets. Chargez uniquement des répertoires d’extensions locaux de confiance ; la désactivation d’une extension prend effet après rechargement pour les ressources déjà injectées.', + settings_extensions_runtime_title: 'Runtime des extensions', + settings_extensions_runtime_status_from: 'Statut depuis /api/extensions/status ; les bascules enregistrent une surcharge locale pour les entrées de manifest installées.', + settings_extensions_diag_extension_dir_configured: 'Répertoire d’extensions configuré', + settings_extensions_diag_extension_dir_valid: 'Répertoire d’extensions valide', + settings_extensions_diag_manifest_configured: 'Manifest configuré', + settings_extensions_diag_manifest_loaded: 'Manifest chargé', + settings_extensions_diag_manifest_status: 'Statut du manifest', + settings_extensions_diag_manifest_entries: 'Entrées de manifest inspectées', + settings_extensions_diag_manifest_scripts: 'Scripts dans le manifest', + settings_extensions_diag_manifest_stylesheets: 'Feuilles de style dans le manifest', + settings_extensions_diag_manifest_sidecars: 'Sidecars dans le manifest', + settings_extensions_diag_final_scripts: 'Scripts au total', + settings_extensions_diag_final_stylesheets: 'Feuilles de style au total', + settings_extensions_diag_loopback_sidecars: 'Sidecars loopback', + settings_extensions_diag_installed_extensions: 'Extensions installées', + settings_extensions_diag_user_disabled: 'Désactivées par l’utilisateur', + settings_extensions_installed_section_title: 'Extensions installées', + settings_extensions_installed_section_meta: 'Activez ou désactivez les extensions locales déjà présentes. Rechargez le WebUI pour appliquer les modifications des ressources injectées dans cet onglet.', + settings_extensions_installed_empty: 'Aucune extension manifest installée dans le bundle configuré.', + settings_extensions_no_dir: 'Aucun répertoire d’extensions configuré.', + ext_none: 'Aucun', + ext_no_warnings: 'Aucun avertissement.', + ext_state_unknown_ids_hint: 'Certaines surcharges d’extensions désactivées ne correspondent plus au manifest actuel ; les extensions ré-ajoutées avec le même id peuvent rester désactivées.', + ext_assets_title: 'URL publiques finales des ressources', + ext_assets_meta: 'URL same-origin qui peuvent être injectées dans le shell de l’application.', + ext_assets_scripts: 'Scripts', + ext_assets_styles: 'Feuilles de style', + ext_warnings_title: 'Avertissements filtrés', + ext_warnings_meta: 'Uniquement des codes et des sources générales ; les chemins et les valeurs rejetées ne sont pas affichés.', + ext_sidecars_title: 'Sidecars', + ext_sidecars_meta: 'Compagnons locaux déclarés ; la vérification d’état est faite directement par ce navigateur sans identifiants WebUI.', + ext_sidecars_none: 'Aucun sidecar loopback déclaré.', + ext_sidecar_origin: 'Origine', + ext_sidecar_health_path: 'Chemin de santé', + ext_sidecar_health_url: 'URL de santé', + ext_sidecar_proxy: 'Proxy', + ext_sidecar_proxy_path: 'Chemin du proxy', + ext_status_true: 'true', + ext_status_false: 'false', sessions_source_webui: 'Sessions WebUI ({0})', sessions_source_cli: 'Sessions CLI ({0})', _speech: 'fr-FR', @@ -18988,6 +19472,50 @@ const LOCALES = { sessions_source_cli: 'Relace CLI ({0})', _lang: 'cs', _speech: 'cs-CZ', + settings_extensions_meta: 'Procházet, instalovat a spravovat rozšíření WebUI.', + settings_extensions_installed_loading: 'Načítání nainstalovaných rozšíření…', + settings_extensions_copy_diagnostics: 'Kopírovat diagnostiku', + settings_extensions_trust_model_label: 'Model důvěry', + settings_extensions_trust_model_text: 'Rozšíření běží v prohlížečovém originu WebUI a mají přístup ke stejným autentizovaným API jako tato relace. Nastavení a úložisko rozšíření zůstává v prohlížeči a není určeno pro tajemství. Načítajte jen důvěryhodné lokální adresáře rozšíření; vypnutí rozšíření se projeví až po reloadu pro již vložené assety.', + settings_extensions_runtime_title: 'Runtime rozšíření', + settings_extensions_runtime_status_from: 'Stav z /api/extensions/status; přepínače ukládají lokální override pro nainstalované manifest položky.', + settings_extensions_diag_extension_dir_configured: 'Adresář rozšíření nastaven', + settings_extensions_diag_extension_dir_valid: 'Adresář rozšíření je platný', + settings_extensions_diag_manifest_configured: 'Manifest nastaven', + settings_extensions_diag_manifest_loaded: 'Manifest načten', + settings_extensions_diag_manifest_status: 'Stav manifestu', + settings_extensions_diag_manifest_entries: 'Zkontrolované manifest položky', + settings_extensions_diag_manifest_scripts: 'Skripty v manifestu', + settings_extensions_diag_manifest_stylesheets: 'StyleSheety v manifestu', + settings_extensions_diag_manifest_sidecars: 'Sidecary v manifestu', + settings_extensions_diag_final_scripts: 'Skripty celkem', + settings_extensions_diag_final_stylesheets: 'StyleSheety celkem', + settings_extensions_diag_loopback_sidecars: 'Loopback sidecary', + settings_extensions_diag_installed_extensions: 'Nainstalovaná rozšíření', + settings_extensions_diag_user_disabled: 'Vypnutá uživatelem', + settings_extensions_installed_section_title: 'Nainstalovaná rozšíření', + settings_extensions_installed_section_meta: 'Zapíná nebo vypíná už přítomná lokální rozšíření. Pro aplikování změn vložených assetů na tuto kartu prohlížeče reloadujte WebUI.', + settings_extensions_installed_empty: 'V nakonfigurovaném bundle nejsou žádná nainstalovaná manifest rozšíření.', + settings_extensions_no_dir: 'Není nastaven adresář rozšíření.', + ext_none: 'Žádné', + ext_no_warnings: 'Žádná varování.', + ext_state_unknown_ids_hint: 'Některá uložená přepsání vypnutých rozšíření už neodpovídají současnému manifestu; znovu-přidaná rozšíření se stejným id mohou zůstat vypnutá.', + ext_assets_title: 'Konečné veřejné URL assetů', + ext_assets_meta: 'Same-origin URL, které mohou být vloženy do shellu aplikace.', + ext_assets_scripts: 'Skripty', + ext_assets_styles: 'StyleSheety', + ext_warnings_title: 'Vyfiltrovaná varování', + ext_warnings_meta: 'Jen kódy a hrubé zdroje; cesty a odmítnuté hodnoty se nezobrazují.', + ext_sidecars_title: 'Sidecary', + ext_sidecars_meta: 'Deklarované lokální doplňky; zdravotní kontrola jde přímo z tohoto prohlížeče bez přihlašovacích údajů WebUI.', + ext_sidecars_none: 'Žádné deklarované loopback sidecary.', + ext_sidecar_origin: 'Origin', + ext_sidecar_health_path: 'Cesta ke stavu', + ext_sidecar_health_url: 'URL stavu', + ext_sidecar_proxy: 'Proxy', + ext_sidecar_proxy_path: 'Cesta proxy', + ext_status_true: 'ano', + ext_status_false: 'ne', copy_relative_path: 'Kopírovat relativní cestu', cron_schedule_hour_label: 'Hodina', cron_schedule_minute_label: 'Minuta', @@ -20716,6 +21244,50 @@ const LOCALES = { offline_stream_waiting: 'Bağlantı kesildi. Yenilenmesi bekleniyor\u2026', _lang: 'tr', _label: 'Türkçe', + settings_extensions_meta: 'WebUI uzantılarını görüntüle, yükle ve yönet.', + settings_extensions_installed_loading: 'Yüklü uzantılar yükleniyor…', + settings_extensions_copy_diagnostics: 'Tanılama kopyala', + settings_extensions_trust_model_label: 'Güven modeli', + settings_extensions_trust_model_text: "Uzantılar WebUI tarayıcı kaynağında çalışır ve bu oturumun erişebildiği aynı kimliği doğrulanmış API'lere erişebilir. Ayarlar ve uzantıya ait depolama tarayıcıda lokal kalır ve gizli bilgi için değildir. Sadece güvenilir lokale uzantı dizinlerini yükle; bir uzantıyı devre dışı bırakmak reload sonrasında halihazırda enjekte edilmiş varlıkları etkiler.", + settings_extensions_runtime_title: 'Uzantı çalışma ortamı', + settings_extensions_runtime_status_from: 'Durum /api/extensions/status kaynaklıdır; anahtarlar, kurulu manifest girdileri için yerel bir override kaydeder.', + settings_extensions_diag_extension_dir_configured: 'Uzantı dizini yapılandırıldı', + settings_extensions_diag_extension_dir_valid: 'Uzantı dizini geçerli', + settings_extensions_diag_manifest_configured: 'Manifest yapılandırıldı', + settings_extensions_diag_manifest_loaded: 'Manifest yüklendi', + settings_extensions_diag_manifest_status: 'Manifest durumu', + settings_extensions_diag_manifest_entries: 'İncelenen manifest girdileri', + settings_extensions_diag_manifest_scripts: 'Manifest betik sayısı', + settings_extensions_diag_manifest_stylesheets: 'Manifest stil sayfası sayısı', + settings_extensions_diag_manifest_sidecars: 'Manifest sidecar sayısı', + settings_extensions_diag_final_scripts: 'Betik toplamı', + settings_extensions_diag_final_stylesheets: 'Stil sayfası toplamı', + settings_extensions_diag_loopback_sidecars: 'Loopback sidecar sayısı', + settings_extensions_diag_installed_extensions: 'Yüklü uzantılar', + settings_extensions_diag_user_disabled: 'Kullanıcı devre dışı bıraktı', + settings_extensions_installed_section_title: 'Yüklü manifest uzantıları', + settings_extensions_installed_section_meta: "Önceden var olan lokale uzantıları aç ya da kapat. Bu tarayıcı sekmesine enjekte edilen varlık değişikliklerini uygulamak için WebUI'yi yeniden yükle.", + settings_extensions_installed_empty: 'Yapılandırılan pakette yüklü manifest uzantısı yok.', + settings_extensions_no_dir: 'Bir uzantı dizini yapılandırılmadı.', + ext_none: 'Yok', + ext_no_warnings: 'Uyarı yok.', + ext_state_unknown_ids_hint: 'Kaydedilen bazı devre dışı bırakılmış uzantı geçersiz kılmaları genel manifestla artık eşleşmiyor; aynı id ile tekrar eklenen uzantılar devre dışı kalabilir.', + ext_assets_title: 'Son genel varlık URL adresleri', + ext_assets_meta: 'Uygulama kabuğuna enjekte edilebilecek same-origin URL adresleri.', + ext_assets_scripts: 'Betikler', + ext_assets_styles: 'Stil sayfaları', + ext_warnings_title: 'Filtrelenmiş uyarılar', + ext_warnings_meta: 'Yalnızca kodlar ve genel kaynaklar; yollar ve reddedilen değerler gösterilmez.', + ext_sidecars_title: 'Sidecarlar', + ext_sidecars_meta: 'Bildirilen lokale yardımcılar; sağlık kontrolü bu tarayıcıdan WebUI kimlik bilgileri olmadan yapılır.', + ext_sidecars_none: 'Bildirilen loopback sidecar yok.', + ext_sidecar_origin: 'Kaynak', + ext_sidecar_health_path: 'Sağlık yolu', + ext_sidecar_health_url: 'Sağlık URL adresi', + ext_sidecar_proxy: 'Proxy', + ext_sidecar_proxy_path: 'Proxy yolu', + ext_status_true: 'true', + ext_status_false: 'false', sessions_source_webui: 'WebUI oturumları ({0})', sessions_source_cli: 'CLI oturumları ({0})', _speech: 'tr-TR', @@ -22468,6 +23040,50 @@ const LOCALES = { offline_stream_waiting: 'Połączenie utracone. Oczekiwanie na odświeżenie…', _lang: 'pl', _label: 'Polski', + settings_extensions_meta: 'Przeglądaj, instaluj i zarządzaj rozszerzeniami WebUI.', + settings_extensions_installed_loading: 'Wczytywanie zainstalowanych rozszerzeń…', + settings_extensions_copy_diagnostics: 'Kopiuj diagnostykę', + settings_extensions_trust_model_label: 'Model zaufania', + settings_extensions_trust_model_text: 'Rozszerzenia działają w przeglądarkowym origin WebUI i mogą korzystać z tych samych uwierzytelnionych API co ta sesja. Ustawienia i magazyn rozszerzeń pozostają w przeglądarce i nie służą do przechowywania sekretów. Wczytuj tylko zaufane lokalne katalogi rozszerzeń; wyłączenie rozszerzenia zadziała po ponownym wczytaniu dla już wstrzykniętych assetów.', + settings_extensions_runtime_title: 'Runtime rozszerzeń', + settings_extensions_runtime_status_from: 'Status z /api/extensions/status; przełączniki utrwalają lokalne nadpisanie dla zainstalowanych wpisów manifestu.', + settings_extensions_diag_extension_dir_configured: 'Skonfigurowano katalog rozszerzeń', + settings_extensions_diag_extension_dir_valid: 'Katalog rozszerzeń jest poprawny', + settings_extensions_diag_manifest_configured: 'Skonfigurowano manifest', + settings_extensions_diag_manifest_loaded: 'Załadowano manifest', + settings_extensions_diag_manifest_status: 'Status manifestu', + settings_extensions_diag_manifest_entries: 'Pozycje manifestu w inspekcji', + settings_extensions_diag_manifest_scripts: 'Skrypty w manifeście', + settings_extensions_diag_manifest_stylesheets: 'Arkusze stylów w manifeście', + settings_extensions_diag_manifest_sidecars: 'Sidecary w manifeście', + settings_extensions_diag_final_scripts: 'Skrypty łącznie', + settings_extensions_diag_final_stylesheets: 'Arkusze stylów łącznie', + settings_extensions_diag_loopback_sidecars: 'Loopback sidecary', + settings_extensions_diag_installed_extensions: 'Zainstalowane rozszerzenia', + settings_extensions_diag_user_disabled: 'Wyłączone przez użytkownika', + settings_extensions_installed_section_title: 'Zainstalowane rozszerzenia', + settings_extensions_installed_section_meta: 'Włącz/wyłącz już istniejące lokalne rozszerzenia. Wczytaj WebUI ponownie, aby zastosować zmiany wstrzykniętych assetów w tej karcie.', + settings_extensions_installed_empty: 'W skonfigurowanym pakiecie nie ma zainstalowanych rozszerzeń manifestu.', + settings_extensions_no_dir: 'Nie skonfigurowano katalogu rozszerzeń.', + ext_none: 'Brak', + ext_no_warnings: 'Brak ostrzeżeń.', + ext_state_unknown_ids_hint: 'Niektóre zapisane nadpisania wyłączonych rozszerzeń nie pasują już do aktualnego manifestu; ponownie dodane rozszerzenia z tym samym id mogą pozostać wyłączone.', + ext_assets_title: 'Ostateczne publiczne adresy URL assetów', + ext_assets_meta: 'Adresy URL same-origin, które można wstrzyknąć do shellu aplikacji.', + ext_assets_scripts: 'Skrypty', + ext_assets_styles: 'Arkusze stylów', + ext_warnings_title: 'Odfiltrowane ostrzeżenia', + ext_warnings_meta: 'Tylko kody i ogólne źródła; ścieżki i odrzucone wartości nie są pokazywane.', + ext_sidecars_title: 'Sidecary', + ext_sidecars_meta: 'Zadeklarowani lokalni pomocnicy; sprawdzenie kondycji odbywa się bezpośrednio z tej przeglądarki bez poświadczeń WebUI.', + ext_sidecars_none: 'Brak zadeklarowanych loopback sidecarów.', + ext_sidecar_origin: 'Origin', + ext_sidecar_health_path: 'Ścieżka health', + ext_sidecar_health_url: 'URL health', + ext_sidecar_proxy: 'Proxy', + ext_sidecar_proxy_path: 'Ścieżka proxy', + ext_status_true: 'tak', + ext_status_false: 'nie', sessions_source_webui: 'Sesje WebUI ({0})', sessions_source_cli: 'Sesje CLI ({0})', _speech: 'pl-PL', @@ -24224,6 +24840,50 @@ const LOCALES = { offline_stream_waiting: 'Mất kết nối. Đang chờ làm mới…', _lang: 'vi', _label: 'Tiếng Việt', + settings_extensions_meta: 'Duyệt, cài đặt và quản lý các tiện ích WebUI.', + settings_extensions_installed_loading: 'Đang tải các tiện ích đã cài…', + settings_extensions_copy_diagnostics: 'Sao chép thông tin chẩn đoán', + settings_extensions_trust_model_label: 'Mô hình tin cậy', + settings_extensions_trust_model_text: 'Tiện ích chạy trong browser origin của WebUI và có thể truy cập cùng các API đã xác thực như phiên này. Cài đặt và kho lưu trữ thuộc về tiện ích chỉ tồn tại trên trình duyệt và không dùng cho thông tin mật. Chỉ tải các thư mục tiện ích cục bộ đáng tin cậy; việc tắt một tiện ích có hiệu lực sau khi tải lại đối với các tài nguyên đã được tiêm.', + settings_extensions_runtime_title: 'Runtime của tiện ích', + settings_extensions_runtime_status_from: 'Trạng thái từ /api/extensions/status; công tắc giữ một local override cho các mục manifest đã cài.', + settings_extensions_diag_extension_dir_configured: 'Đã cấu hình thư mục tiện ích', + settings_extensions_diag_extension_dir_valid: 'Thư mục tiện ích hợp lệ', + settings_extensions_diag_manifest_configured: 'Đã cấu hình manifest', + settings_extensions_diag_manifest_loaded: 'Đã nạp manifest', + settings_extensions_diag_manifest_status: 'Trạng thái manifest', + settings_extensions_diag_manifest_entries: 'Các mục manifest đã kiểm tra', + settings_extensions_diag_manifest_scripts: 'Số script trong manifest', + settings_extensions_diag_manifest_stylesheets: 'Số stylesheet trong manifest', + settings_extensions_diag_manifest_sidecars: 'Số sidecar trong manifest', + settings_extensions_diag_final_scripts: 'Tổng số script', + settings_extensions_diag_final_stylesheets: 'Tổng số stylesheet', + settings_extensions_diag_loopback_sidecars: 'Số sidecar loopback', + settings_extensions_diag_installed_extensions: 'Các tiện ích đã cài', + settings_extensions_diag_user_disabled: 'Người dùng đã tắt', + settings_extensions_installed_section_title: 'Các tiện ích manifest đã cài', + settings_extensions_installed_section_meta: 'Bật hoặc tắt các tiện ích cục bộ đã có sẵn. Tải lại WebUI để áp dụng thay đổi tài nguyên đã tiêm cho tab trình duyệt này.', + settings_extensions_installed_empty: 'Không có tiện ích manifest nào được cài trong bundle đã cấu hình.', + settings_extensions_no_dir: 'Chưa cấu hình thư mục tiện ích.', + ext_none: 'Không có', + ext_no_warnings: 'Không có cảnh báo.', + ext_state_unknown_ids_hint: 'Một số override đã lưu cho tiện ích bị tắt không còn khớp với manifest hiện tại; các tiện ích được thêm lại với cùng id có thể vẫn giữ trạng thái tắt.', + ext_assets_title: 'URL tài nguyên công khai cuối cùng', + ext_assets_meta: 'URL cùng nguồn gốc có thể được tiêm vào vỏ ứng dụng.', + ext_assets_scripts: 'Scripts', + ext_assets_styles: 'Stylesheets', + ext_warnings_title: 'Cảnh báo đã lọc', + ext_warnings_meta: 'Chỉ mã và nguồn tổng quát; đường dẫn và giá trị bị từ chối không được hiển thị.', + ext_sidecars_title: 'Sidecar', + ext_sidecars_meta: 'Companion cục bộ đã khai báo; kiểm tra tình trạng được thực hiện trực tiếp từ trình duyệt này mà không cần thông tin xác thực WebUI.', + ext_sidecars_none: 'Không khai báo sidecar loopback nào.', + ext_sidecar_origin: 'Origin', + ext_sidecar_health_path: 'Đường dẫn kiểm tra', + ext_sidecar_health_url: 'URL kiểm tra', + ext_sidecar_proxy: 'Proxy', + ext_sidecar_proxy_path: 'Đường dẫn proxy', + ext_status_true: 'true', + ext_status_false: 'false', sessions_source_webui: 'Phiên WebUI ({0})', sessions_source_cli: 'Phiên CLI ({0})', _speech: 'vi-VN', diff --git a/static/index.html b/static/index.html index 6ead38ac..b95528e8 100644 --- a/static/index.html +++ b/static/index.html @@ -1504,7 +1504,7 @@
Extensions
- +
@@ -1528,20 +1528,20 @@
diff --git a/static/panels.js b/static/panels.js index b53a89c6..1444162a 100644 --- a/static/panels.js +++ b/static/panels.js @@ -9766,31 +9766,31 @@ async function loadSettingsPanel(){ // ── Extensions panel (browser-origin diagnostics + local enable controls) ── function _extensionStatusLabel(value){ - return value ? 'Enabled' : 'Disabled'; + return value ? t('plugins_enabled') : t('plugins_disabled'); } function _extensionBooleanBadge(value){ const cls=value?'extension-status-badge-on':'extension-status-badge-off'; - return `${value?'true':'false'}`; + return `${value?t('ext_status_true'):t('ext_status_false')}`; } function _extensionAssetList(urls){ if(!Array.isArray(urls)||urls.length===0){ - return '
None
'; + return '
'+t('ext_none')+'
'; } return '
    '+urls.map(url=>`
  • ${esc(url)}
  • `).join('')+'
'; } function _extensionWarningList(warnings){ if(!Array.isArray(warnings)||warnings.length===0){ - return '
No warnings.
'; + return '
'+t('ext_no_warnings')+'
'; } return '
    '+warnings.map(item=>{ const rawCode=(item&&item.code)||'unknown_warning'; const code=esc(rawCode); const source=esc((item&&item.source)||'unknown'); const hint=rawCode==='extension_state_unknown_ids' - ? 'Some saved disabled-extension overrides no longer match the current manifest; re-added extensions with the same id may stay disabled.' + ? ''+t('ext_state_unknown_ids_hint')+'' : ''; return `
  • ${code}${source}${hint}
  • `; }).join('')+'
'; @@ -9890,8 +9890,8 @@ function _extensionConfigureButton(entry,surface){ function _extensionInstalledList(extensions,extensionDirConfigured,surface){ const list=Array.isArray(extensions)?extensions:[]; if(!list.length){ - if(!extensionDirConfigured) return '
No extension directory is configured.
'; - return '
No manifest extensions are installed in the configured bundle.
'; + if(!extensionDirConfigured) return '
'+t('settings_extensions_no_dir')+'
'; + return '
'+t('settings_extensions_installed_empty')+'
'; } return `
${list.map(entry=>{ const id=(entry&&entry.id)||''; @@ -10035,23 +10035,23 @@ function _extensionSidecarCard(sidecars){
${esc(meta)}
-
Origin${esc(origin)}
-
Health path${esc(healthPath)}
-
Health URL${esc(healthUrl)}
-
Proxy${esc(proxyStatus)}
-
Proxy path${esc(proxyPath)}
+
${esc(t('ext_sidecar_origin'))}${esc(origin)}
+
${esc(t('ext_sidecar_health_path'))}${esc(healthPath)}
+
${esc(t('ext_sidecar_health_url'))}${esc(healthUrl)}
+
${esc(t('ext_sidecar_proxy'))}${esc(proxyStatus)}
+
${esc(t('ext_sidecar_proxy_path'))}${esc(proxyPath)}
${proxyButton}
${proxyWarning} `; - }).join('')}`:'
No loopback sidecars declared.
'; + }).join('')}`:'
'+t('ext_sidecars_none')+'
'; return `
-
Loopback sidecars
-
Declared local companions; health is checked directly from this browser with WebUI credentials omitted.
+
${esc(t('ext_sidecars_title'))}
+
${esc(t('ext_sidecars_meta'))}
@@ -10144,35 +10144,35 @@ function _renderExtensionsPanel(data,seq){
-
Extension runtime
-
Status from /api/extensions/status; toggles persist a local override for installed manifest entries.
+
${esc(t('settings_extensions_runtime_title'))}
+
${esc(t('settings_extensions_runtime_status_from'))}
${_extensionStatusLabel(!!(data&&data.enabled))}
-
Extension dir configured${_extensionBooleanBadge(!!(data&&data.extension_dir_configured))}
-
Extension dir valid${_extensionBooleanBadge(!!(data&&data.extension_dir_valid))}
-
Manifest configured${_extensionBooleanBadge(!!manifest.configured)}
-
Manifest loaded${_extensionBooleanBadge(!!manifest.loaded)}
-
Manifest status${esc(manifest.status||'unknown')}
-
Manifest entries inspected${Number(manifest.entry_count)||0}
-
Manifest script count${Number(manifest.script_count)||0}
-
Manifest stylesheet count${Number(manifest.stylesheet_count)||0}
-
Manifest sidecar count${Number(manifest.sidecar_count)||0}
-
Final script count${scriptCount}
-
Final stylesheet count${styleCount}
-
Loopback sidecar count${sidecarCount}
-
Installed manifest extensions${manifestExtensionCount}
-
User-disabled extensions${userDisabledCount}
+
${esc(t('settings_extensions_diag_extension_dir_configured'))}${_extensionBooleanBadge(!!(data&&data.extension_dir_configured))}
+
${esc(t('settings_extensions_diag_extension_dir_valid'))}${_extensionBooleanBadge(!!(data&&data.extension_dir_valid))}
+
${esc(t('settings_extensions_diag_manifest_configured'))}${_extensionBooleanBadge(!!manifest.configured)}
+
${esc(t('settings_extensions_diag_manifest_loaded'))}${_extensionBooleanBadge(!!manifest.loaded)}
+
${esc(t('settings_extensions_diag_manifest_status'))}${esc(manifest.status||'unknown')}
+
${esc(t('settings_extensions_diag_manifest_entries'))}${Number(manifest.entry_count)||0}
+
${esc(t('settings_extensions_diag_manifest_scripts'))}${Number(manifest.script_count)||0}
+
${esc(t('settings_extensions_diag_manifest_stylesheets'))}${Number(manifest.stylesheet_count)||0}
+
${esc(t('settings_extensions_diag_manifest_sidecars'))}${Number(manifest.sidecar_count)||0}
+
${esc(t('settings_extensions_diag_final_scripts'))}${scriptCount}
+
${esc(t('settings_extensions_diag_final_stylesheets'))}${styleCount}
+
${esc(t('settings_extensions_diag_loopback_sidecars'))}${sidecarCount}
+
${esc(t('settings_extensions_diag_installed_extensions'))}${manifestExtensionCount}
+
${esc(t('settings_extensions_diag_user_disabled'))}${userDisabledCount}
-
Installed manifest extensions
-
Enable or disable already-present local extensions. Reload WebUI to apply injected asset changes to this browser tab.
+
${esc(t('settings_extensions_installed_section_title'))}
+
${esc(t('settings_extensions_installed_section_meta'))}
@@ -10182,14 +10182,14 @@ function _renderExtensionsPanel(data,seq){
-
Final public asset URLs
-
Same-origin URLs that may be injected into the app shell.
+
${esc(t('ext_assets_title'))}
+
${esc(t('ext_assets_meta'))}
-
Scripts
+
${esc(t('ext_assets_scripts'))}
${_extensionAssetList(scripts)} - + ${_extensionAssetList(styles)}
@@ -10197,8 +10197,8 @@ function _renderExtensionsPanel(data,seq){
-
Sanitized warnings
-
Codes and coarse sources only; paths and rejected values are not shown.
+
${esc(t('ext_warnings_title'))}
+
${esc(t('ext_warnings_meta'))}
diff --git a/tests/test_extensions_settings_panel.py b/tests/test_extensions_settings_panel.py index 6e529489..aa4e0667 100644 --- a/tests/test_extensions_settings_panel.py +++ b/tests/test_extensions_settings_panel.py @@ -166,7 +166,10 @@ def test_extensions_panel_renders_sanitized_status_payload(): assert "const code=esc(rawCode)" in warning_block assert "esc((item&&item.source)||'unknown')" in warning_block assert "extension_state_unknown_ids" in warning_block - assert "Some saved disabled-extension overrides no longer match the current manifest" in warning_block + # #7581: the hint text is now routed through t(); the raw English must live + # only in the locale bundle, not inline in the JS. + assert "Some saved disabled-extension overrides no longer match the current manifest" not in warning_block + assert "ext_state_unknown_ids_hint" in warning_block assert "Rejected" not in render_block # rejected values must never be rendered directly @@ -179,8 +182,8 @@ def test_extensions_panel_renders_loopback_sidecar_monitor_safely(): load_block = _between("async function loadExtensionsPanel", "async function copyExtensionsDiagnostics") load_catch_block = load_block[load_block.index("}catch(e){"):] - assert "Loopback sidecars" in sidecar_block - assert "No loopback sidecars declared." in sidecar_block + assert "ext_sidecars_title" in sidecar_block + assert "ext_sidecars_none" in sidecar_block assert "esc(title)" in sidecar_block assert "esc(meta)" in sidecar_block assert "esc(origin)" in sidecar_block @@ -191,7 +194,7 @@ def test_extensions_panel_renders_loopback_sidecar_monitor_safely(): assert "proxy.consented===true" in sidecar_block assert "proxy.consent_required===true" in sidecar_block assert "proxy.origin_changed===true" in sidecar_block - assert "Proxy path" in sidecar_block + assert "ext_sidecar_proxy_path" in sidecar_block assert "data-extension-sidecar-proxy-id" in sidecar_block assert "data-extension-sidecar-proxy-approved" in sidecar_block assert 'data-sidecar-runtime-index="${index}"' in sidecar_block @@ -251,8 +254,8 @@ def test_extensions_panel_toggle_uses_dedicated_endpoint_without_settings_or_ins assert "data-extension-toggle-id" in installed_block assert "data-extension-next-enabled" in installed_block - assert "No extension directory is configured." in installed_block - assert "No manifest extensions are installed in the configured bundle." in installed_block + assert "settings_extensions_no_dir" in installed_block + assert "settings_extensions_installed_empty" in installed_block assert "extensionDirConfigured" in installed_block assert "Manifest-disabled entries cannot be enabled from WebUI." in installed_block assert "api('/api/extensions/toggle',{method:'POST',body:JSON.stringify({id,enabled})})" in toggle_block From cdcd37f98659ede75d3a9dccd161c2f1dc503a28 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Thu, 17 Sep 2026 14:05:38 +0300 Subject: [PATCH 083/446] fix: retain durable unread clears under storage pressure Keep an older persisted unread-clear ordering record when quota rejects its higher replacement, so reload cannot lose the last durable tombstone. Replace unread source-shape assertions with Node-driven behavior checks against the real sessions.js functions. --- .../webui-run-state-consistency-contract.md | 19 ++- static/sessions.js | 46 +++--- tests/_unread_store_helpers.py | 1 + ...t_issue856_background_completion_unread.py | 146 ++++++++++++++---- tests/test_unread_cross_client_sync.py | 31 ++++ ...ebui_run_state_consistency_contract_rfc.py | 9 -- 6 files changed, 185 insertions(+), 67 deletions(-) diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index aae3d695..408fd3e0 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -210,14 +210,17 @@ writes. A client still running the previous revision therefore does not observe clears recorded after the migration; that reload boundary is deliberate, because a dual write cannot make the shared blob concurrency-safe. Versioned records are - pruned by age (7 days) and supersession only — never by session existence, - because the sidebar list is filtered by profile, project, and source, so an - absent row may simply be hidden. They are never part of the marker map - consumers read. A clear order is retained in module memory even when storage - quota prevents allocating its versioned key; the client still attempts the - smaller write that removes the marker from the existing marker map, so a user - can dismiss unread state under storage pressure. Logical clear order and - retention time are separate: records compare markers using their order stamp, + pruned by age (7 days), or when a newer ordering fact for that session was + successfully persisted — never by an in-memory-only superseding clear and never + by session existence, because the sidebar list is filtered by profile, project, + and source, so an absent row may simply be hidden. They are never part of the + marker map consumers read. A clear order is retained in module memory even when + storage quota prevents allocating its versioned key; the client still attempts + the smaller write that removes the marker from the existing marker map, so a user + can dismiss unread state under storage pressure. The failed allocation also + leaves any older durable clear record in place so reload does not lose the last + persisted ordering fact. Logical clear order and retention time are separate: + records compare markers using their order stamp, but the 7-day cap uses the wall-clock time at which the record was written, so a future logical stamp cannot extend retention indefinitely. The in-memory fallback lasts until reload, while successfully persisted records retain the diff --git a/static/sessions.js b/static/sessions.js index 8efd006d..ae6bc699 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -744,10 +744,10 @@ function _parseSessionCompletionUnreadClearedKey(key) { return { sid, version }; } -// Read the legacy whole-map key plus independent versioned clear records. A -// clear writes a new key rather than replacing an older operation, so readers -// can fold the maximum timestamp without a cross-context compare-and-set. -function _readSessionCompletionUnreadCleared() { +// Read persisted clear ordering independently from the in-memory fallback. The +// distinction matters during pruning: a higher clear that failed quota and lives +// only in memory must not delete the older durable record it supersedes. +function _readPersistedSessionCompletionUnreadCleared() { // Snapshot keys before values. A clear that starts after this operation began // is deliberately excluded, so a marker prepared first gets the same logical // order and loses the clear-wins tie. A marker started after a completed clear @@ -757,16 +757,6 @@ function _readSessionCompletionUnreadCleared() { keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i)); } catch (_) {} const cleared = _readStoredJsonMap(SESSION_COMPLETION_UNREAD_CLEARED_KEY); - const now = Date.now(); - for (const [sid, memoryRecord] of Object.entries(_sessionCompletionUnreadClearedMemory || {})) { - const value = Number(memoryRecord && memoryRecord.order) || 0; - const recordedAt = Number(memoryRecord && memoryRecord.recorded_at) || 0; - if (recordedAt && now - recordedAt > SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS) { - delete _sessionCompletionUnreadClearedMemory[sid]; - continue; - } - if (value > (Number(cleared[sid]) || 0)) cleared[sid] = value; - } try { for (const key of keys) { const parsed = _parseSessionCompletionUnreadClearedKey(key); @@ -781,6 +771,23 @@ function _readSessionCompletionUnreadCleared() { return cleared; } +// Combine durable clear ordering with this client's in-memory fallback. The +// fallback keeps the current window correct when quota rejects a new key. +function _readSessionCompletionUnreadCleared() { + const cleared = _readPersistedSessionCompletionUnreadCleared(); + const now = Date.now(); + for (const [sid, memoryRecord] of Object.entries(_sessionCompletionUnreadClearedMemory || {})) { + const value = Number(memoryRecord && memoryRecord.order) || 0; + const recordedAt = Number(memoryRecord && memoryRecord.recorded_at) || 0; + if (recordedAt && now - recordedAt > SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS) { + delete _sessionCompletionUnreadClearedMemory[sid]; + continue; + } + if (value > (Number(cleared[sid]) || 0)) cleared[sid] = value; + } + return cleared; +} + function _nextSessionCompletionUnreadOrder(sid, cleared = null) { const clears = cleared || _readSessionCompletionUnreadCleared(); const cached = _getSessionCompletionUnread()[sid]; @@ -931,7 +938,7 @@ function _writeSessionCompletionUnreadCleared(sidOrSids, clearedAt) { try { localStorage.removeItem(SESSION_COMPLETION_UNREAD_CLEARED_KEY); } catch (_) {} } - const cleared = _readSessionCompletionUnreadCleared(); + const persistedCleared = _readPersistedSessionCompletionUnreadCleared(); let keys = []; try { keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i)); @@ -943,11 +950,10 @@ function _writeSessionCompletionUnreadCleared(sidOrSids, clearedAt) { const recordedAt = Number(localStorage.getItem(key)) || 0; const version = parsed.version || recordedAt; const expired = recordedAt > 0 && Date.now() - recordedAt > SESSION_COMPLETION_UNREAD_CLEARED_TTL_MS; - const superseded = version < (Number(cleared[parsed.sid]) || 0); - // Records are pruned by age and supersession only. Session existence is - // not a pruning signal: the sidebar list is filtered by profile, project, - // and source, so a session missing from it may simply be hidden, and - // dropping its clear record would re-light a cleared dot. + const superseded = version < (Number(persistedCleared[parsed.sid]) || 0); + // Prune by age, or when a newer ordering fact is itself durable. A + // memory-only clear may keep this window correct under quota pressure, but + // it cannot safely replace the older record across reload. if (expired || superseded) localStorage.removeItem(key); } catch (_) {} } diff --git a/tests/_unread_store_helpers.py b/tests/_unread_store_helpers.py index 0d187e64..ed31e43d 100644 --- a/tests/_unread_store_helpers.py +++ b/tests/_unread_store_helpers.py @@ -32,6 +32,7 @@ _HELPER_NAMES = ( "_nextSessionCompletionUnreadOrder", "_sessionCompletionUnreadClearedKey", "_parseSessionCompletionUnreadClearedKey", + "_readPersistedSessionCompletionUnreadCleared", "_readSessionCompletionUnreadCleared", "_mergeSessionCompletionUnread", "_writeSessionCompletionUnreadCleared", diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index f11349ba..83dcc58a 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -1,12 +1,21 @@ """Regression checks for #856 background completion unread markers.""" import json +import shutil +import subprocess +import sys from pathlib import Path +import pytest + REPO = Path(__file__).resolve().parent.parent SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + +sys.path.insert(0, str(REPO / "tests")) +import _unread_store_helpers as unread_store_helpers # noqa: E402 def _done_block() -> str: @@ -40,25 +49,81 @@ def _function_body(block: str) -> str: raise AssertionError("function closing brace not found") +def _extract_sessions_function(name: str) -> str: + start = SESSIONS_JS.index(f"function {name}(") + brace = SESSIONS_JS.index("{", start) + depth = 0 + for i in range(brace, len(SESSIONS_JS)): + if SESSIONS_JS[i] == "{": + depth += 1 + elif SESSIONS_JS[i] == "}": + depth -= 1 + if depth == 0: + return SESSIONS_JS[start:i + 1] + raise AssertionError(f"could not brace-match {name}") + + +def _run_unread_behavior(body: str) -> dict: + functions = "\n".join( + _extract_sessions_function(name) + for name in ( + "_getSessionViewedCounts", + "_saveSessionViewedCounts", + "_setSessionViewedCount", + "_getSessionCompletionUnread", + "_saveSessionCompletionUnread", + "_clearSessionCompletionUnread", + "_hasSessionCompletionUnread", + "_hasUnreadForSession", + ) + ) + script = f""" +const store = {{}}; +const localStorage = {{ + getItem: (key) => Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null, + setItem: (key, value) => {{ store[key] = String(value); }}, + removeItem: (key) => {{ delete store[key]; }}, + key: (index) => Object.keys(store)[index] ?? null, + get length() {{ return Object.keys(store).length; }}, +}}; +const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts'; +const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread'; +let _sessionViewedCounts = null; +let _sessionCompletionUnread = null; +let _allSessions = [{{session_id: 'X'}}]; +const _sessionListSnapshotById = new Map(); +const S = {{session: null}}; +{unread_store_helpers.BLOCK} +{functions} +{body} +""" + result = subprocess.run( + [NODE, "-e", script], capture_output=True, text=True, timeout=60 + ) + assert result.returncode == 0, f"node harness failed:\n{result.stderr}" + return json.loads(result.stdout) + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_background_completion_unread_uses_explicit_marker_not_message_delta(): """A background completion must stay unread even when message_count has no delta.""" - assert "SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread'" in SESSIONS_JS - assert "function _markSessionCompletionUnread(" in SESSIONS_JS - assert "function _clearSessionCompletionUnread(" in SESSIONS_JS - assert "function _hasSessionCompletionUnread(" in SESSIONS_JS - - has_unread_idx = SESSIONS_JS.find("function _hasUnreadForSession(s)") - assert has_unread_idx != -1, "_hasUnreadForSession not found" - has_unread_block = SESSIONS_JS[has_unread_idx:SESSIONS_JS.find("async function newSession", has_unread_idx)] - - marker_idx = has_unread_block.find("_hasSessionCompletionUnread(s.session_id)") - count_idx = has_unread_block.find("s.message_count > viewed.message_count") - assert marker_idx != -1, "_hasUnreadForSession must check explicit completion unread marker" - assert count_idx != -1, "_hasUnreadForSession must keep the existing message_count fallback" - assert marker_idx < count_idx, ( - "explicit completion unread marker must be checked before message_count delta, " - "because completed streams can have viewed_count == message_count" - ) + out = _run_unread_behavior(""" +store[SESSION_VIEWED_COUNTS_KEY] = JSON.stringify({ + X: {message_count: 3, transcript_generation: 0}, +}); +store[SESSION_COMPLETION_UNREAD_KEY] = JSON.stringify({ + X: {message_count: 3, completed_at: 1000, unread_order: 1000}, +}); +const beforeClear = _hasUnreadForSession({ + session_id: 'X', message_count: 3, transcript_generation: 0, +}); +_clearSessionCompletionUnread('X'); +const afterClear = _hasUnreadForSession({ + session_id: 'X', message_count: 3, transcript_generation: 0, +}); +console.log(JSON.stringify({beforeClear, afterClear})); +""") + assert out == {"beforeClear": True, "afterClear": False} def test_background_done_sets_marker_when_session_not_actively_viewed(): @@ -550,17 +615,38 @@ def test_completion_unread_clears_only_when_session_is_opened(): assert "_setSessionViewedCount(sid, messageCount);" in ack_body -def test_historical_sessions_are_not_marked_unread_on_list_render(): - """The explicit unread marker must be event-driven, not initialized by _hasUnreadForSession.""" - has_unread_idx = SESSIONS_JS.find("function _hasUnreadForSession(s)") - assert has_unread_idx != -1 - has_unread_block = SESSIONS_JS[ - has_unread_idx:SESSIONS_JS.find("function _isSessionActivelyViewedForList", has_unread_idx) - ] +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_historical_sessions_are_not_marked_unread_on_first_list_render(): + """First observation establishes a read baseline instead of inventing unread.""" + out = _run_unread_behavior(""" +const unread = _hasUnreadForSession({ + session_id: 'X', message_count: 5, transcript_generation: 3, + transcript_generation_baseline: 0, +}); +const viewed = JSON.parse(store[SESSION_VIEWED_COUNTS_KEY]).X; +console.log(JSON.stringify({unread, viewed})); +""") + assert out == { + "unread": False, + "viewed": {"message_count": 5, "transcript_generation": 3}, + } - assert "_markSessionCompletionUnread" not in has_unread_block, ( - "rendering old historical sessions must not create completion-unread markers" - ) - assert "_setSessionViewedCount(s.session_id, Number(s.message_count || 0), generation);" in has_unread_block, ( - "missing viewed-count baseline should still initialize as read for historical sessions" - ) + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_new_transcript_generation_keeps_post_shrink_messages_unread(): + """A coalesced shrink and growth acknowledges only the retained baseline.""" + out = _run_unread_behavior(""" +store[SESSION_VIEWED_COUNTS_KEY] = JSON.stringify({ + X: {message_count: 10, transcript_generation: 0}, +}); +const unread = _hasUnreadForSession({ + session_id: 'X', message_count: 3, transcript_generation: 1, + transcript_generation_baseline: 2, +}); +const viewed = JSON.parse(store[SESSION_VIEWED_COUNTS_KEY]).X; +console.log(JSON.stringify({unread, viewed})); +""") + assert out == { + "unread": True, + "viewed": {"message_count": 2, "transcript_generation": 1}, + } diff --git a/tests/test_unread_cross_client_sync.py b/tests/test_unread_cross_client_sync.py index ab7b38c4..d09adc9d 100644 --- a/tests/test_unread_cross_client_sync.py +++ b/tests/test_unread_cross_client_sync.py @@ -125,6 +125,7 @@ _HELPER_FNS = [ "_nextSessionCompletionUnreadOrder", "_sessionCompletionUnreadClearedKey", "_parseSessionCompletionUnreadClearedKey", + "_readPersistedSessionCompletionUnreadCleared", "_readSessionCompletionUnreadCleared", "_mergeSessionCompletionUnread", "_writeSessionCompletionUnreadCleared", @@ -327,6 +328,36 @@ console.log(JSON.stringify({ assert out == {"before": True, "after": False, "diskHasX": False} +def test_quota_failed_clear_keeps_the_older_durable_tombstone_after_reload(): + """An in-memory-only higher clear must not prune the durable ordering fact.""" + out = _run_node(_script(""" +listed('X'); +const oldClearKey = `${SESSION_COMPLETION_UNREAD_CLEARED_PREFIX}${encodeURIComponent('X')}:2000`; +_store[oldClearKey] = '1000'; +const A = makeClient(); +const ordinarySetItem = localStorage.setItem; +localStorage.setItem = (key, value) => { + if (!Object.prototype.hasOwnProperty.call(_store, key)) throw new Error('QuotaExceededError'); + ordinarySetItem(key, value); +}; +// This advances X to 2001 in A's memory, but quota rejects that new durable key. +A.clearUnread('X'); +const durableAfterClear = Object.prototype.hasOwnProperty.call(_store, oldClearKey); +// Simulate a reload, then a stale client restoring a marker the durable clear +// already outranks. Module memory is gone; only localStorage may suppress it. +const B = makeClient(); +setDisk(SESSION_COMPLETION_UNREAD_KEY, { + X: {message_count: 3, completed_at: 1500, unread_order: 1500}, +}); +B.handleStorage({ key: oldClearKey }); +console.log(JSON.stringify({ + durableAfterClear, + unreadAfterReload: B.hasUnread('X'), +})); +""")) + assert out == {"durableAfterClear": True, "unreadAfterReload": False} + + def test_stale_client_cannot_roll_a_viewed_count_back(): """Sequential rollback: a client that saw an older list snapshot must not lower a count another client already acknowledged.""" diff --git a/tests/test_webui_run_state_consistency_contract_rfc.py b/tests/test_webui_run_state_consistency_contract_rfc.py index dedee864..b8c38a10 100644 --- a/tests/test_webui_run_state_consistency_contract_rfc.py +++ b/tests/test_webui_run_state_consistency_contract_rfc.py @@ -46,15 +46,6 @@ def test_contract_documents_the_client_side_unread_store_keys(): ) -def test_contract_documents_generation_scoped_viewed_counts(): - text = RFC.read_text(encoding="utf-8") - source = _sessions_js() - - assert "transcript_generation" in source - assert "transcript_generation" in text - assert "monotonic only within" in text - - def test_documented_retention_window_matches_the_implemented_cap(): source = _sessions_js() match = re.search( From a4a87a97a3e0a2d5540620989e85fa879a4fed43 Mon Sep 17 00:00:00 2001 From: Michael Snoyman Date: Thu, 17 Sep 2026 14:28:11 +0300 Subject: [PATCH 084/446] test: prove first historical render creates no unread state The historical-session behavior check only asserted the first return value and the viewed baseline, so a _hasUnreadForSession regression that fabricated a completion-unread marker while returning false would pass and surface as unread on the next render. Also assert the second call stays false and that neither the in-memory completion-unread cache nor the persisted store gains a marker for the session. --- ...t_issue856_background_completion_unread.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index 83dcc58a..f21bc03e 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -619,15 +619,31 @@ def test_completion_unread_clears_only_when_session_is_opened(): def test_historical_sessions_are_not_marked_unread_on_first_list_render(): """First observation establishes a read baseline instead of inventing unread.""" out = _run_unread_behavior(""" -const unread = _hasUnreadForSession({ +const session = { session_id: 'X', message_count: 5, transcript_generation: 3, transcript_generation_baseline: 0, -}); +}; +const first = _hasUnreadForSession(session); +// A first render that fabricates a completion-unread marker while still +// returning false would surface as unread on the next render (in-memory +// cache) or after a reload (persisted store), so inspect both before the +// second call. +const cachedMarker = Boolean( + _sessionCompletionUnread && + Object.prototype.hasOwnProperty.call(_sessionCompletionUnread, 'X') +); +const persistedMarker = Object.prototype.hasOwnProperty.call( + JSON.parse(store[SESSION_COMPLETION_UNREAD_KEY] || '{}'), 'X' +); +const second = _hasUnreadForSession(session); const viewed = JSON.parse(store[SESSION_VIEWED_COUNTS_KEY]).X; -console.log(JSON.stringify({unread, viewed})); +console.log(JSON.stringify({first, second, cachedMarker, persistedMarker, viewed})); """) assert out == { - "unread": False, + "first": False, + "second": False, + "cachedMarker": False, + "persistedMarker": False, "viewed": {"message_count": 5, "transcript_generation": 3}, } From 8c5ee95dab0ce9e81e648a5d8608c6e881a04989 Mon Sep 17 00:00:00 2001 From: carlotestor Date: Wed, 16 Sep 2026 20:23:57 +0000 Subject: [PATCH 085/446] fix(media): serve .zip archives through /api/media MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEDIA:/path/to/file.zip links always failed to open in chat. .zip was absent from MIME_MAP, so it resolved to application/octet-stream, which _SESSION_MEDIA_TOKEN_TYPES rejects — the browser rendered a download link that the server then refused. Map .zip to application/zip and add an _ARCHIVE_TYPES set to the session MEDIA: token allow-list. Archives are deliberately kept out of _INLINE_PREVIEW_TYPES and _INLINE_IMAGE_TYPES so they are always sent as Content-Disposition: attachment and never rendered inline. --- api/config.py | 1 + api/routes.py | 7 ++- tests/test_zip_media_delivery.py | 93 ++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 tests/test_zip_media_delivery.py diff --git a/api/config.py b/api/config.py index 0eb26136..44d18b9d 100644 --- a/api/config.py +++ b/api/config.py @@ -1046,6 +1046,7 @@ MIME_MAP = { ".ico": "image/x-icon", ".bmp": "image/bmp", ".pdf": "application/pdf", + ".zip": "application/zip", ".json": "application/json", ".html": "text/html", ".htm": "text/html", diff --git a/api/routes.py b/api/routes.py index 4cdb91c5..b975fd21 100644 --- a/api/routes.py +++ b/api/routes.py @@ -20775,7 +20775,12 @@ def _handle_media(handler, parsed): "video/mp4", "video/quicktime", "video/webm", "video/ogg", "application/pdf", } - _SESSION_MEDIA_TOKEN_TYPES = _INLINE_IMAGE_TYPES | _AUDIO_VIDEO_PDF_TYPES | {"text/html"} + # Archives are download-only: never added to the inline-preview sets below, + # so they always get Content-Disposition: attachment. + _ARCHIVE_TYPES = {"application/zip"} + _SESSION_MEDIA_TOKEN_TYPES = ( + _INLINE_IMAGE_TYPES | _AUDIO_VIDEO_PDF_TYPES | _ARCHIVE_TYPES | {"text/html"} + ) session_media_allowed = _session_media_token_allows_path( qs.get("session_id", [""])[0], target, diff --git a/tests/test_zip_media_delivery.py b/tests/test_zip_media_delivery.py new file mode 100644 index 00000000..dfd9ec2f --- /dev/null +++ b/tests/test_zip_media_delivery.py @@ -0,0 +1,93 @@ +"""Tests for zip archive delivery through /api/media. + +Archives previously resolved to application/octet-stream (``.zip`` was absent +from ``MIME_MAP``) and were therefore rejected by the session MEDIA: token +allow-list, so a ``MEDIA:/path/to/file.zip`` link always failed to open. + +Covers: +1. ``.zip`` maps to ``application/zip`` in MIME_MAP +2. ``application/zip`` is accepted by the session MEDIA: token allow-list +3. ``application/zip`` is NOT an inline-preview type (must download, never render) +""" +from __future__ import annotations + +import pathlib +import re +import unittest + +REPO_ROOT = pathlib.Path(__file__).parent.parent +ROUTES_PY = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8") + + +class TestZipMimeMapping(unittest.TestCase): + """.zip must resolve to a concrete archive type, not octet-stream.""" + + def test_zip_in_mime_map(self): + from api.config import MIME_MAP + + self.assertEqual( + MIME_MAP.get(".zip"), + "application/zip", + ".zip must map to application/zip so /api/media can serve archives", + ) + + def test_zip_not_octet_stream(self): + from api.config import MIME_MAP + + self.assertNotEqual( + MIME_MAP.get(".zip", "application/octet-stream"), + "application/octet-stream", + "octet-stream is rejected by the MEDIA: token allow-list", + ) + + +class TestZipTokenAllowList(unittest.TestCase): + """The session MEDIA: token gate must accept archives.""" + + def test_archive_types_declared(self): + self.assertIn( + '_ARCHIVE_TYPES = {"application/zip"}', + ROUTES_PY, + "_ARCHIVE_TYPES must declare the servable archive types", + ) + + def test_archive_types_in_session_token_types(self): + match = re.search( + r"_SESSION_MEDIA_TOKEN_TYPES\s*=\s*\(?\s*(.+?)\)?\n\s*session_media_allowed", + ROUTES_PY, + re.S, + ) + self.assertIsNotNone( + match, "could not locate the _SESSION_MEDIA_TOKEN_TYPES assignment" + ) + self.assertIn( + "_ARCHIVE_TYPES", + match.group(1), + "_SESSION_MEDIA_TOKEN_TYPES must include _ARCHIVE_TYPES", + ) + + +class TestZipIsDownloadOnly(unittest.TestCase): + """Archives must never be offered for inline rendering.""" + + def test_archive_not_in_inline_preview_types(self): + match = re.search(r"_INLINE_PREVIEW_TYPES\s*=\s*(.+)", ROUTES_PY) + self.assertIsNotNone(match, "could not locate _INLINE_PREVIEW_TYPES") + self.assertNotIn( + "_ARCHIVE_TYPES", + match.group(1), + "archives must not be inline-previewable; they must download", + ) + + def test_archive_not_in_inline_image_types(self): + match = re.search(r"_INLINE_IMAGE_TYPES\s*=\s*\{(.+?)\}", ROUTES_PY, re.S) + self.assertIsNotNone(match, "could not locate _INLINE_IMAGE_TYPES") + self.assertNotIn( + "zip", + match.group(1), + "zip must not be treated as an inline image type", + ) + + +if __name__ == "__main__": + unittest.main() From 1f7e5e78da690ca75183b650bd4da182045e5eda Mon Sep 17 00:00:00 2001 From: Mike McHargue Date: Mon, 14 Sep 2026 17:41:53 -0700 Subject: [PATCH 086/446] fix: resolve Hermes Agent names moved by the Sep 2026 decomposition Hermes Agent moved several names the WebUI uses into sibling modules (tools.approval_context, tools.mcp_tool_discovery/_lifecycle, hermes_cli.kanban_db_connect/_dispatch, hermes_cli.models_local, agent.anthropic_credentials). The old paths only resolved through PLUGIN-COMPAT __getattr__ pointers that warn on every use and are removed on schedule by reverting one commit. With the pointers removed, every affected WebUI call site is wrapped in a broad except, so nothing crashes; features silently degrade instead: per-turn approval session-key binding is skipped, MCP discovery/status return nothing, /reload-mcp reports "MCP runtime unavailable", Claude Code credential linking returns None, every kanban connection raises AttributeError, and the dispatcher reports "unavailable". Add api.agent_compat.agent_attr, which resolves a moved name from the original module's own namespace first (pre-split Agents and existing test stubs), then from its new home (split Agents, with or without pointers), then plain attribute access (non-module doubles). Importing only the new modules would have broken pre-split Agents and bypassed the sys.modules stubs the test suite installs on the original modules. Names still native to their original module (_servers, _lock, has_blocking_approval, is_session_yolo_enabled, register/unregister gateway notify, the rest of kanban_db) are unchanged. Co-Authored-By: Claude Opus 5 --- api/agent_compat.py | 54 ++++++++++++++++ api/commands.py | 10 ++- api/config.py | 6 +- api/kanban_bridge.py | 19 ++++-- api/oauth.py | 10 ++- api/routes.py | 3 +- api/streaming.py | 13 +++- tests/test_agent_compat.py | 127 +++++++++++++++++++++++++++++++++++++ 8 files changed, 226 insertions(+), 16 deletions(-) create mode 100644 api/agent_compat.py create mode 100644 tests/test_agent_compat.py diff --git a/api/agent_compat.py b/api/agent_compat.py new file mode 100644 index 00000000..74d02a27 --- /dev/null +++ b/api/agent_compat.py @@ -0,0 +1,54 @@ +"""Resolve Hermes Agent names that moved to a sibling module. + +Hermes Agent's September 2026 decomposition moved many names out of their +original modules (``tools.approval``, ``tools.mcp_tool``, +``hermes_cli.kanban_db``, ...) into ``_`` siblings. The old paths +kept resolving for a while only through PEP 562 ``__getattr__`` pointers that +emit ``HermesPluginCompatWarning`` and are removed on schedule, so WebUI code +must not rely on them. Importing only the new module would instead break Agent +installs that predate the split. + +``agent_attr`` is compatibility-only and resolves a moved name in this order: + +1. the original module's own namespace -- pre-split Agents, and tests that stub + the original module in ``sys.modules`` or patch the name onto it; +2. the new home module -- split Agents, with or without the old-path pointers; +3. plain attribute access on the original object -- non-module test doubles, + or an Agent whose new home is not importable. +""" + +from __future__ import annotations + +import importlib +from types import ModuleType +from typing import Any + +_MISSING = object() + + +def agent_attr(owner: Any, name: str, home: str, default: Any = _MISSING) -> Any: + """Return ``owner.name`` without going through a removed/deprecated pointer. + + ``owner`` is the original module (or its dotted name); ``home`` is the + dotted name of the module the Agent moved ``name`` to. Raises + ``ImportError``/``AttributeError`` like the plain import it replaces, unless + ``default`` is given. + """ + if isinstance(owner, str): + try: + owner = importlib.import_module(owner) + except ImportError: + if default is _MISSING: + return getattr(importlib.import_module(home), name) + try: + return getattr(importlib.import_module(home), name, default) + except ImportError: + return default + if isinstance(owner, ModuleType) and name not in vars(owner): + try: + return getattr(importlib.import_module(home), name) + except (ImportError, AttributeError): + pass + if default is _MISSING: + return getattr(owner, name) + return getattr(owner, name, default) diff --git a/api/commands.py b/api/commands.py index 36d85920..31f72315 100644 --- a/api/commands.py +++ b/api/commands.py @@ -260,7 +260,15 @@ def _run_reload_mcp_command() -> str: """Execute the MCP reconnect path and return a short user-facing summary.""" with _RELOAD_MCP_LOCK: try: - from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock + from tools.mcp_tool import _servers, _lock + from api.agent_compat import agent_attr + + shutdown_mcp_servers = agent_attr( + "tools.mcp_tool", "shutdown_mcp_servers", "tools.mcp_tool_lifecycle" + ) + discover_mcp_tools = agent_attr( + "tools.mcp_tool", "discover_mcp_tools", "tools.mcp_tool_discovery" + ) except Exception as exc: logger.warning("Failed to import MCP runtime for /reload-mcp", exc_info=True) raise RuntimeError("MCP runtime unavailable") from exc diff --git a/api/config.py b/api/config.py index 0eb26136..1eb04bc0 100644 --- a/api/config.py +++ b/api/config.py @@ -5562,8 +5562,10 @@ def _lmstudio_model_reasoning_options( ) try: - from hermes_cli.models import ( - lmstudio_model_reasoning_options as _cli_lmstudio_model_reasoning_options, + from api.agent_compat import agent_attr + + _cli_lmstudio_model_reasoning_options = agent_attr( + "hermes_cli.models", "lmstudio_model_reasoning_options", "hermes_cli.models_local" ) except Exception: return _lmstudio_reasoning_probe_options_fallback( diff --git a/api/kanban_bridge.py b/api/kanban_bridge.py index bd8dfaa9..4f1488e0 100644 --- a/api/kanban_bridge.py +++ b/api/kanban_bridge.py @@ -19,6 +19,7 @@ import time from dataclasses import asdict, is_dataclass from urllib.parse import parse_qs, unquote +from api.agent_compat import agent_attr from api.helpers import bad, j from api.workspace import resolve_trusted_workspace @@ -33,6 +34,11 @@ def _kb(): return kb +def _kb_connect(kb, board=None): + """Raw ``kb.connect`` (moved to ``hermes_cli.kanban_db_connect`` by the Agent split).""" + return agent_attr(kb, "connect", "hermes_cli.kanban_db_connect")(board=board) + + def _resolve_board(parsed): """Validate and normalise a ?board= query param. @@ -93,13 +99,13 @@ def _conn(board=None): """ kb = _kb() kb.init_db(board=board) - closing = getattr(kb, "connect_closing", None) + closing = agent_attr(kb, "connect_closing", "hermes_cli.kanban_db_connect", None) if closing is not None: return closing(board=board) # Older kanban_db builds (and lightweight test doubles) without # connect_closing: fall back to the raw connection; sqlite3's own # context manager at least scopes the transaction. - return kb.connect(board=board) + return _kb_connect(kb, board=board) def _obj_dict(value): @@ -720,10 +726,11 @@ def _dispatch_payload(parsed): kb = _kb() dry_run = _bool_query(parsed, "dry_run", False) max_spawn = _int_query(parsed, "max", 8, minimum=1, maximum=100) - if not hasattr(kb, "dispatch_once"): + dispatch_once = agent_attr(kb, "dispatch_once", "hermes_cli.kanban_db_dispatch", None) + if dispatch_once is None: raise ValueError("dispatcher is unavailable") with _conn(board=board) as conn: - result = kb.dispatch_once(conn, dry_run=dry_run, max_spawn=max_spawn) + result = dispatch_once(conn, dry_run=dry_run, max_spawn=max_spawn) if isinstance(result, dict): return result try: @@ -787,7 +794,7 @@ def _board_counts_for_slug(slug): if not kb.board_exists(slug): return {} try: - conn = kb.connect(board=slug) + conn = _kb_connect(kb, board=slug) except Exception: return {} try: @@ -1021,7 +1028,7 @@ def _kanban_sse_fetch_new(board, cursor): if board != default_slug and not kb.board_exists(board): return cursor, [] try: - conn = kb.connect(board=board) + conn = _kb_connect(kb, board=board) except Exception: return cursor, [] try: diff --git a/api/oauth.py b/api/oauth.py index 13d9a693..97924c5b 100644 --- a/api/oauth.py +++ b/api/oauth.py @@ -314,9 +314,13 @@ def _read_claude_code_credentials() -> dict[str, Any] | None: and macOS Keychain. Returns the credential dict or None. """ try: - from agent.anthropic_adapter import ( - is_claude_code_token_valid, - read_claude_code_credentials, + from api.agent_compat import agent_attr + + is_claude_code_token_valid = agent_attr( + "agent.anthropic_adapter", "is_claude_code_token_valid", "agent.anthropic_credentials" + ) + read_claude_code_credentials = agent_attr( + "agent.anthropic_adapter", "read_claude_code_credentials", "agent.anthropic_credentials" ) creds = read_claude_code_credentials() diff --git a/api/routes.py b/api/routes.py index 4cdb91c5..7f42ed9d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -28836,7 +28836,8 @@ def _mcp_runtime_status_by_name() -> dict[str, dict]: is unavailable, fall back to an empty map so the API remains safe. """ try: - from tools.mcp_tool import get_mcp_status + from api.agent_compat import agent_attr + get_mcp_status = agent_attr("tools.mcp_tool", "get_mcp_status", "tools.mcp_tool_discovery") statuses = get_mcp_status() except Exception: return {} diff --git a/api/streaming.py b/api/streaming.py index beab649b..71cf8208 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2981,7 +2981,10 @@ def _set_turn_session_identity(session_id: str, workspace: str = ""): sid = str(session_id or "") tokens: dict = {} try: - from tools.approval import set_current_session_key + from api.agent_compat import agent_attr + set_current_session_key = agent_attr( + "tools.approval", "set_current_session_key", "tools.approval_context" + ) tokens["approval"] = set_current_session_key(sid) except Exception: logger.debug("per-turn approval session-key bind failed", exc_info=True) @@ -3043,7 +3046,10 @@ def _reset_turn_session_identity(tokens) -> None: tok = tokens.get("approval") if tok is not None: try: - from tools.approval import reset_current_session_key + from api.agent_compat import agent_attr + reset_current_session_key = agent_attr( + "tools.approval", "reset_current_session_key", "tools.approval_context" + ) reset_current_session_key(tok) except Exception: logger.debug("per-turn approval session-key reset failed", exc_info=True) @@ -9857,7 +9863,8 @@ def _run_agent_streaming( # lives outside this WebUI repo. This change fixes the headline bug # for users who run a single non-default profile per WebUI process. try: - from tools.mcp_tool import discover_mcp_tools + from api.agent_compat import agent_attr + discover_mcp_tools = agent_attr("tools.mcp_tool", "discover_mcp_tools", "tools.mcp_tool_discovery") discover_mcp_tools() except Exception: pass # MCP not available or not configured — non-fatal diff --git a/tests/test_agent_compat.py b/tests/test_agent_compat.py new file mode 100644 index 00000000..e489624b --- /dev/null +++ b/tests/test_agent_compat.py @@ -0,0 +1,127 @@ +"""api.agent_compat.agent_attr resolves Hermes Agent names that moved modules. + +The Agent's September 2026 decomposition moved names such as +``tools.approval.set_current_session_key`` into sibling modules. The old paths +resolved only through warning ``__getattr__`` pointers that are removed on +schedule; with the pointers gone, the WebUI's broad ``except Exception`` call +sites silently dropped approval session binding, MCP discovery/status, Claude +Code credential linking, and every kanban connection. +""" + +import sys +import types +import warnings + +import pytest + +from api.agent_compat import agent_attr + + +class _CompatWarning(FutureWarning): + pass + + +def _real(value): + return value + + +def _install(monkeypatch, name, **attrs): + mod = types.ModuleType(name) + for key, value in attrs.items(): + setattr(mod, key, value) + monkeypatch.setitem(sys.modules, name, mod) + return mod + + +def _split_facade(monkeypatch, *, with_pointer): + """An Agent after the split: the name lives in the home module; the facade + optionally keeps a warning PEP 562 pointer (the temporary compat layer).""" + _install(monkeypatch, "fakeagent_home", moved=_real) + facade = _install(monkeypatch, "fakeagent_facade", native=_real) + if with_pointer: + def __getattr__(name): + if name == "moved": + warnings.warn("`fakeagent_facade.moved` moved", _CompatWarning, stacklevel=2) + return _real + raise AttributeError(name) + facade.__getattr__ = __getattr__ + return facade + + +def test_pre_split_agent_uses_original_module(monkeypatch): + orig = _install(monkeypatch, "fakeagent_facade", moved=lambda: "pre-split") + assert agent_attr("fakeagent_facade", "moved", "fakeagent_missing_home")() == "pre-split" + assert agent_attr(orig, "moved", "fakeagent_missing_home")() == "pre-split" + + +def test_split_agent_with_pointer_uses_home_without_compat_warning(monkeypatch): + _split_facade(monkeypatch, with_pointer=True) + with warnings.catch_warnings(): + warnings.simplefilter("error", _CompatWarning) + assert agent_attr("fakeagent_facade", "moved", "fakeagent_home") is _real + + +def test_split_agent_after_pointer_removal_uses_home(monkeypatch): + facade = _split_facade(monkeypatch, with_pointer=False) + with pytest.raises(AttributeError): + _ = facade.moved # what `from facade import moved` hits after the removal + assert agent_attr(facade, "moved", "fakeagent_home") is _real + + +def test_stub_on_original_module_still_wins(monkeypatch): + _split_facade(monkeypatch, with_pointer=False) + stub = lambda: "stub" # noqa: E731 + monkeypatch.setattr(sys.modules["fakeagent_facade"], "moved", stub, raising=False) + assert agent_attr("fakeagent_facade", "moved", "fakeagent_home") is stub + + +def test_non_module_double_uses_plain_attribute(monkeypatch): + class FakeKanbanDB: + def connect(self, *, board=None): + return board + + double = FakeKanbanDB() + assert agent_attr(double, "connect", "fakeagent_home")(board="b") == "b" + assert agent_attr(double, "connect_closing", "fakeagent_home", None) is None + + +def test_default_when_name_is_absent_everywhere(monkeypatch): + _split_facade(monkeypatch, with_pointer=False) + assert agent_attr("fakeagent_facade", "nope", "fakeagent_home", None) is None + with pytest.raises(AttributeError): + agent_attr("fakeagent_facade", "nope", "fakeagent_home") + + +def test_missing_agent_raises_import_error_or_returns_default(): + with pytest.raises(ImportError): + agent_attr("fakeagent_not_installed", "moved", "fakeagent_not_installed_home") + assert agent_attr("fakeagent_not_installed", "moved", "fakeagent_not_installed_home", None) is None + + +def test_webui_does_not_use_removed_old_agent_paths(): + """Guard the old paths the Agent removed from being reintroduced in api/.""" + import ast + from pathlib import Path + + moved_imports = { + "tools.approval": {"set_current_session_key", "reset_current_session_key"}, + "tools.mcp_tool": {"discover_mcp_tools", "shutdown_mcp_servers", "get_mcp_status"}, + "agent.anthropic_adapter": {"read_claude_code_credentials", "is_claude_code_token_valid"}, + "hermes_cli.models": {"lmstudio_model_reasoning_options"}, + } + moved_kb = {"connect", "connect_closing", "dispatch_once"} + offenders = [] + for path in sorted((Path(__file__).resolve().parent.parent / "api").glob("*.py")): + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.ImportFrom) and node.module in moved_imports: + offenders += [(path.name, node.lineno, a.name) for a in node.names + if a.name in moved_imports[node.module]] + elif (isinstance(node, ast.Attribute) and node.attr in moved_kb + and isinstance(node.value, ast.Name) and node.value.id == "kb"): + offenders.append((path.name, node.lineno, f"kb.{node.attr}")) + elif (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + and node.func.id in {"getattr", "hasattr"} and len(node.args) >= 2 + and isinstance(node.args[0], ast.Name) and node.args[0].id == "kb" + and isinstance(node.args[1], ast.Constant) and node.args[1].value in moved_kb): + offenders.append((path.name, node.lineno, f"{node.func.id}(kb, {node.args[1].value!r})")) + assert not offenders, f"old agent paths (use api.agent_compat.agent_attr): {offenders}" From 8abf95335bc18501bd48aa34d415c3552c229f40 Mon Sep 17 00:00:00 2001 From: Mike McHargue Date: Mon, 14 Sep 2026 18:42:38 -0700 Subject: [PATCH 087/446] test+docs: exercise moved-name call sites; document the agent_compat contract Review follow-up for the Hermes Agent moved-name fix. Tests: replace the AST source guard with behavior tests that run each affected WebUI entry point against two fake Agent shapes, pre_split (name on the original module) and pointer_removed (name only in its new home): approval session identity bind/reset, MCP runtime status, /reload-mcp, Claude Code credential read, LM Studio reasoning options, and kanban connections (closed after use), board counts, SSE event feed, and dispatch. All 8 pointer_removed cases fail against the pre-fix call sites; the pre_split cases pass on both. Docs: ARCHITECTURE.md gains the api/agent_compat.py inventory entry and section 4.9 covering ownership (Agent's compat_manifest.json), resolution order, call-site rules, and the removal policy. Co-Authored-By: Claude Opus 5 --- ARCHITECTURE.md | 34 ++++++ tests/test_agent_compat.py | 226 ++++++++++++++++++++++++++++++++----- 2 files changed, 233 insertions(+), 27 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d465c724..72be8bc8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -56,6 +56,7 @@ actions. The topbar remains focused on conversation context and the workspace/fi .dockerignore Excludes .git, tests/, .env* from Docker builds api/ __init__.py Package marker + agent_compat.py Resolver for Hermes Agent names moved to sibling modules (compatibility-only) auth.py Optional password authentication, signed cookies, passkeys/WebAuthn config.py Discovery, globals, model detection, reloadable config helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers @@ -418,6 +419,39 @@ whose default `SessionDB()` path remains frozen at module import. Keep this fall compatibility-only: new goal semantics belong in Hermes Agent's native manager rather than a second WebUI implementation. +### 4.9 Hermes Agent Moved-Name Compatibility + +Hermes Agent owns its module layout. Its September 2026 decomposition moved names the +WebUI uses (for example `tools.approval.set_current_session_key` to +`tools.approval_context`) into `_` sibling modules. The old paths resolve +only through temporary PLUGIN-COMPAT `__getattr__` pointers that emit +`HermesPluginCompatWarning` and are removed on schedule. The Agent's +`compat_manifest.json` is the authoritative map of what moved where. + +WebUI code reaches a moved name only through +`api.agent_compat.agent_attr(owner, name, home, default=...)`, which resolves in this +order: + +1. the owner module's own namespace: pre-split Agents, and tests that stub the original + module in `sys.modules` or patch the name onto it; +2. the `home` module: split Agents, with or without the old-path pointer (no warning); +3. plain attribute access on the owner: non-module test doubles. + +It raises like the import it replaces (or returns `default`), so each call site keeps its +existing fallback. Current users: approval session identity and MCP discovery +(`streaming.py`), `/reload-mcp` (`commands.py`), MCP runtime status (`routes.py`), Claude +Code credential linking (`oauth.py`), LM Studio reasoning options (`config.py`), and +kanban connections and dispatch (`kanban_bridge.py`). + +Import names that are still native to their module directly. Never +`from import `, and never feature-detect a moved name with +`hasattr`/`getattr` on the old module: once the pointers are removed those silently turn +"moved" into "missing" behind the call sites' broad `except` blocks. When a later Agent +split moves another name, route it through `agent_attr` and add pre-split and +pointer-removed cases to `tests/test_agent_compat.py`. The resolver is +compatibility-only: delete it, and import directly from the new homes, once the WebUI +stops supporting Agents that predate the split. + --- ## 5. Frontend Architecture: Current State diff --git a/tests/test_agent_compat.py b/tests/test_agent_compat.py index e489624b..883f8eb2 100644 --- a/tests/test_agent_compat.py +++ b/tests/test_agent_compat.py @@ -1,4 +1,4 @@ -"""api.agent_compat.agent_attr resolves Hermes Agent names that moved modules. +"""Hermes Agent names that moved modules keep working at every WebUI call site. The Agent's September 2026 decomposition moved names such as ``tools.approval.set_current_session_key`` into sibling modules. The old paths @@ -6,16 +6,27 @@ resolved only through warning ``__getattr__`` pointers that are removed on schedule; with the pointers gone, the WebUI's broad ``except Exception`` call sites silently dropped approval session binding, MCP discovery/status, Claude Code credential linking, and every kanban connection. + +The call-site tests run each affected WebUI entry point against two fake Agent +shapes: ``pre_split`` (the name still lives on its original module) and +``pointer_removed`` (the name lives only in its new home module, and the +original module no longer resolves it). """ +import contextlib +import sqlite3 import sys +import threading import types import warnings +from types import SimpleNamespace import pytest from api.agent_compat import agent_attr +SHAPES = ("pre_split", "pointer_removed") + class _CompatWarning(FutureWarning): pass @@ -33,6 +44,25 @@ def _install(monkeypatch, name, **attrs): return mod +def _install_agent(monkeypatch, shape, facade, moved, native=None): + """Install a fake Agent module ``facade``. + + ``moved`` maps each new home module to the ``{name: object}`` it now owns. + ``pre_split`` puts those names on ``facade``; ``pointer_removed`` installs + them only in their home modules. ``native`` names always stay on ``facade``. + """ + names = dict(native or {}) + for home, attrs in moved.items(): + if shape == "pre_split": + names.update(attrs) + else: + _install(monkeypatch, home, **attrs) + return _install(monkeypatch, facade, **names) + + +# ── resolver ───────────────────────────────────────────────────────────────── + + def _split_facade(monkeypatch, *, with_pointer): """An Agent after the split: the name lives in the home module; the facade optionally keeps a warning PEP 562 pointer (the temporary compat layer).""" @@ -98,30 +128,172 @@ def test_missing_agent_raises_import_error_or_returns_default(): assert agent_attr("fakeagent_not_installed", "moved", "fakeagent_not_installed_home", None) is None -def test_webui_does_not_use_removed_old_agent_paths(): - """Guard the old paths the Agent removed from being reintroduced in api/.""" - import ast - from pathlib import Path +# ── WebUI call sites ───────────────────────────────────────────────────────── - moved_imports = { - "tools.approval": {"set_current_session_key", "reset_current_session_key"}, - "tools.mcp_tool": {"discover_mcp_tools", "shutdown_mcp_servers", "get_mcp_status"}, - "agent.anthropic_adapter": {"read_claude_code_credentials", "is_claude_code_token_valid"}, - "hermes_cli.models": {"lmstudio_model_reasoning_options"}, - } - moved_kb = {"connect", "connect_closing", "dispatch_once"} - offenders = [] - for path in sorted((Path(__file__).resolve().parent.parent / "api").glob("*.py")): - for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): - if isinstance(node, ast.ImportFrom) and node.module in moved_imports: - offenders += [(path.name, node.lineno, a.name) for a in node.names - if a.name in moved_imports[node.module]] - elif (isinstance(node, ast.Attribute) and node.attr in moved_kb - and isinstance(node.value, ast.Name) and node.value.id == "kb"): - offenders.append((path.name, node.lineno, f"kb.{node.attr}")) - elif (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) - and node.func.id in {"getattr", "hasattr"} and len(node.args) >= 2 - and isinstance(node.args[0], ast.Name) and node.args[0].id == "kb" - and isinstance(node.args[1], ast.Constant) and node.args[1].value in moved_kb): - offenders.append((path.name, node.lineno, f"{node.func.id}(kb, {node.args[1].value!r})")) - assert not offenders, f"old agent paths (use api.agent_compat.agent_attr): {offenders}" + +@pytest.mark.parametrize("shape", SHAPES) +def test_turn_session_identity_binds_and_resets_agent_approval_key(monkeypatch, shape): + import api.streaming as streaming + + calls = [] + + def set_current_session_key(sid): + calls.append(("set", sid)) + return f"token-{sid}" + + def reset_current_session_key(token): + calls.append(("reset", token)) + + _install_agent(monkeypatch, shape, "tools.approval", {"tools.approval_context": { + "set_current_session_key": set_current_session_key, + "reset_current_session_key": reset_current_session_key, + }}) + + tokens = streaming._set_turn_session_identity("sess-1") + assert tokens.get("approval") == "token-sess-1" + streaming._reset_turn_session_identity(tokens) + assert calls == [("set", "sess-1"), ("reset", "token-sess-1")] + + +@pytest.mark.parametrize("shape", SHAPES) +def test_mcp_runtime_status_reads_agent_registry(monkeypatch, shape): + import api.routes as routes + + statuses = [{"name": "alpha", "connected": True}, {"name": "beta", "connected": False}] + _install_agent(monkeypatch, shape, "tools.mcp_tool", {"tools.mcp_tool_discovery": { + "get_mcp_status": lambda: statuses, + }}) + + assert routes._mcp_runtime_status_by_name() == {"alpha": statuses[0], "beta": statuses[1]} + + +@pytest.mark.parametrize("shape", SHAPES) +def test_reload_mcp_command_shuts_down_and_rediscovers(monkeypatch, shape): + import api.commands as commands + + servers = {"old": object()} + calls = [] + + def shutdown_mcp_servers(): + calls.append("shutdown") + servers.clear() + + def discover_mcp_tools(): + calls.append("discover") + servers.update(old=object(), new=object()) + return ["t1", "t2", "t3"] + + _install_agent( + monkeypatch, shape, "tools.mcp_tool", + { + "tools.mcp_tool_lifecycle": {"shutdown_mcp_servers": shutdown_mcp_servers}, + "tools.mcp_tool_discovery": {"discover_mcp_tools": discover_mcp_tools}, + }, + native={"_servers": servers, "_lock": threading.Lock()}, + ) + + out = commands._run_reload_mcp_command() + assert calls == ["shutdown", "discover"] + assert "Reconnected: old" in out + assert "Added: new" in out + assert "3 tool(s) available across 2 server(s)" in out + + +@pytest.mark.parametrize("shape", SHAPES) +def test_claude_code_credentials_read_through_agent(monkeypatch, shape): + import api.oauth as oauth + + creds = {"accessToken": "cc-access", "refreshToken": "cc-refresh"} + _install_agent(monkeypatch, shape, "agent.anthropic_adapter", {"agent.anthropic_credentials": { + "read_claude_code_credentials": lambda: creds, + "is_claude_code_token_valid": lambda value: value is creds, + }}) + + assert oauth._read_claude_code_credentials() is creds + + +@pytest.mark.parametrize("shape", SHAPES) +def test_lmstudio_reasoning_options_use_agent_probe(monkeypatch, shape): + import api.config as config + + seen = [] + + def lmstudio_model_reasoning_options(model, base_url, api_key=None, timeout=5.0): + seen.append((model, base_url, timeout)) + return ["low", "high"] + + monkeypatch.setattr(config, "_lmstudio_reasoning_probe_options_fallback", lambda *a, **k: ["fallback"]) + _install_agent(monkeypatch, shape, "hermes_cli.models", {"hermes_cli.models_local": { + "lmstudio_model_reasoning_options": lmstudio_model_reasoning_options, + }}) + + assert config._lmstudio_model_reasoning_options("qwen", "http://127.0.0.1:1234", timeout=2.0) == ["low", "high"] + assert seen == [("qwen", "http://127.0.0.1:1234", 2.0)] + + +_KANBAN_SQL = """ +CREATE TABLE tasks (id INTEGER PRIMARY KEY, status TEXT); +INSERT INTO tasks (status) VALUES ('todo'), ('todo'), ('done'), ('archived'); +CREATE TABLE task_events ( + id INTEGER PRIMARY KEY, task_id TEXT, run_id TEXT, kind TEXT, payload TEXT, created_at INTEGER +); +INSERT INTO task_events VALUES (1, 't1', NULL, 'created', '{"a": 1}', 100), (2, 't2', 'r1', 'moved', NULL, 101); +""" + + +def _install_kanban_agent(monkeypatch, shape): + import api.kanban_bridge as bridge + + def connect(*, board=None): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.executescript(_KANBAN_SQL) + return conn + + def connect_closing(*, board=None): + return contextlib.closing(connect(board=board)) + + def dispatch_once(conn, dry_run=False, max_spawn=8): + tasks = conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] + return {"dry_run": dry_run, "max_spawn": max_spawn, "tasks_seen": tasks} + + kb = _install_agent( + monkeypatch, shape, "hermes_cli.kanban_db", + { + "hermes_cli.kanban_db_connect": {"connect": connect, "connect_closing": connect_closing}, + "hermes_cli.kanban_db_dispatch": {"dispatch_once": dispatch_once}, + }, + native={"init_db": lambda *, board=None: None, "board_exists": lambda slug: True, "DEFAULT_BOARD": "default"}, + ) + pkg = types.ModuleType("hermes_cli") + pkg.kanban_db = kb + monkeypatch.setitem(sys.modules, "hermes_cli", pkg) + return bridge + + +@pytest.mark.parametrize("shape", SHAPES) +def test_kanban_connection_is_closed_after_use(monkeypatch, shape): + bridge = _install_kanban_agent(monkeypatch, shape) + + with bridge._conn(board=None) as conn: + assert conn.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 4 + with pytest.raises(sqlite3.ProgrammingError): + conn.execute("SELECT 1") # connect_closing, not a leaked raw connection + + +@pytest.mark.parametrize("shape", SHAPES) +def test_kanban_counts_and_event_feed_read_agent_db(monkeypatch, shape): + bridge = _install_kanban_agent(monkeypatch, shape) + + assert bridge._board_counts_for_slug("default") == {"todo": 2, "done": 1} + cursor, events = bridge._kanban_sse_fetch_new(None, 0) + assert cursor == 2 + assert [(e["id"], e["kind"], e["payload"]) for e in events] == [(1, "created", {"a": 1}), (2, "moved", None)] + + +@pytest.mark.parametrize("shape", SHAPES) +def test_kanban_dispatch_runs_agent_dispatcher(monkeypatch, shape): + bridge = _install_kanban_agent(monkeypatch, shape) + + parsed = SimpleNamespace(path="/api/kanban/dispatch", query="dry_run=true&max=3") + assert bridge._dispatch_payload(parsed) == {"dry_run": True, "max_spawn": 3, "tasks_seen": 4} From 16ebfcace80dae0aeb5dddd14e8b715e1fb02534 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 17 Sep 2026 21:58:48 +0000 Subject: [PATCH 088/446] changelog: zip media delivery (#7612) + Agent moved-name resolution (#7574) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74c6fb52..75219873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### Fixed +- **`.zip` artifacts now open from chat instead of silently failing.** A `MEDIA:/path/to/file.zip` link always failed to open: `.zip` was absent from `MIME_MAP`, so archives resolved to `application/octet-stream`, which the session `MEDIA:` token allow-list rejects. `.zip` now maps to `application/zip` and archives are admitted to the session-token type set. Archives are download-only by construction — the new `_ARCHIVE_TYPES` set is never added to either inline-preview set, so a zip always gets `Content-Disposition: attachment` and can never render inline (verified including `inline=1` and an uppercase `.ZIP` suffix). Path confinement is unchanged: a session token still grants only the exact canonical path an assistant-emitted token referenced, and the state/secret deny guard still runs ahead of the allow decision. Thanks @carlotestor. (#7612) +- **WebUI no longer depends on Hermes Agent compatibility shims that have already passed their removal date.** The Agent's September 2026 decomposition moved several names WebUI imports (`tools.approval`, `tools.mcp_tool`, `hermes_cli.kanban_db`, …) into sibling modules, leaving the old paths resolvable only through PEP 562 `__getattr__` pointers scheduled for deletion on 2026-09-14. Because every affected call site is wrapped in a broad `except`, losing those pointers would not have crashed anything — features would have degraded *silently*: MCP tools missing from turns, the per-turn approval session key never binding, Claude Code credential linking stopping, and the kanban bridge raising `AttributeError`. A new `api/agent_compat.py::agent_attr()` resolves each moved name from the original module's own namespace first (pre-split Agents and test stubs), then the new home module (split Agents, with or without the pointers), then plain attribute access — so WebUI works across all three Agent shapes and stops emitting `HermesPluginCompatWarning`. Thanks @mikemchargue. (#7574) - **Typing in a long session no longer lags.** The composer's `autoResize()` has a fast path meant to skip an expensive measure-and-restore layout on every keystroke, but the guard enabling it compared the composer height against its CSS `min-height` (44px) rather than the height a single row actually settles at (line-height + padding + borders, ~48px), so the skip never fired in a real browser. Every keystroke then forced a full-document synchronous layout whose cost grows with the rendered transcript — on a ~750-message session this was ~168ms of echo latency per key versus ~72ms with the transcript detached. The fast path now compares against the real one-row height (tracking the appearance font size), so single-row typing skips the layout while any append that genuinely needs a taller composer still triggers a full resize. Thanks @KevinMeinon. (#7604) - **Live-follow no longer breaks mid-stream when content above the tail collapses.** A reader pinned flush at the bottom of the transcript was being unpinned whenever a section *above* the tail collapsed — a worklog "Done" fold, a thinking/tool card collapse, an interim-note collapse. The collapse shrinks the transcript height, the browser clamps the scroll position downward and fires a scroll event, and that was misread as the user scrolling away, killing live-follow while a response was still streaming. The scroll listener now only treats an upward scroll as intent when the reader has actually left the bottom, so an above-tail collapse keeps following the stream. Genuine upward scrolls still unpin immediately. Thanks @KevinMeinon. (#7605) - **Sidebar source-filter tabs are now localized.** The WebUI / CLI session-source tabs in the sidebar were hardcoded English (`WebUI sessions (N)` / `CLI sessions (N)`) and assigned straight to `textContent`, so they stayed English under every non-English locale. They now route through `t()` with the standard `{0}` count placeholder and are translated across all shipped locales. Thanks @Yularzhi. (#7602, #7580) From 7003f9dc5bdf34c111beffb39db12a5211f07148 Mon Sep 17 00:00:00 2001 From: hermes Date: Thu, 17 Sep 2026 19:28:30 +0100 Subject: [PATCH 089/446] fix(webui): let /skills write-approval subcommands fall through to the agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmdSkills intercepted every /skills argument as a local search filter, so /skills pending|approve|reject|diff|approval|mode never reached the agent's own write-approval handler (hermes_cli/write_approval_commands.py via gateway/slash_commands.py) — pending memory/skill writes staged by skills.write_approval could not be reviewed or approved from the WebUI at all, only via CLI/gateway. cmdSkills now checks the first argument against the known agent subcommand names and returns false to opt out of local handling (matching the existing /reasoning fall-through convention), letting those subcommands reach the agent like any other unrecognized text. Co-Authored-By: Claude Sonnet 5 --- static/commands.js | 88 ++++++++++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 39 deletions(-) diff --git a/static/commands.js b/static/commands.js index e322c1d8..3daf787d 100644 --- a/static/commands.js +++ b/static/commands.js @@ -1101,47 +1101,57 @@ async function cmdTheme(args){ showToast(t('theme_usage')+themes.join('|')+' | '+skins.join('|')+' | legacy:'+legacyThemes.join('|')); } -async function cmdSkills(args){ - try{ - const data = await api('/api/skills'); - let skills = data.skills || []; - if(args){ - const q = args.toLowerCase(); - skills = skills.filter(s => - (s.name||'').toLowerCase().includes(q) || - (s.description||'').toLowerCase().includes(q) || - (s.category||'').toLowerCase().includes(q) - ); - } - if(!skills.length){ - const msg = {role:'assistant', content: args ? `No skills matching "${args}".` : 'No skills found.'}; - S.messages.push(msg); renderMessages(); return; - } - // Group by category - const byCategory = {}; - skills.forEach(s => { - const cat = s.category || 'General'; - if(!byCategory[cat]) byCategory[cat] = []; - byCategory[cat].push(s); - }); - const lines = []; - for(const [cat, items] of Object.entries(byCategory).sort()){ - lines.push(`**${cat}**`); - items.forEach(s => { - const desc = s.description ? ` — ${s.description.slice(0,80)}${s.description.length>80?'...':''}` : ''; - lines.push(` \`${s.name}\`${desc}`); +// Subcommands owned by the agent's own /skills write-approval handler +// (hermes_cli/write_approval_commands.py via gateway/slash_commands.py) — these must +// fall through to the normal send path rather than be swallowed by the local search below. +const SKILLS_AGENT_SUBCOMMANDS=['pending','approve','reject','diff','approval','mode']; + +function cmdSkills(args){ + const sub=(args||'').trim().split(/\s+/)[0].toLowerCase(); + if(SKILLS_AGENT_SUBCOMMANDS.includes(sub)) return false; + (async()=>{ + try{ + const data = await api('/api/skills'); + let skills = data.skills || []; + if(args){ + const q = args.toLowerCase(); + skills = skills.filter(s => + (s.name||'').toLowerCase().includes(q) || + (s.description||'').toLowerCase().includes(q) || + (s.category||'').toLowerCase().includes(q) + ); + } + if(!skills.length){ + const msg = {role:'assistant', content: args ? `No skills matching "${args}".` : 'No skills found.'}; + S.messages.push(msg); renderMessages(); return; + } + // Group by category + const byCategory = {}; + skills.forEach(s => { + const cat = s.category || 'General'; + if(!byCategory[cat]) byCategory[cat] = []; + byCategory[cat].push(s); }); - lines.push(''); + const lines = []; + for(const [cat, items] of Object.entries(byCategory).sort()){ + lines.push(`**${cat}**`); + items.forEach(s => { + const desc = s.description ? ` — ${s.description.slice(0,80)}${s.description.length>80?'...':''}` : ''; + lines.push(` \`${s.name}\`${desc}`); + }); + lines.push(''); + } + const header = args + ? `Skills matching "${args}" (${skills.length}):\n\n` + : `Available skills (${skills.length}):\n\n`; + S.messages.push({role:'assistant', content: header + lines.join('\n')}); + renderMessages(); + showToast(t('type_slash')); + }catch(e){ + showToast('Failed to load skills: '+e.message); } - const header = args - ? `Skills matching "${args}" (${skills.length}):\n\n` - : `Available skills (${skills.length}):\n\n`; - S.messages.push({role:'assistant', content: header + lines.join('\n')}); - renderMessages(); - showToast(t('type_slash')); - }catch(e){ - showToast('Failed to load skills: '+e.message); - } + })(); + return true; } async function cmdUse(args){ From a865c7f68802ccc950bd6eb7e66f8663c8ffe209 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 17 Sep 2026 22:56:36 +0000 Subject: [PATCH 090/446] fix(webui): also fall through the approve/reject/approval aliases The agent's handle_pending_subcommand() accepts aliases the initial list missed: approve|apply, reject|deny|drop. Those three were still swallowed by the local /skills search, so '/skills apply 3' and '/skills deny 3' silently ran a skill-name search instead of reaching the write-approval handler. Completes the class rather than the reported instance. Co-authored-by: totalitarian --- static/commands.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/commands.js b/static/commands.js index 3daf787d..6df02358 100644 --- a/static/commands.js +++ b/static/commands.js @@ -1104,7 +1104,9 @@ async function cmdTheme(args){ // Subcommands owned by the agent's own /skills write-approval handler // (hermes_cli/write_approval_commands.py via gateway/slash_commands.py) — these must // fall through to the normal send path rather than be swallowed by the local search below. -const SKILLS_AGENT_SUBCOMMANDS=['pending','approve','reject','diff','approval','mode']; +// Includes every alias that handler accepts: approve/apply, reject/deny/drop, approval/mode. +// Keep in sync with handle_pending_subcommand() — a missing alias is silently swallowed here. +const SKILLS_AGENT_SUBCOMMANDS=['pending','approve','apply','reject','deny','drop','diff','approval','mode']; function cmdSkills(args){ const sub=(args||'').trim().split(/\s+/)[0].toLowerCase(); From 4ba80ced1253db032833aa3cde5070ffc44498b3 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 17 Sep 2026 23:01:06 +0000 Subject: [PATCH 091/446] changelog: AGENTS.md task-scoped pointers (#7593) + /api/models cache contract doc (#7560) --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75219873..c61bece8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +### Documentation + +- **`AGENTS.md` now routes contributors to the references that match their change.** The old "read first" list asked for four files up front regardless of what was being changed, and carried a compressed copy of the ten change guidelines that `docs/GUIDELINES.md` owns. It now maps each reference to the kind of work it applies to and states explicit completion/verification criteria instead. No information is lost — the ten rules remain in `docs/GUIDELINES.md`, which the new version still points to. Thanks @steveafrost. (#7593) +- **The `/api/models` cache invalidation contract is documented.** `#7556` shipped a change to the catalog cache's source fingerprint, and its review flagged the surrounding contract as undocumented runtime behavior. `docs/architecture/models-cache-invalidation.md` now records what is cached (in-memory snapshot, per-profile `models_cache.json`, cold vs hot path), the three source axes (`config_yaml` stat identity, `auth_json` content hash with a volatile-key deny-list, baked-in plus Codex catalog hashes) and why each is fingerprinted the way it is, and the invariant that both volatile-key sets are deny-lists that may only remove fields which provably do not gate the provider/model set. Changes no runtime behavior. Thanks @webtecnica. (#7560, #7556) + ### Changed - **The chat composer grows natively instead of being resized by JavaScript on every keystroke.** `autoResize()` measured `scrollHeight` and wrote `style.height` on each input event — a forced synchronous reflow on the most-typed-in surface in the app. Browsers that support CSS `field-sizing: content` (Chromium today; also Firefox 152 and Safari 26.2) now own the geometry directly, gated on `CSS.supports()`, and the existing JavaScript path is untouched for every other engine. Measured behaviour is identical across both paths: 44px resting height, no jump when the first character is typed or the last deleted, growth to the 200px ceiling, then internal scrolling. Because `field-sizing` deliberately includes placeholder text in content sizing, `:placeholder-shown` pins fixed sizing while the composer is empty so a long placeholder can't inflate it. Thanks @starship-s. (#6760, #5514) From 6f05ff9657d4261dc4db839699da6ca2e3c4438b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 24 Aug 2026 19:57:15 +0000 Subject: [PATCH 092/446] perf(webui): stop re-redacting large strings on every response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_redact_fn_cached` skipped memoization for any string over 16KB, so those strings re-ran the full ~15-pass redactor on every single request. On a real 22MB session that is 59 large strings (29 unique, 0.76MB) costing 1.68s per request — 99.9% of the recurring redaction cost once the small-string cache is warm — repaid by every tab and every poll for a byte-identical result. Large strings now get their own bounded cache, separate from the 4096-entry small-string one so a large blob can never evict the thousands of small recurring strings. Entry count and per-entry size are both capped; anything above the ceiling stays uncached, keeping retained bytes bounded. `_redact_value` additionally stops rebuilding containers whose descendants were unchanged. Most of a transcript carries no credential marker, so the unconditional dict/list comprehension deep-copied the whole payload to reproduce an identical structure — pure CPU under the GIL, which serializes concurrent tab loads on a threaded server. Callers treat redacted output as read-only (`_public_message_projection` and `redact_session_data` both build fresh containers), so sharing an unmodified subtree cannot leak a later mutation back into session state. As soon as one string is masked, every container on the path to it is rebuilt and the caller's original is untouched. Measured on the live 22.6MB session, steady state: redact_session_data 3.78s -> 1.96s single tab (-48%), 7.93s -> 4.06s with two concurrent tabs (-49%), byte-identical output. --- api/helpers.py | 57 ++++++++- tests/test_redact_large_string_cache.py | 114 ++++++++++++++++++ tests/test_redact_value_no_copy_when_clean.py | 105 ++++++++++++++++ 3 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 tests/test_redact_large_string_cache.py create mode 100644 tests/test_redact_value_no_copy_when_clean.py diff --git a/api/helpers.py b/api/helpers.py index 9a8e7f36..c92a4518 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -472,10 +472,31 @@ _redact_fn_lru = functools.lru_cache(maxsize=4096)(_redact_fn_uncached) # thousands of small recurring strings that actually benefit, or balloon RSS. _REDACT_CACHE_MAX_TEXT_LEN = 16384 +# Strings above that threshold used to bypass memoization entirely and re-run +# the full ~15-pass redactor on every request. On a real 22MB session that was +# 59 large strings (29 unique, 0.76MB) costing 1.68s per request — 99.9% of the +# recurring redaction cost once the small-string cache is warm, paid again by +# every tab and every poll for a byte-identical result. +# +# They get their own small cache instead of sharing the 4096-entry one, so a +# large blob can never evict the thousands of small recurring strings. Both the +# entry count and the per-entry size are capped, which bounds retained bytes to +# roughly _REDACT_LARGE_CACHE_SIZE * _REDACT_LARGE_CACHE_MAX_TEXT_LEN (key plus +# value). Anything larger stays uncached: unbounded growth is the failure mode +# this threshold exists to prevent. +_REDACT_LARGE_CACHE_MAX_TEXT_LEN = 131072 +_REDACT_LARGE_CACHE_SIZE = 64 + +_redact_fn_large_lru = functools.lru_cache(maxsize=_REDACT_LARGE_CACHE_SIZE)( + _redact_fn_uncached +) + def _redact_fn_cached(text): if len(text) > _REDACT_CACHE_MAX_TEXT_LEN: - return _redact_fn_uncached(text) + if len(text) > _REDACT_LARGE_CACHE_MAX_TEXT_LEN: + return _redact_fn_uncached(text) + return _redact_fn_large_lru(text) return _redact_fn_lru(text) @@ -956,13 +977,43 @@ def _redact_value(v, *, _enabled: bool | None = None): ``_enabled`` is threaded through so a single response-level redact pass only reads settings.json once. (Opus pre-release perf fix.) + + Containers are rebuilt only when a descendant actually changed. The + overwhelming majority of a transcript carries no credential marker, so the + previous unconditional dict/list comprehension deep-copied the entire + payload — tens of MB per response — to reproduce an identical structure. + That copy is pure CPU under the GIL (allocation and refcounting never + release it), which serialized concurrent tab loads on a threaded server. + + Returning the original object when nothing changed is safe because callers + treat redacted output as read-only: ``_public_message_projection`` builds a + fresh ``item`` dict per message, and ``redact_session_data`` builds a fresh + ``result``. Nothing mutates a value returned from here in place, so sharing + an unmodified subtree cannot leak a later mutation back into session state. + The redacting path is unchanged: as soon as one string is masked, every + container on the path to it is rebuilt and the caller's original is left + untouched. """ if isinstance(v, str): return _redact_text(v, _enabled=_enabled) if isinstance(v, dict): - return {key: _redact_value(value, _enabled=_enabled) for key, value in v.items()} + changed = False + out = {} + for key, value in v.items(): + redacted = _redact_value(value, _enabled=_enabled) + out[key] = redacted + if redacted is not value: + changed = True + return out if changed else v if isinstance(v, list): - return [_redact_value(item, _enabled=_enabled) for item in v] + changed = False + out = [] + for item in v: + redacted = _redact_value(item, _enabled=_enabled) + out.append(redacted) + if redacted is not item: + changed = True + return out if changed else v return v diff --git a/tests/test_redact_large_string_cache.py b/tests/test_redact_large_string_cache.py new file mode 100644 index 00000000..d125adc1 --- /dev/null +++ b/tests/test_redact_large_string_cache.py @@ -0,0 +1,114 @@ +"""Grosses chaines: memoiser au lieu de repayer ~15 regex a chaque requete. + +``_redact_fn_cached`` excluait du LRU toute chaine > 16 384 caracteres, pour +eviter d'evincer les milliers de petites chaines recurrentes et de gonfler la +RSS. Consequence mesuree sur une session reelle de 22 Mo: 59 grosses chaines +(29 uniques, 0,76 Mo) repassaient l'integralite des regex a CHAQUE requete, +pour un resultat toujours identique — 1,68 s par requete, soit 99,9% du cout +recurrent de la redaction une fois le cache des petites chaines chaud. + +Le correctif ajoute un SECOND cache, dedie aux grosses chaines, borne en +nombre d'entrees ET par une taille maximale par entree, pour que la RSS reste +plafonnee. Les chaines geantes (> plafond) restent volontairement non cachees. + +Contrat verifie ici: + 1. une grosse chaine est redigee exactement comme avant; + 2. la meme grosse chaine n'est pas recalculee au second appel; + 3. les deux caches restent separes (pas d'eviction croisee); + 4. une chaine au-dela du plafond n'est jamais retenue. +""" +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from api import helpers # noqa: E402 +from api.helpers import ( # noqa: E402 + _REDACT_CACHE_MAX_TEXT_LEN, + _REDACT_LARGE_CACHE_MAX_TEXT_LEN, + _redact_fn_cached, + _redact_fn_uncached, +) + +SECRET = "ghp_0123456789abcdefghijklmnopqrstuvwxyzAB" + + +@pytest.fixture(autouse=True) +def _isolate_large_cache(): + """Ne pas laisser ce fichier polluer l'etat global du process. + + Le cache est un singleton de module partage par toute la suite. Le vider + sans le restaurer laisse les tests suivants demarrer a froid, ce qui + perturbe ceux qui dependent de fenetres de fraicheur ou de temps de + reponse. On repart d'un cache vide ET on le revide en sortie, pour que ce + fichier n'ait aucun effet observable en dehors de lui-meme. + """ + helpers._redact_fn_large_lru.cache_clear() + yield + helpers._redact_fn_large_lru.cache_clear() + + +def _big(secret: str, size: int) -> str: + """Chaine > seuil du petit cache, porteuse d'un secret. + + Le remplissage doit etre assez long pour atteindre ``size`` meme au-dela + du plafond du grand cache, sinon la troncature produit une chaine trop + courte et le test ne verifie plus ce qu'il annonce. + """ + unit = "lorem ipsum dolor sit amet " + filler = unit * (size // len(unit) + 1) + text = (filler + secret + filler)[:size] + assert len(text) == size + return text + + +def test_large_string_redaction_matches_uncached(): + text = _big(SECRET, _REDACT_CACHE_MAX_TEXT_LEN + 5000) + assert len(text) > _REDACT_CACHE_MAX_TEXT_LEN + assert _redact_fn_cached(text) == _redact_fn_uncached(text) + assert SECRET not in _redact_fn_cached(text) + + +def test_large_string_is_memoized(): + helpers._redact_fn_large_lru.cache_clear() + text = _big(SECRET, _REDACT_CACHE_MAX_TEXT_LEN + 7000) + + first = _redact_fn_cached(text) + misses_after_first = helpers._redact_fn_large_lru.cache_info().misses + + second = _redact_fn_cached(text) + info = helpers._redact_fn_large_lru.cache_info() + + assert second == first + assert info.hits >= 1, "grosse chaine recalculee au second appel" + assert info.misses == misses_after_first, "miss supplementaire inattendu" + + +def test_small_strings_do_not_use_the_large_cache(): + helpers._redact_fn_large_lru.cache_clear() + small = f"petit texte {SECRET}" + assert len(small) <= _REDACT_CACHE_MAX_TEXT_LEN + _redact_fn_cached(small) + info = helpers._redact_fn_large_lru.cache_info() + assert info.hits == 0 and info.misses == 0, "petite chaine routee vers le grand cache" + + +def test_giant_string_is_not_retained(): + """Au-dela du plafond: correct, mais jamais mis en cache (RSS bornee).""" + helpers._redact_fn_large_lru.cache_clear() + giant = _big(SECRET, _REDACT_LARGE_CACHE_MAX_TEXT_LEN + 10000) + assert len(giant) > _REDACT_LARGE_CACHE_MAX_TEXT_LEN + + out = _redact_fn_cached(giant) + + assert out == _redact_fn_uncached(giant) + assert SECRET not in out + info = helpers._redact_fn_large_lru.cache_info() + assert info.currsize == 0, "chaine geante retenue en cache" + + +def test_large_cache_is_bounded(): + assert helpers._redact_fn_large_lru.cache_info().maxsize is not None + assert helpers._redact_fn_large_lru.cache_info().maxsize <= 128 diff --git a/tests/test_redact_value_no_copy_when_clean.py b/tests/test_redact_value_no_copy_when_clean.py new file mode 100644 index 00000000..ffb8f875 --- /dev/null +++ b/tests/test_redact_value_no_copy_when_clean.py @@ -0,0 +1,105 @@ +"""Redaction: ne pas reconstruire ce qui n'a pas change. + +``_redact_value`` reconstruisait integralement chaque dict/liste, soit une +copie profonde de tout le payload a chaque reponse, meme quand rien n'est +redige. Sur une session de 22 Mo, 97,4% des chaines ne contiennent aucun +marqueur sensible: la copie est donc du travail pur perte, et elle tient le +GIL (json/allocations ne le liberent pas), ce qui serialise les onglets. + +Contrat verifie ici: + 1. Quand rien n'est redige, la valeur RENVOYEE EST l'objet d'origine + (identite, pas seulement egalite) -> zero allocation. + 2. Quand quelque chose est redige, un NOUVEL objet est renvoye et + l'original n'est PAS mute (fail-closed: pas de fuite par aliasing). + 3. Le resultat reste egal a celui de l'ancienne implementation. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from api.helpers import _redact_value # noqa: E402 + + +SECRET = "authorization=Bearer sk-live-abcdef0123456789" + + +def _reference_redact(v, *, _enabled): + """Implementation d'origine (copie systematique), pour comparaison.""" + from api.helpers import _redact_text + + if isinstance(v, str): + return _redact_text(v, _enabled=_enabled) + if isinstance(v, dict): + return {k: _reference_redact(x, _enabled=_enabled) for k, x in v.items()} + if isinstance(v, list): + return [_reference_redact(x, _enabled=_enabled) for x in v] + return v + + +def test_clean_payload_is_returned_by_identity(): + """Rien de sensible -> aucun dict/liste ne doit etre reconstruit.""" + payload = { + "messages": [ + {"role": "user", "content": "bonjour, ou en est la commande 4512 ?"}, + {"role": "assistant", "content": [{"type": "text", "text": "elle part demain"}]}, + ], + "meta": {"workspace": "/a0/usr/projects/MES", "count": 3, "ok": True}, + } + + out = _redact_value(payload, _enabled=True) + + assert out is payload, "payload propre reconstruit inutilement" + assert out["messages"] is payload["messages"] + assert out["messages"][0] is payload["messages"][0] + assert out["meta"] is payload["meta"] + + +def test_sensitive_payload_is_copied_and_original_untouched(): + """Quelque chose est redige -> nouvel objet, original intact.""" + inner = {"role": "user", "content": SECRET} + payload = {"messages": [inner], "meta": {"workspace": "/tmp"}} + + out = _redact_value(payload, _enabled=True) + + # un nouvel objet est renvoye sur le chemin modifie + assert out is not payload + assert out["messages"] is not payload["messages"] + assert out["messages"][0] is not inner + + # l'original n'est pas mute (pas de fuite par aliasing) + assert inner["content"] == SECRET + assert payload["messages"][0]["content"] == SECRET + + # le secret est bien masque dans la sortie + assert out["messages"][0]["content"] != SECRET + + # les branches NON modifiees restent partagees (c'est tout l'interet) + assert out["meta"] is payload["meta"] + + +def test_matches_reference_implementation(): + """Le resultat doit rester identique a l'implementation d'origine.""" + payload = { + "messages": [ + {"role": "user", "content": "texte normal"}, + {"role": "assistant", "content": SECRET}, + {"role": "user", "content": ["propre", SECRET, 42, None]}, + ], + "nested": {"a": {"b": {"c": SECRET}}, "d": {"e": "rien"}}, + "scalars": [1, 2.5, True, None], + } + + assert _redact_value(payload, _enabled=True) == _reference_redact(payload, _enabled=True) + + +def test_disabled_redaction_still_shares(): + """Redaction desactivee -> rien ne doit etre copie.""" + payload = {"messages": [{"role": "user", "content": SECRET}]} + out = _redact_value(payload, _enabled=False) + assert out is payload + + +def test_non_container_values_pass_through(): + for v in (42, 2.5, True, None): + assert _redact_value(v, _enabled=True) is v From b7f30c6365ddaaf870e06ca6921a629ecae8d349 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 24 Aug 2026 20:29:53 +0000 Subject: [PATCH 093/446] perf(webui): memoize the sensitive-text prefilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once large strings stopped being re-redacted on every response, cProfile showed the cheap prefilter had become the dominant cost of the redaction pass: 2.98s of 3.7s, spent testing 71 literal markers against each of the ~74k strings (~19MB) of a large session, i.e. ~3.1M interpreted iterations per request — repeated by every tab and every poll for a constant verdict. Memoize the verdict behind a bounded LRU, mirroring the existing redaction caches: entry count and per-entry size are both capped, so retained bytes stay bounded and oversized blobs simply run the scan uncached. Non-strings never reach the cache, so an unhashable value cannot raise. Measured on a real 22MB session, warm: full redaction 2.07s -> 1.07s (-49%), output byte-identical. Combined with the large-string cache, the recurring redaction cost drops from ~8.4s to ~1.1s. Rejected alternative, kept in a comment: collapsing the 71 `in` scans into one compiled alternation is the obvious-looking fix but measured 38% SLOWER (2.60s vs 1.88s) — CPython's substring search is already a tuned C routine. Security: the prefilter gates the entire redaction pass, so equivalence is pinned by a differential test suite against a copy of the previous implementation — every marker individually, case variants, embeddings, near-misses, unicode lowercase traps, 4000 fuzz cases, plus oversized and unhashable inputs. Verdicts are identical on all 73,658 strings of a real session (1935 flagged before and after). Broad selection: 22 failures before and after, identical set — all pre-existing. --- api/helpers.py | 40 ++++- tests/test_sensitive_prefilter_equivalence.py | 165 ++++++++++++++++++ 2 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 tests/test_sensitive_prefilter_equivalence.py diff --git a/api/helpers.py b/api/helpers.py index c92a4518..e9af3c47 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -580,7 +580,27 @@ _SENSITIVE_DISCORD_MARKER_RE = _re.compile(r"<@!?\d{17,20}>") _SENSITIVE_PHONE_MARKER_RE = _re.compile(r"(? bool: +# The prefilter runs on every string of every API response — ~74k strings / +# ~19MB on a real large session — and cProfile showed it costing 2.98s of the +# 3.7s redaction pass once the redactor's own caches are warm. The work is +# pure and deterministic (fixed marker tuples, fixed patterns), so identical +# text always yields the same verdict and is safe to memoize without +# invalidation, exactly like `_redact_fn_cached` above. +# +# Note on what was NOT done: replacing the 71 `in` scans with one compiled +# alternation looks like the obvious fix, but measured 38% SLOWER on the real +# payload (2.60s vs 1.88s). CPython's substring search is already a tuned +# C-level algorithm, and a large regex alternation has to try each branch at +# each position. Memoizing the verdict avoids the scan entirely instead. +# +# Bounds mirror the redaction caches: capping entry count and per-entry size +# keeps retained bytes to roughly size * max_len, so a burst of giant unique +# blobs cannot balloon RSS. Oversized strings simply run the scan uncached. +_SENSITIVE_PREFILTER_CACHE_SIZE = 8192 +_SENSITIVE_PREFILTER_MAX_TEXT_LEN = 16384 + + +def _might_contain_sensitive_text_uncached(text: str) -> bool: """Cheap prefilter before the full agent+fallback redaction pass.""" if not isinstance(text, str) or not text: return False @@ -598,6 +618,24 @@ def _might_contain_sensitive_text(text: str) -> bool: return False +_might_contain_sensitive_text_lru = functools.lru_cache( + maxsize=_SENSITIVE_PREFILTER_CACHE_SIZE +)(_might_contain_sensitive_text_uncached) + + +def _might_contain_sensitive_text(text: str) -> bool: + """Memoized wrapper around the prefilter. + + Falls back to the uncached scan for non-strings and oversized text so the + cache can never be poisoned by an unhashable value or grow unbounded. + """ + if not isinstance(text, str) or not text: + return False + if len(text) > _SENSITIVE_PREFILTER_MAX_TEXT_LEN: + return _might_contain_sensitive_text_uncached(text) + return _might_contain_sensitive_text_lru(text) + + def _redact_text(text: str, *, _enabled: bool | None = None) -> str: """Redact sensitive text from API responses. Respects api_redact_enabled setting. diff --git a/tests/test_sensitive_prefilter_equivalence.py b/tests/test_sensitive_prefilter_equivalence.py new file mode 100644 index 00000000..75f1dc07 --- /dev/null +++ b/tests/test_sensitive_prefilter_equivalence.py @@ -0,0 +1,165 @@ +"""The memoized prefilter must stay EXACTLY equivalent to the raw scan. + +`_might_contain_sensitive_text` is the gate in front of the whole redaction +pass: a string it rejects is never inspected for secrets. Caching its verdict +is only acceptable if the answer is identical for every input -- a single +missed marker would leak a credential into an API response. + +These tests pin the equivalence against a reference implementation that is a +literal copy of the pre-optimisation logic, so a future edit to the marker +lists or the cache wrapper is caught here rather than in production. + +They must not leave global state behind: the LRU is shared process-wide, so +each test that touches it restores it (see the autouse fixture). +""" + +import random +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from api import helpers # noqa: E402 +from api.helpers import ( # noqa: E402 + _SENSITIVE_CASE_MARKERS, + _SENSITIVE_LOWER_MARKERS, + _might_contain_sensitive_text, +) + + +@pytest.fixture(autouse=True) +def _isolate_prefilter_cache(): + """Keep the shared LRU out of other tests' way, before and after.""" + helpers._might_contain_sensitive_text_lru.cache_clear() + yield + helpers._might_contain_sensitive_text_lru.cache_clear() + + +def _reference(text) -> bool: + """The exact pre-optimisation implementation, kept as the oracle.""" + if not isinstance(text, str) or not text: + return False + if any(marker in text for marker in _SENSITIVE_CASE_MARKERS): + return True + lower = text.lower() + if any(marker in lower for marker in _SENSITIVE_LOWER_MARKERS): + return True + if ":" in text and helpers._SENSITIVE_TELEGRAM_MARKER_RE.search(text): + return True + if "<@" in text and helpers._SENSITIVE_DISCORD_MARKER_RE.search(text): + return True + if "+" in text and helpers._SENSITIVE_PHONE_MARKER_RE.search(text): + return True + return False + + +def test_every_case_marker_is_still_detected(): + """Each individual marker must trip the filter, alone and in context.""" + for marker in _SENSITIVE_CASE_MARKERS: + assert _might_contain_sensitive_text(marker), marker + assert _might_contain_sensitive_text(f"noise {marker} noise"), marker + + +def test_every_lower_marker_is_still_detected(): + for marker in _SENSITIVE_LOWER_MARKERS: + assert _might_contain_sensitive_text(marker), marker + # Upper-casing must not hide it: that is what the .lower() pass is for. + assert _might_contain_sensitive_text(f"NOISE {marker.upper()} NOISE"), marker + + +def test_matches_reference_on_marker_corpus(): + """Differential test over markers, cases and embeddings.""" + corpus = [] + for marker in list(_SENSITIVE_CASE_MARKERS) + list(_SENSITIVE_LOWER_MARKERS): + corpus += [ + marker, + marker.upper(), + marker.lower(), + marker.swapcase(), + f"prefix{marker}", + f"{marker}suffix", + f"a b {marker} c d", + marker[:-1] if len(marker) > 1 else marker, # near-miss + ] + for text in corpus: + assert _might_contain_sensitive_text(text) == _reference(text), repr(text) + + +def test_matches_reference_on_clean_and_edge_inputs(): + samples = [ + "", "hello world", "a" * 10_000, "\n\t ", "ré­sumé accentué", + "https", "http", "user@example.com", "1234567890", + "+33612345678", # phone marker + "<@123456789012345678>", # discord marker + "1234567890:AAHnotarealtelegramtokenvaluehere123", # telegram marker + "İstanbul", "file", "K", # unicode lowercase traps + "not_a_secret_at_all", "{}", "[]", "null", + ] + for text in samples: + assert _might_contain_sensitive_text(text) == _reference(text), repr(text) + + +def test_matches_reference_on_random_fuzz(): + """Random strings built from marker fragments must agree with the oracle.""" + rnd = random.Random(20260824) + alphabet = "abcXYZ_-.:/+<@0189{}\"' \n" + fragments = [m[: rnd.randint(1, len(m))] for m in _SENSITIVE_CASE_MARKERS] + fragments += [m[: rnd.randint(1, len(m))] for m in _SENSITIVE_LOWER_MARKERS] + for _ in range(4000): + n = rnd.randint(0, 40) + text = "".join(rnd.choice(alphabet) for _ in range(n)) + if rnd.random() < 0.4 and fragments: + frag = rnd.choice(fragments) + pos = rnd.randint(0, len(text)) + text = text[:pos] + frag + text[pos:] + assert _might_contain_sensitive_text(text) == _reference(text), repr(text) + + +def test_repeated_calls_are_stable_and_cached(): + """A cache hit must return the same verdict as the first, uncached call.""" + secret = "sk-" + "a" * 40 + clean = "just a normal sentence" + for _ in range(5): + assert _might_contain_sensitive_text(secret) is True + assert _might_contain_sensitive_text(clean) is False + info = helpers._might_contain_sensitive_text_lru.cache_info() + assert info.hits > 0, "expected the memo to serve repeated lookups" + + +def test_oversized_text_bypasses_the_cache_but_keeps_the_verdict(): + """Huge strings must stay correct while never entering the LRU.""" + limit = helpers._SENSITIVE_PREFILTER_MAX_TEXT_LEN + big_secret = "x" * (limit + 1) + "sk-" + "b" * 40 + big_clean = "y" * (limit + 100) + + before = helpers._might_contain_sensitive_text_lru.cache_info().currsize + assert _might_contain_sensitive_text(big_secret) is True + assert _might_contain_sensitive_text(big_clean) is False + after = helpers._might_contain_sensitive_text_lru.cache_info().currsize + assert after == before, "oversized strings must not be memoized" + + assert _might_contain_sensitive_text(big_secret) == _reference(big_secret) + assert _might_contain_sensitive_text(big_clean) == _reference(big_clean) + + +def test_non_string_and_empty_inputs_are_rejected(): + for value in (None, 0, 1, [], {}, ()): + assert _might_contain_sensitive_text(value) is False # type: ignore[arg-type] + assert _might_contain_sensitive_text("") is False + + +def test_unhashable_input_does_not_reach_the_cache(): + """Guard the wrapper's isinstance check: a list must not raise TypeError.""" + before = helpers._might_contain_sensitive_text_lru.cache_info().currsize + assert _might_contain_sensitive_text(["sk-secret"]) is False # type: ignore[arg-type] + assert helpers._might_contain_sensitive_text_lru.cache_info().currsize == before + + +def test_cache_is_bounded(): + """The LRU must cap its own size rather than grow with traffic.""" + limit = helpers._SENSITIVE_PREFILTER_CACHE_SIZE + for i in range(limit + 500): + _might_contain_sensitive_text(f"unique-clean-string-{i}") + assert helpers._might_contain_sensitive_text_lru.cache_info().currsize <= limit From 86fe1a574629bb50fee71424c32b80402aef2f14 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Mon, 24 Aug 2026 23:58:05 +0000 Subject: [PATCH 094/446] perf(webui): make redaction containers copy-on-write --- api/helpers.py | 26 ++++----- tests/test_redact_value_no_copy_when_clean.py | 53 ++++++++++++++++++- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/api/helpers.py b/api/helpers.py index e9af3c47..c53b402b 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -1035,23 +1035,25 @@ def _redact_value(v, *, _enabled: bool | None = None): if isinstance(v, str): return _redact_text(v, _enabled=_enabled) if isinstance(v, dict): - changed = False - out = {} + out = None for key, value in v.items(): redacted = _redact_value(value, _enabled=_enabled) + if redacted is value: + continue + if out is None: + out = dict(v) out[key] = redacted - if redacted is not value: - changed = True - return out if changed else v + return v if out is None else out if isinstance(v, list): - changed = False - out = [] - for item in v: + out = None + for index, item in enumerate(v): redacted = _redact_value(item, _enabled=_enabled) - out.append(redacted) - if redacted is not item: - changed = True - return out if changed else v + if redacted is item: + continue + if out is None: + out = list(v) + out[index] = redacted + return v if out is None else out return v diff --git a/tests/test_redact_value_no_copy_when_clean.py b/tests/test_redact_value_no_copy_when_clean.py index ffb8f875..da3ca30e 100644 --- a/tests/test_redact_value_no_copy_when_clean.py +++ b/tests/test_redact_value_no_copy_when_clean.py @@ -8,12 +8,14 @@ GIL (json/allocations ne le liberent pas), ce qui serialise les onglets. Contrat verifie ici: 1. Quand rien n'est redige, la valeur RENVOYEE EST l'objet d'origine - (identite, pas seulement egalite) -> zero allocation. + (identite, pas seulement egalite) et aucun conteneur proportionnel au + payload n'est alloue en chemin. 2. Quand quelque chose est redige, un NOUVEL objet est renvoye et l'original n'est PAS mute (fail-closed: pas de fuite par aliasing). 3. Le resultat reste egal a celui de l'ancienne implementation. """ import sys +import tracemalloc from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) @@ -55,6 +57,32 @@ def test_clean_payload_is_returned_by_identity(): assert out["meta"] is payload["meta"] +def test_clean_payload_does_not_allocate_eager_container_copies(): + """La voie propre doit rester O(1) en allocation de conteneurs temporaires.""" + payload = { + "dict": {index: index for index in range(20_000)}, + "list": list(range(20_000)), + } + + was_tracing = tracemalloc.is_tracing() + if not was_tracing: + tracemalloc.start() + before, _ = tracemalloc.get_traced_memory() + tracemalloc.reset_peak() + try: + out = _redact_value(payload, _enabled=True) + _, peak = tracemalloc.get_traced_memory() + finally: + if not was_tracing: + tracemalloc.stop() + + assert out is payload + # Une liste temporaire de 20 000 pointeurs depasse a elle seule 160 Ko. + # Le seuil reste volontairement un ordre de grandeur en dessous, sans + # dependre des details exacts de l'allocateur d'une version de CPython. + assert peak - before < 16_384, f"copies temporaires detectees: {peak - before} octets" + + def test_sensitive_payload_is_copied_and_original_untouched(): """Quelque chose est redige -> nouvel objet, original intact.""" inner = {"role": "user", "content": SECRET} @@ -78,6 +106,29 @@ def test_sensitive_payload_is_copied_and_original_untouched(): assert out["meta"] is payload["meta"] +def test_copy_on_first_change_preserves_order_types_and_clean_identity(): + """Une redaction tardive copie seulement son chemin sans reordonner le JSON.""" + before = {"clean": ["avant", 1]} + after = {"clean": ["apres", 2]} + payload = { + "before": before, + "items": [before, {"secret": SECRET}, after], + "after": after, + } + + out = _redact_value(payload, _enabled=True) + + assert type(out) is dict + assert type(out["items"]) is list + assert list(out) == list(payload) + assert out["before"] is before + assert out["after"] is after + assert out["items"][0] is before + assert out["items"][2] is after + assert out["items"][1] is not payload["items"][1] + assert out["items"][1]["secret"] != SECRET + + def test_matches_reference_implementation(): """Le resultat doit rester identique a l'implementation d'origine.""" payload = { From d825d2061a6e3c7b3e5d8f10537f26f55048f0e0 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Tue, 25 Aug 2026 00:04:46 +0000 Subject: [PATCH 095/446] test(redaction): neutralize private fixtures --- tests/test_redact_large_string_cache.py | 2 +- tests/test_redact_value_no_copy_when_clean.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_redact_large_string_cache.py b/tests/test_redact_large_string_cache.py index d125adc1..dfcbbf21 100644 --- a/tests/test_redact_large_string_cache.py +++ b/tests/test_redact_large_string_cache.py @@ -32,7 +32,7 @@ from api.helpers import ( # noqa: E402 _redact_fn_uncached, ) -SECRET = "ghp_0123456789abcdefghijklmnopqrstuvwxyzAB" +SECRET = "gh" + "p_" + "0123456789abcdefghijklmnopqrstuvwxyzAB" @pytest.fixture(autouse=True) diff --git a/tests/test_redact_value_no_copy_when_clean.py b/tests/test_redact_value_no_copy_when_clean.py index da3ca30e..210217bf 100644 --- a/tests/test_redact_value_no_copy_when_clean.py +++ b/tests/test_redact_value_no_copy_when_clean.py @@ -46,7 +46,7 @@ def test_clean_payload_is_returned_by_identity(): {"role": "user", "content": "bonjour, ou en est la commande 4512 ?"}, {"role": "assistant", "content": [{"type": "text", "text": "elle part demain"}]}, ], - "meta": {"workspace": "/a0/usr/projects/MES", "count": 3, "ok": True}, + "meta": {"workspace": "/workspace/project", "count": 3, "ok": True}, } out = _redact_value(payload, _enabled=True) From 43394141a8b95bfe0824e96d5fb013fdb7fd91aa Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Thu, 3 Sep 2026 09:42:14 +0000 Subject: [PATCH 096/446] test(redaction): translate French docstrings and messages to English --- tests/test_redact_large_string_cache.py | 62 ++++++++-------- tests/test_redact_value_no_copy_when_clean.py | 72 +++++++++---------- 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/tests/test_redact_large_string_cache.py b/tests/test_redact_large_string_cache.py index dfcbbf21..400b0d1b 100644 --- a/tests/test_redact_large_string_cache.py +++ b/tests/test_redact_large_string_cache.py @@ -1,21 +1,21 @@ -"""Grosses chaines: memoiser au lieu de repayer ~15 regex a chaque requete. +"""Large strings: memoize instead of re-paying ~15 regexes on every request. -``_redact_fn_cached`` excluait du LRU toute chaine > 16 384 caracteres, pour -eviter d'evincer les milliers de petites chaines recurrentes et de gonfler la -RSS. Consequence mesuree sur une session reelle de 22 Mo: 59 grosses chaines -(29 uniques, 0,76 Mo) repassaient l'integralite des regex a CHAQUE requete, -pour un resultat toujours identique — 1,68 s par requete, soit 99,9% du cout -recurrent de la redaction une fois le cache des petites chaines chaud. +``_redact_fn_cached`` excluded every string > 16,384 characters from the LRU, +to avoid evicting the thousands of small recurring strings and inflating RSS. +Measured consequence on a real 22 MB session: 59 large strings (29 unique, +0.76 MB) went through the full regex set on EVERY request, for an always +identical result -- 1.68 s per request, i.e. 99.9% of the recurring redaction +cost once the small-string cache is warm. -Le correctif ajoute un SECOND cache, dedie aux grosses chaines, borne en -nombre d'entrees ET par une taille maximale par entree, pour que la RSS reste -plafonnee. Les chaines geantes (> plafond) restent volontairement non cachees. +The fix adds a SECOND cache, dedicated to large strings, bounded both by entry +count AND by a maximum size per entry, so that RSS stays capped. Giant strings +(> cap) deliberately remain uncached. -Contrat verifie ici: - 1. une grosse chaine est redigee exactement comme avant; - 2. la meme grosse chaine n'est pas recalculee au second appel; - 3. les deux caches restent separes (pas d'eviction croisee); - 4. une chaine au-dela du plafond n'est jamais retenue. +Contract verified here: + 1. a large string is redacted exactly as before; + 2. the same large string is not recomputed on the second call; + 3. the two caches stay separate (no cross-eviction); + 4. a string above the cap is never retained. """ import sys from pathlib import Path @@ -37,13 +37,13 @@ SECRET = "gh" + "p_" + "0123456789abcdefghijklmnopqrstuvwxyzAB" @pytest.fixture(autouse=True) def _isolate_large_cache(): - """Ne pas laisser ce fichier polluer l'etat global du process. + """Do not let this file pollute the process-wide global state. - Le cache est un singleton de module partage par toute la suite. Le vider - sans le restaurer laisse les tests suivants demarrer a froid, ce qui - perturbe ceux qui dependent de fenetres de fraicheur ou de temps de - reponse. On repart d'un cache vide ET on le revide en sortie, pour que ce - fichier n'ait aucun effet observable en dehors de lui-meme. + The cache is a module singleton shared by the whole suite. Clearing it + without restoring it makes the following tests start cold, which disturbs + those that depend on freshness windows or response times. We start from an + empty cache AND clear it again on exit, so that this file has no observable + effect outside of itself. """ helpers._redact_fn_large_lru.cache_clear() yield @@ -51,11 +51,11 @@ def _isolate_large_cache(): def _big(secret: str, size: int) -> str: - """Chaine > seuil du petit cache, porteuse d'un secret. + """String > small-cache threshold, carrying a secret. - Le remplissage doit etre assez long pour atteindre ``size`` meme au-dela - du plafond du grand cache, sinon la troncature produit une chaine trop - courte et le test ne verifie plus ce qu'il annonce. + The filler must be long enough to reach ``size`` even beyond the large + cache cap, otherwise truncation yields a string that is too short and the + test no longer verifies what it claims. """ unit = "lorem ipsum dolor sit amet " filler = unit * (size // len(unit) + 1) @@ -82,21 +82,21 @@ def test_large_string_is_memoized(): info = helpers._redact_fn_large_lru.cache_info() assert second == first - assert info.hits >= 1, "grosse chaine recalculee au second appel" - assert info.misses == misses_after_first, "miss supplementaire inattendu" + assert info.hits >= 1, "large string recomputed on second call" + assert info.misses == misses_after_first, "unexpected additional miss" def test_small_strings_do_not_use_the_large_cache(): helpers._redact_fn_large_lru.cache_clear() - small = f"petit texte {SECRET}" + small = f"small text {SECRET}" assert len(small) <= _REDACT_CACHE_MAX_TEXT_LEN _redact_fn_cached(small) info = helpers._redact_fn_large_lru.cache_info() - assert info.hits == 0 and info.misses == 0, "petite chaine routee vers le grand cache" + assert info.hits == 0 and info.misses == 0, "small string routed to the large cache" def test_giant_string_is_not_retained(): - """Au-dela du plafond: correct, mais jamais mis en cache (RSS bornee).""" + """Above the cap: correct, but never cached (bounded RSS).""" helpers._redact_fn_large_lru.cache_clear() giant = _big(SECRET, _REDACT_LARGE_CACHE_MAX_TEXT_LEN + 10000) assert len(giant) > _REDACT_LARGE_CACHE_MAX_TEXT_LEN @@ -106,7 +106,7 @@ def test_giant_string_is_not_retained(): assert out == _redact_fn_uncached(giant) assert SECRET not in out info = helpers._redact_fn_large_lru.cache_info() - assert info.currsize == 0, "chaine geante retenue en cache" + assert info.currsize == 0, "giant string retained in cache" def test_large_cache_is_bounded(): diff --git a/tests/test_redact_value_no_copy_when_clean.py b/tests/test_redact_value_no_copy_when_clean.py index 210217bf..eb52bc93 100644 --- a/tests/test_redact_value_no_copy_when_clean.py +++ b/tests/test_redact_value_no_copy_when_clean.py @@ -1,18 +1,18 @@ -"""Redaction: ne pas reconstruire ce qui n'a pas change. +"""Redaction: do not rebuild what has not changed. -``_redact_value`` reconstruisait integralement chaque dict/liste, soit une -copie profonde de tout le payload a chaque reponse, meme quand rien n'est -redige. Sur une session de 22 Mo, 97,4% des chaines ne contiennent aucun -marqueur sensible: la copie est donc du travail pur perte, et elle tient le -GIL (json/allocations ne le liberent pas), ce qui serialise les onglets. +``_redact_value`` used to rebuild every dict/list in full, i.e. a deep copy of +the whole payload on every response, even when nothing is redacted. On a 22 MB +session, 97.4% of the strings contain no sensitive marker: the copy is +therefore pure wasted work, and it holds the GIL (json/allocations do not +release it), which serializes the tabs. -Contrat verifie ici: - 1. Quand rien n'est redige, la valeur RENVOYEE EST l'objet d'origine - (identite, pas seulement egalite) et aucun conteneur proportionnel au - payload n'est alloue en chemin. - 2. Quand quelque chose est redige, un NOUVEL objet est renvoye et - l'original n'est PAS mute (fail-closed: pas de fuite par aliasing). - 3. Le resultat reste egal a celui de l'ancienne implementation. +Contract verified here: + 1. When nothing is redacted, the RETURNED value IS the original object + (identity, not just equality) and no container proportional to the + payload is allocated along the way. + 2. When something is redacted, a NEW object is returned and the original + is NOT mutated (fail-closed: no leak through aliasing). + 3. The result stays equal to the one of the previous implementation. """ import sys import tracemalloc @@ -27,7 +27,7 @@ SECRET = "authorization=Bearer sk-live-abcdef0123456789" def _reference_redact(v, *, _enabled): - """Implementation d'origine (copie systematique), pour comparaison.""" + """Original implementation (systematic copy), kept for comparison.""" from api.helpers import _redact_text if isinstance(v, str): @@ -40,25 +40,25 @@ def _reference_redact(v, *, _enabled): def test_clean_payload_is_returned_by_identity(): - """Rien de sensible -> aucun dict/liste ne doit etre reconstruit.""" + """Nothing sensitive -> no dict/list must be rebuilt.""" payload = { "messages": [ - {"role": "user", "content": "bonjour, ou en est la commande 4512 ?"}, - {"role": "assistant", "content": [{"type": "text", "text": "elle part demain"}]}, + {"role": "user", "content": "hello, where is order 4512 at?"}, + {"role": "assistant", "content": [{"type": "text", "text": "it ships tomorrow"}]}, ], "meta": {"workspace": "/workspace/project", "count": 3, "ok": True}, } out = _redact_value(payload, _enabled=True) - assert out is payload, "payload propre reconstruit inutilement" + assert out is payload, "clean payload rebuilt needlessly" assert out["messages"] is payload["messages"] assert out["messages"][0] is payload["messages"][0] assert out["meta"] is payload["meta"] def test_clean_payload_does_not_allocate_eager_container_copies(): - """La voie propre doit rester O(1) en allocation de conteneurs temporaires.""" + """The clean path must stay O(1) in temporary container allocation.""" payload = { "dict": {index: index for index in range(20_000)}, "list": list(range(20_000)), @@ -77,39 +77,39 @@ def test_clean_payload_does_not_allocate_eager_container_copies(): tracemalloc.stop() assert out is payload - # Une liste temporaire de 20 000 pointeurs depasse a elle seule 160 Ko. - # Le seuil reste volontairement un ordre de grandeur en dessous, sans - # dependre des details exacts de l'allocateur d'une version de CPython. - assert peak - before < 16_384, f"copies temporaires detectees: {peak - before} octets" + # A temporary list of 20,000 pointers alone exceeds 160 KB. The threshold + # deliberately stays an order of magnitude below that, without depending + # on the exact allocator details of a given CPython version. + assert peak - before < 16_384, f"temporary copies detected: {peak - before} bytes" def test_sensitive_payload_is_copied_and_original_untouched(): - """Quelque chose est redige -> nouvel objet, original intact.""" + """Something is redacted -> new object, original intact.""" inner = {"role": "user", "content": SECRET} payload = {"messages": [inner], "meta": {"workspace": "/tmp"}} out = _redact_value(payload, _enabled=True) - # un nouvel objet est renvoye sur le chemin modifie + # a new object is returned along the modified path assert out is not payload assert out["messages"] is not payload["messages"] assert out["messages"][0] is not inner - # l'original n'est pas mute (pas de fuite par aliasing) + # the original is not mutated (no leak through aliasing) assert inner["content"] == SECRET assert payload["messages"][0]["content"] == SECRET - # le secret est bien masque dans la sortie + # the secret is indeed masked in the output assert out["messages"][0]["content"] != SECRET - # les branches NON modifiees restent partagees (c'est tout l'interet) + # the UNmodified branches stay shared (that is the whole point) assert out["meta"] is payload["meta"] def test_copy_on_first_change_preserves_order_types_and_clean_identity(): - """Une redaction tardive copie seulement son chemin sans reordonner le JSON.""" - before = {"clean": ["avant", 1]} - after = {"clean": ["apres", 2]} + """A late redaction copies only its own path without reordering the JSON.""" + before = {"clean": ["before", 1]} + after = {"clean": ["after", 2]} payload = { "before": before, "items": [before, {"secret": SECRET}, after], @@ -130,14 +130,14 @@ def test_copy_on_first_change_preserves_order_types_and_clean_identity(): def test_matches_reference_implementation(): - """Le resultat doit rester identique a l'implementation d'origine.""" + """The result must stay identical to the original implementation.""" payload = { "messages": [ - {"role": "user", "content": "texte normal"}, + {"role": "user", "content": "plain text"}, {"role": "assistant", "content": SECRET}, - {"role": "user", "content": ["propre", SECRET, 42, None]}, + {"role": "user", "content": ["clean", SECRET, 42, None]}, ], - "nested": {"a": {"b": {"c": SECRET}}, "d": {"e": "rien"}}, + "nested": {"a": {"b": {"c": SECRET}}, "d": {"e": "nothing"}}, "scalars": [1, 2.5, True, None], } @@ -145,7 +145,7 @@ def test_matches_reference_implementation(): def test_disabled_redaction_still_shares(): - """Redaction desactivee -> rien ne doit etre copie.""" + """Redaction disabled -> nothing must be copied.""" payload = {"messages": [{"role": "user", "content": SECRET}]} out = _redact_value(payload, _enabled=False) assert out is payload From fc7ae2ddd1e8f7385a8a928a7c1cde3f68b94186 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Thu, 17 Sep 2026 23:35:54 +0000 Subject: [PATCH 097/446] fix(redaction): fail closed on large strings, byte-bound the prefilter cache Gate found two blocking defects in the new caches: 1. The large-string LRU was keyed on input text only. The installed agent.redact registry can extend the secret matcher at runtime via register_redaction_patterns() and exposes no policy generation or invalidation hook, so a >16k string primed before a registration was served with the new secret intact (reproduced against the installed registry). Drop the large-string cache: strings above 16,384 chars run the redactor uncached again. Regression primes a large string, registers a runtime pattern on the real registry and proves the next redact_session_data() response is redacted. 2. The prefilter LRU was count-bounded (8192) but its per-entry gate was a character cap, which is not a byte bound: 16k four-byte code points is ~64 KiB per key, ~512 MiB at capacity, and the workload died with MemoryError under 96 MiB of headroom. Gate each entry on sys.getsizeof(text) <= 2048 so the retained-key ceiling is a hard 16 MiB. Regressions cover max-width Unicode, sensitive verdict pairs, eviction past the cap, and a low-memory RLIMIT_AS subprocess. --- api/helpers.py | 61 +++--- tests/test_redact_large_string_cache.py | 148 ++++++++------ tests/test_sensitive_prefilter_equivalence.py | 189 +++++++++++++++++- 3 files changed, 310 insertions(+), 88 deletions(-) diff --git a/api/helpers.py b/api/helpers.py index c53b402b..e049c1ab 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -9,6 +9,7 @@ import logging import os import re as _re import ssl +import sys from pathlib import Path from api.config import IMAGE_EXTS, MD_EXTS @@ -472,31 +473,26 @@ _redact_fn_lru = functools.lru_cache(maxsize=4096)(_redact_fn_uncached) # thousands of small recurring strings that actually benefit, or balloon RSS. _REDACT_CACHE_MAX_TEXT_LEN = 16384 -# Strings above that threshold used to bypass memoization entirely and re-run -# the full ~15-pass redactor on every request. On a real 22MB session that was -# 59 large strings (29 unique, 0.76MB) costing 1.68s per request — 99.9% of the -# recurring redaction cost once the small-string cache is warm, paid again by -# every tab and every poll for a byte-identical result. +# Strings above that threshold deliberately stay UNCACHED and re-run the full +# redactor on every request, even though they dominate the recurring cost of a +# large session (measured: 59 large strings, 29 unique, 1.68s per request on a +# real 22MB session). Memoizing them by input text alone is not safe: +# ``agent.redact.register_redaction_patterns()`` lets a plugin extend the +# secret matcher at runtime, and the installed registry exposes neither a +# policy generation the cache key could include nor a hook that could clear +# it. A large blob primed before such a registration would keep being served +# with the newly-registered secret intact through session, SSE and +# public-share projections. Until the agent registry exposes a generation, +# large strings fail closed. # -# They get their own small cache instead of sharing the 4096-entry one, so a -# large blob can never evict the thousands of small recurring strings. Both the -# entry count and the per-entry size are capped, which bounds retained bytes to -# roughly _REDACT_LARGE_CACHE_SIZE * _REDACT_LARGE_CACHE_MAX_TEXT_LEN (key plus -# value). Anything larger stays uncached: unbounded growth is the failure mode -# this threshold exists to prevent. -_REDACT_LARGE_CACHE_MAX_TEXT_LEN = 131072 -_REDACT_LARGE_CACHE_SIZE = 64 - -_redact_fn_large_lru = functools.lru_cache(maxsize=_REDACT_LARGE_CACHE_SIZE)( - _redact_fn_uncached -) +# tests/test_redact_large_string_cache.py pins this against the real agent +# registry: prime a large string, register a pattern, the next response must +# be redacted. def _redact_fn_cached(text): if len(text) > _REDACT_CACHE_MAX_TEXT_LEN: - if len(text) > _REDACT_LARGE_CACHE_MAX_TEXT_LEN: - return _redact_fn_uncached(text) - return _redact_fn_large_lru(text) + return _redact_fn_uncached(text) return _redact_fn_lru(text) @@ -593,11 +589,21 @@ _SENSITIVE_PHONE_MARKER_RE = _re.compile(r"(? bool: @@ -626,12 +632,13 @@ _might_contain_sensitive_text_lru = functools.lru_cache( def _might_contain_sensitive_text(text: str) -> bool: """Memoized wrapper around the prefilter. - Falls back to the uncached scan for non-strings and oversized text so the - cache can never be poisoned by an unhashable value or grow unbounded. + Falls back to the uncached scan for non-strings and for any string whose + object size exceeds the per-entry byte gate, so the cache can never be + poisoned by an unhashable value and its retained bytes stay hard-bounded. """ if not isinstance(text, str) or not text: return False - if len(text) > _SENSITIVE_PREFILTER_MAX_TEXT_LEN: + if sys.getsizeof(text) > _SENSITIVE_PREFILTER_MAX_ENTRY_BYTES: return _might_contain_sensitive_text_uncached(text) return _might_contain_sensitive_text_lru(text) diff --git a/tests/test_redact_large_string_cache.py b/tests/test_redact_large_string_cache.py index 400b0d1b..36572acf 100644 --- a/tests/test_redact_large_string_cache.py +++ b/tests/test_redact_large_string_cache.py @@ -1,21 +1,24 @@ -"""Large strings: memoize instead of re-paying ~15 regexes on every request. +"""Large strings must NOT be memoized by input text alone. -``_redact_fn_cached`` excluded every string > 16,384 characters from the LRU, -to avoid evicting the thousands of small recurring strings and inflating RSS. -Measured consequence on a real 22 MB session: 59 large strings (29 unique, -0.76 MB) went through the full regex set on EVERY request, for an always -identical result -- 1.68 s per request, i.e. 99.9% of the recurring redaction -cost once the small-string cache is warm. +``_redact_fn_cached`` memoizes strings up to 16,384 characters. An earlier +revision of this branch added a second LRU for larger strings, keyed only on +the text. That is unsafe: ``agent.redact.register_redaction_patterns()`` lets +a plugin extend the secret matcher at runtime, and the installed registry +exposes no policy generation the cache key could include and no hook that +could clear the cache. A large blob primed BEFORE such a registration would +keep being served with the newly-registered secret intact through session, +SSE and public-share projections. -The fix adds a SECOND cache, dedicated to large strings, bounded both by entry -count AND by a maximum size per entry, so that RSS stays capped. Giant strings -(> cap) deliberately remain uncached. +Until the agent registry exposes a generation, strings above the small-cache +threshold fail closed: they run the full redactor on every call. Contract verified here: - 1. a large string is redacted exactly as before; - 2. the same large string is not recomputed on the second call; - 3. the two caches stay separate (no cross-eviction); - 4. a string above the cap is never retained. + 1. a large string is redacted exactly like the uncached redactor; + 2. no module-level cache retains a large string (only the small LRU exists + and it never sees a string above the threshold); + 3. production-registry regression: prime a large string carrying a token + the redactor does not know yet, register a runtime pattern for it, and + the NEXT ``redact_session_data`` response is redacted. """ import sys from pathlib import Path @@ -27,36 +30,25 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from api import helpers # noqa: E402 from api.helpers import ( # noqa: E402 _REDACT_CACHE_MAX_TEXT_LEN, - _REDACT_LARGE_CACHE_MAX_TEXT_LEN, _redact_fn_cached, _redact_fn_uncached, + _redact_text, + redact_session_data, ) SECRET = "gh" + "p_" + "0123456789abcdefghijklmnopqrstuvwxyzAB" @pytest.fixture(autouse=True) -def _isolate_large_cache(): - """Do not let this file pollute the process-wide global state. - - The cache is a module singleton shared by the whole suite. Clearing it - without restoring it makes the following tests start cold, which disturbs - those that depend on freshness windows or response times. We start from an - empty cache AND clear it again on exit, so that this file has no observable - effect outside of itself. - """ - helpers._redact_fn_large_lru.cache_clear() +def _isolate_small_cache(): + """Start and finish with an empty small-string LRU.""" + helpers._redact_fn_lru.cache_clear() yield - helpers._redact_fn_large_lru.cache_clear() + helpers._redact_fn_lru.cache_clear() def _big(secret: str, size: int) -> str: - """String > small-cache threshold, carrying a secret. - - The filler must be long enough to reach ``size`` even beyond the large - cache cap, otherwise truncation yields a string that is too short and the - test no longer verifies what it claims. - """ + """String of exactly ``size`` characters carrying ``secret``.""" unit = "lorem ipsum dolor sit amet " filler = unit * (size // len(unit) + 1) text = (filler + secret + filler)[:size] @@ -71,44 +63,84 @@ def test_large_string_redaction_matches_uncached(): assert SECRET not in _redact_fn_cached(text) -def test_large_string_is_memoized(): - helpers._redact_fn_large_lru.cache_clear() +def test_large_string_is_never_memoized(): + """Above the threshold the redactor runs every time: no cache hit, no + cache entry, on any module-level LRU.""" text = _big(SECRET, _REDACT_CACHE_MAX_TEXT_LEN + 7000) first = _redact_fn_cached(text) - misses_after_first = helpers._redact_fn_large_lru.cache_info().misses - second = _redact_fn_cached(text) - info = helpers._redact_fn_large_lru.cache_info() assert second == first - assert info.hits >= 1, "large string recomputed on second call" - assert info.misses == misses_after_first, "unexpected additional miss" + info = helpers._redact_fn_lru.cache_info() + assert info.hits == 0 and info.misses == 0 and info.currsize == 0, ( + "large string reached the small-string LRU" + ) + assert not hasattr(helpers, "_redact_fn_large_lru"), ( + "a text-keyed large-string cache is back; it must carry a redaction " + "policy generation before it can exist" + ) -def test_small_strings_do_not_use_the_large_cache(): - helpers._redact_fn_large_lru.cache_clear() +def test_small_strings_still_use_the_small_cache(): small = f"small text {SECRET}" assert len(small) <= _REDACT_CACHE_MAX_TEXT_LEN - _redact_fn_cached(small) - info = helpers._redact_fn_large_lru.cache_info() - assert info.hits == 0 and info.misses == 0, "small string routed to the large cache" + first = _redact_fn_cached(small) + second = _redact_fn_cached(small) + assert first == second + assert SECRET not in first + assert helpers._redact_fn_lru.cache_info().hits >= 1 -def test_giant_string_is_not_retained(): - """Above the cap: correct, but never cached (bounded RSS).""" - helpers._redact_fn_large_lru.cache_clear() - giant = _big(SECRET, _REDACT_LARGE_CACHE_MAX_TEXT_LEN + 10000) - assert len(giant) > _REDACT_LARGE_CACHE_MAX_TEXT_LEN +# ── Production-registry regression ───────────────────────────────────────── +# Uses the REAL installed agent.redact registry (skipped when the agent is not +# importable): the module-level `_redact_fn_uncached` is `_combined_redact` +# only when `agent.redact` imported at helpers import time. - out = _redact_fn_cached(giant) - - assert out == _redact_fn_uncached(giant) - assert SECRET not in out - info = helpers._redact_fn_large_lru.cache_info() - assert info.currsize == 0, "giant string retained in cache" +_RUNTIME_TOKEN = "nvapi-" + "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8S9t0U1v2W3x4Y5z6A7b8C9d0" +_RUNTIME_PATTERN = r"nvapi-[A-Za-z0-9]{60}" -def test_large_cache_is_bounded(): - assert helpers._redact_fn_large_lru.cache_info().maxsize is not None - assert helpers._redact_fn_large_lru.cache_info().maxsize <= 128 +@pytest.fixture +def agent_registry(): + redact = pytest.importorskip("agent.redact", reason="hermes-agent not installed") + if not hasattr(redact, "register_redaction_patterns"): + pytest.skip("installed agent.redact has no runtime pattern registry") + if helpers._redact_fn_uncached.__name__ != "_combined_redact": + pytest.skip("api.helpers was built without the agent redactor") + reset = getattr(redact, "_reset_plugin_redaction_patterns", None) + if reset is None: + pytest.skip("installed agent.redact has no registry reset seam") + reset() + yield redact + reset() + + +def test_runtime_pattern_registration_is_honored_for_large_strings(agent_registry): + """Prime → register → the next response must be redacted (fail-closed).""" + # A marker the WebUI prefilter already trips on, so the large string goes + # through the redactor both before and after registration. + text = ("see https://example.invalid/docs lorem ipsum " * 500) + _RUNTIME_TOKEN + " tail" + assert len(text) > _REDACT_CACHE_MAX_TEXT_LEN + assert helpers._might_contain_sensitive_text(text) + + # Sanity: the installed registry does not know this token shape yet. + # If it ever does, the test still proves the cache path stays honest. + primed = _redact_text(text, _enabled=True) + session_before = redact_session_data( + {"messages": [{"role": "assistant", "content": text}]} + ) + assert session_before["messages"][0]["content"] == primed + + accepted = agent_registry.register_redaction_patterns([_RUNTIME_PATTERN], source="test-7276") + assert accepted == 1, "the registry rejected the probe pattern" + + oracle = _redact_fn_uncached(text) + assert _RUNTIME_TOKEN not in oracle, "the registered pattern is not effective" + + assert _redact_text(text, _enabled=True) == oracle + session_after = redact_session_data( + {"messages": [{"role": "assistant", "content": text}]} + ) + assert _RUNTIME_TOKEN not in session_after["messages"][0]["content"] + assert session_after["messages"][0]["content"] == oracle diff --git a/tests/test_sensitive_prefilter_equivalence.py b/tests/test_sensitive_prefilter_equivalence.py index 75f1dc07..376ff7e5 100644 --- a/tests/test_sensitive_prefilter_equivalence.py +++ b/tests/test_sensitive_prefilter_equivalence.py @@ -11,9 +11,15 @@ lists or the cache wrapper is caught here rather than in production. They must not leave global state behind: the LRU is shared process-wide, so each test that touches it restores it (see the autouse fixture). + +The cache retains its keys, so it is also bounded in BYTES, not just in entry +count: ``sys.getsizeof(text)`` gates every entry (a character cap is not a +byte bound -- 16k four-byte code points is ~64 KiB) and the entry cap gives a +hard aggregate ceiling. The memory tests below pin that. """ import random +import subprocess import sys from pathlib import Path @@ -25,9 +31,17 @@ from api import helpers # noqa: E402 from api.helpers import ( # noqa: E402 _SENSITIVE_CASE_MARKERS, _SENSITIVE_LOWER_MARKERS, + _SENSITIVE_PREFILTER_CACHE_SIZE, + _SENSITIVE_PREFILTER_MAX_ENTRY_BYTES, + _SENSITIVE_PREFILTER_MAX_RETAINED_KEY_BYTES, _might_contain_sensitive_text, ) +REPO_ROOT = Path(__file__).resolve().parents[1] + +# Widest code point CPython stores (UCS-4): every character costs 4 bytes. +_WIDE = "\U0001d54f" + @pytest.fixture(autouse=True) def _isolate_prefilter_cache(): @@ -130,9 +144,10 @@ def test_repeated_calls_are_stable_and_cached(): def test_oversized_text_bypasses_the_cache_but_keeps_the_verdict(): """Huge strings must stay correct while never entering the LRU.""" - limit = helpers._SENSITIVE_PREFILTER_MAX_TEXT_LEN - big_secret = "x" * (limit + 1) + "sk-" + "b" * 40 + limit = _SENSITIVE_PREFILTER_MAX_ENTRY_BYTES + big_secret = "x" * limit + "sk-" + "b" * 40 big_clean = "y" * (limit + 100) + assert sys.getsizeof(big_secret) > limit and sys.getsizeof(big_clean) > limit before = helpers._might_contain_sensitive_text_lru.cache_info().currsize assert _might_contain_sensitive_text(big_secret) is True @@ -159,7 +174,175 @@ def test_unhashable_input_does_not_reach_the_cache(): def test_cache_is_bounded(): """The LRU must cap its own size rather than grow with traffic.""" - limit = helpers._SENSITIVE_PREFILTER_CACHE_SIZE + limit = _SENSITIVE_PREFILTER_CACHE_SIZE for i in range(limit + 500): _might_contain_sensitive_text(f"unique-clean-string-{i}") assert helpers._might_contain_sensitive_text_lru.cache_info().currsize <= limit + + +# ── Byte bound regressions ───────────────────────────────────────────────── + + +def _retained_key_bytes(inserted_in_order: list) -> int: + """Actual ``sys.getsizeof`` sum of the keys the LRU currently holds. + + ``functools.lru_cache`` exposes no key iterator, but it is a strict LRU + and the tests below never re-touch a key before calling this, so the + resident set is exactly the last ``currsize`` keys that were inserted. + """ + info = helpers._might_contain_sensitive_text_lru.cache_info() + resident = inserted_in_order[len(inserted_in_order) - info.currsize:] + assert len(resident) == info.currsize + return sum(sys.getsizeof(key) for key in resident) + + +def test_entry_gate_is_object_bytes_not_characters(): + """A short string of 4-byte code points must be judged on its byte size.""" + max_ascii = "a" * (_SENSITIVE_PREFILTER_MAX_ENTRY_BYTES - sys.getsizeof("")) + assert sys.getsizeof(max_ascii) <= _SENSITIVE_PREFILTER_MAX_ENTRY_BYTES + # Same CHARACTER count in the widest storage is ~4x the bytes: must bypass. + wide_same_len = _WIDE * len(max_ascii) + assert len(wide_same_len) == len(max_ascii) + assert sys.getsizeof(wide_same_len) > _SENSITIVE_PREFILTER_MAX_ENTRY_BYTES + + before = helpers._might_contain_sensitive_text_lru.cache_info().currsize + assert _might_contain_sensitive_text(max_ascii) is False + assert helpers._might_contain_sensitive_text_lru.cache_info().currsize == before + 1 + assert _might_contain_sensitive_text(wide_same_len) is False + assert helpers._might_contain_sensitive_text_lru.cache_info().currsize == before + 1, ( + "a wide-character string above the byte gate was retained" + ) + assert _might_contain_sensitive_text(wide_same_len) == _reference(wide_same_len) + + +def test_maximum_width_unicode_fills_cache_within_byte_budget(): + """Fill the cache with the largest admissible 4-byte-per-char keys and + check the aggregate stays under the hard byte budget.""" + # Largest wide string that still passes the gate. + n = (_SENSITIVE_PREFILTER_MAX_ENTRY_BYTES - sys.getsizeof(_WIDE)) // 4 + 1 + while sys.getsizeof(_WIDE * n) > _SENSITIVE_PREFILTER_MAX_ENTRY_BYTES: + n -= 1 + assert n > 0 + base = _WIDE * n + assert sys.getsizeof(base) <= _SENSITIVE_PREFILTER_MAX_ENTRY_BYTES + + # Unique keys: vary the LAST code point (keeps width 4, same byte size). + total = _SENSITIVE_PREFILTER_CACHE_SIZE + 64 + keys = [base[:-1] + chr(0x1D400 + i) for i in range(total)] + assert all(sys.getsizeof(k) == sys.getsizeof(base) for k in keys[:8]) + for key in keys: + assert _might_contain_sensitive_text(key) is False + + info = helpers._might_contain_sensitive_text_lru.cache_info() + assert info.currsize == _SENSITIVE_PREFILTER_CACHE_SIZE + retained = _retained_key_bytes(keys) + assert retained <= _SENSITIVE_PREFILTER_MAX_RETAINED_KEY_BYTES + # The bound is tight, not slack: max-width keys fill most of it. + assert retained > _SENSITIVE_PREFILTER_MAX_RETAINED_KEY_BYTES * 0.95 + # Concretely: 16 MiB, not the ~512 MiB a character cap would have allowed. + assert _SENSITIVE_PREFILTER_MAX_RETAINED_KEY_BYTES == 16 * 1024 * 1024 + + +def test_sensitive_output_pairs_are_byte_gated_too(): + """A SENSITIVE verdict is cached under the same byte gate: an oversized + secret-bearing string is neither retained nor misjudged, and an + admissible one is cached with the right verdict.""" + small_secret = "token=" + "s" * 40 + assert sys.getsizeof(small_secret) <= _SENSITIVE_PREFILTER_MAX_ENTRY_BYTES + wide_secret = _WIDE * 600 + " sk-" + "c" * 40 + assert sys.getsizeof(wide_secret) > _SENSITIVE_PREFILTER_MAX_ENTRY_BYTES + + before = helpers._might_contain_sensitive_text_lru.cache_info().currsize + assert _might_contain_sensitive_text(small_secret) is True + assert _might_contain_sensitive_text(small_secret) is True + assert helpers._might_contain_sensitive_text_lru.cache_info().currsize == before + 1 + assert helpers._might_contain_sensitive_text_lru.cache_info().hits >= 1 + + assert _might_contain_sensitive_text(wide_secret) is True + assert _might_contain_sensitive_text(wide_secret) is True + assert helpers._might_contain_sensitive_text_lru.cache_info().currsize == before + 1, ( + "oversized sensitive string retained" + ) + assert _might_contain_sensitive_text(wide_secret) == _reference(wide_secret) + + +def test_eviction_keeps_aggregate_under_budget_and_verdicts_correct(): + """Churn well past the entry cap with max-size keys of mixed verdicts: + the cache must evict (never exceed the cap) and every verdict must still + match the oracle, including for keys that were evicted and re-scanned.""" + fill = _SENSITIVE_PREFILTER_MAX_ENTRY_BYTES - sys.getsizeof("") - 64 + total = _SENSITIVE_PREFILTER_CACHE_SIZE * 2 + 17 + peak = 0 + inserted = [] + samples = [] + for i in range(total): + tag = f"{i:08d}" + if i % 3 == 0: + text = "k" * fill + " api_key=" + tag + else: + text = "k" * fill + " plain " + tag + assert sys.getsizeof(text) <= _SENSITIVE_PREFILTER_MAX_ENTRY_BYTES + assert _might_contain_sensitive_text(text) == _reference(text) + inserted.append(text) + peak = max(peak, helpers._might_contain_sensitive_text_lru.cache_info().currsize) + if i % 997 == 0: + samples.append(text) + assert peak <= _SENSITIVE_PREFILTER_CACHE_SIZE + assert helpers._might_contain_sensitive_text_lru.cache_info().currsize == _SENSITIVE_PREFILTER_CACHE_SIZE + assert _retained_key_bytes(inserted) <= _SENSITIVE_PREFILTER_MAX_RETAINED_KEY_BYTES + # Early samples were evicted; re-querying them must still be correct. + misses_before = helpers._might_contain_sensitive_text_lru.cache_info().misses + for text in samples: + assert _might_contain_sensitive_text(text) == _reference(text) + evicted = [t for t in samples if t not in inserted[-_SENSITIVE_PREFILTER_CACHE_SIZE:]] + assert evicted, "test did not churn past the cap" + assert helpers._might_contain_sensitive_text_lru.cache_info().misses - misses_before >= len(evicted) + + +_LOW_MEMORY_PROBE = r""" +import resource, sys +sys.path.insert(0, sys.argv[1]) +from api import helpers +# Address-space headroom well below the ~512 MiB a character-capped cache of +# wide keys would pin, but comfortably above the byte-bounded 16 MiB ceiling. +headroom = int(sys.argv[2]) * 1024 * 1024 +soft, hard = resource.getrlimit(resource.RLIMIT_AS) +current = 0 +for line in open("/proc/self/status"): + if line.startswith("VmSize:"): + current = int(line.split()[1]) * 1024 +resource.setrlimit(resource.RLIMIT_AS, (current + headroom, hard)) +wide = "\U0001d54f" +n = helpers._SENSITIVE_PREFILTER_MAX_ENTRY_BYTES // 4 +total = helpers._SENSITIVE_PREFILTER_CACHE_SIZE + 256 +entered = 0 +for i in range(total): + # Unique wide key of ~16k characters (~64 KiB each): what the old + # character cap admitted. Must be scanned, must NOT be retained. + text = wide * 16000 + chr(0x1D400 + (i % 500)) + f"{i:06d}" + verdict = helpers._might_contain_sensitive_text(text) + assert verdict is False, verdict + # And a unique key that DOES pass the byte gate, to keep the cache busy. + small = wide * (n - 8) + f"{i:06d}" + assert helpers._might_contain_sensitive_text(small) is False + entered += 1 +info = helpers._might_contain_sensitive_text_lru.cache_info() +assert info.currsize <= helpers._SENSITIVE_PREFILTER_CACHE_SIZE, info +print("OK", entered, info.currsize) +""" + + +@pytest.mark.skipif(sys.platform != "linux", reason="RLIMIT_AS + /proc probe is Linux-only") +def test_low_memory_headroom_survives_wide_unicode_churn(): + """Under ~96 MiB of address-space headroom, pushing more than the entry + cap of 16k-character wide strings through the prefilter must complete + without MemoryError. Before the byte gate this workload retained ~512 MiB + and died after a few thousand entries.""" + proc = subprocess.run( + [sys.executable, "-c", _LOW_MEMORY_PROBE, str(REPO_ROOT), "96"], + capture_output=True, + text=True, + timeout=300, + ) + assert proc.returncode == 0, proc.stderr[-4000:] + assert proc.stdout.strip().startswith("OK"), proc.stdout From ef9341b2ab1e59ae84cd3437a24cca00c7bdefbd Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Sun, 6 Sep 2026 14:12:12 +0000 Subject: [PATCH 098/446] fix(sqlite): keep state.db readers strictly read-only and stop idle polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Readers never upgrade to writers. open_state_db_readonly() opens file:...?mode=ro only; a read-only open failure propagates instead of silently retrying with a write-capable handle on the multi-GiB WAL database the agent is streaming into. The session listing no longer self-heals idx_messages_session with CREATE INDEX (a writer lock held for minutes on a large messages table) — a missing index degrades to the pre-aggregated path. Cron sidebar info, insights and deep health checks in api/routes.py use the same read-only opener. Watcher (api/gateway_watcher.py): _poll_once returns without touching the database when nobody is subscribed, and forces a fresh full projection on the next subscription. Maintenance (scripts/ensure_state_db_read_indexes.py): the covering read indexes (idx_messages_session, idx_messages_session_role with NOCASE, idx_sessions_webui_fingerprint) are created only under --confirm-drained, optionally holding an exclusive flock on --lock-file for deployments that serialise agent turns on a lock file; existing indexes are verified for shape/collation and coverage (EXPLAIN QUERY PLAN), never silently replaced. Tests: tests/test_state_db_readonly_readers.py (read-only failure never retries writable; no poll without subscribers; listing creates no index; index tool requires drain, is idempotent, works without a lock file and refuses a held lock; cron sidebar read is mode=ro). Neighbouring tests that asserted the retired writable fallback / self-heal writer (test_issue3887_index_prime, test_issue5455_listing_readonly_connection, test_issue5455_lineage_readonly_reads, test_issue2628_cli_sessions_perf, test_issue1494_state_db_fd_leak) now assert the read-only contract; test_issue3506 subscribes before polling. --- api/agent_sessions.py | 57 +------- api/gateway_watcher.py | 6 + api/routes.py | 7 +- scripts/ensure_state_db_read_indexes.py | 98 ++++++++++++++ tests/test_issue1494_state_db_fd_leak.py | 6 +- tests/test_issue2628_cli_sessions_perf.py | 3 +- tests/test_issue3506_memory_and_watcher.py | 1 + tests/test_issue3887_index_prime.py | 34 +++-- .../test_issue5455_lineage_readonly_reads.py | 27 ++-- ...t_issue5455_listing_readonly_connection.py | 45 ++++--- tests/test_state_db_readonly_readers.py | 124 ++++++++++++++++++ 11 files changed, 303 insertions(+), 105 deletions(-) create mode 100644 scripts/ensure_state_db_read_indexes.py create mode 100644 tests/test_state_db_readonly_readers.py diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 97086795..310df64d 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -24,30 +24,17 @@ def open_state_db_readonly(db_path: Path, log: logging.Logger | None = None) -> Same rationale as the session-listing path (#5455): a write-capable handle on the multi-GB, WAL ``state.db`` while the agent streams into it adds needless checkpoint/lock surface. The read-only ``file:...?mode=ro`` URI - avoids that. Falls back to a writable connection (and warns) if the - read-only open fails, so callers never lose data on exotic filesystems. + avoids that. Read failures propagate; a reader never upgrades to a writer. The caller must ensure ``db_path`` exists — this raises ``FileNotFoundError`` - for a missing path rather than letting the writable fallback below create an - empty, writable ``state.db`` there (a ghost DB in the agent's HOME). The - fallback is only for an *existing* DB whose read-only open fails on an exotic - filesystem, so a real read never loses data. + for a missing path rather than creating a ghost database. Callers own the returned connection (wrap it in ``contextlib.closing``). """ - log = log or logger if not db_path.exists(): raise FileNotFoundError(f"agent state.db not found: {db_path}") read_only_uri = f"{db_path.resolve().as_uri()}?mode=ro" - try: - return sqlite3.connect(read_only_uri, uri=True) - except sqlite3.Error as exc: - log.warning( - "agent state.db read-only open failed for %s; falling back to writable connection: %s", - db_path, - exc, - ) - return sqlite3.connect(str(db_path)) + return sqlite3.connect(read_only_uri, uri=True) MESSAGING_SOURCES = { @@ -549,21 +536,7 @@ def read_importable_agent_session_rows( return [] log = log or logger - # Open read-only for this projection/listing path: it is a pure read, and - # holding a write-capable handle on the live (multi-GB, WAL) state.db while - # the agent streams into it adds needless checkpoint/lock surface (#5455). - # The defensive index self-heal below still runs, but through a separate - # short-lived writable connection on the rare missing-index path only. - read_only_uri = f"{db_path.resolve().as_uri()}?mode=ro" - try: - conn = sqlite3.connect(read_only_uri, uri=True) - except sqlite3.Error as exc: - log.warning( - "agent session listing read-only open failed for %s; falling back to writable connection: %s", - db_path, - exc, - ) - conn = sqlite3.connect(str(db_path)) + conn = open_state_db_readonly(db_path, log=log) with closing(conn): conn.row_factory = sqlite3.Row cur = conn.cursor() @@ -613,11 +586,7 @@ def read_importable_agent_session_rows( use_messages_join = messages_has_session_id count_col = 'id' if 'id' in message_cols else 'session_id' - # Defensive index prime (#3887). The normal candidate-ordering shape uses - # the agent's standard ``idx_messages_session ON messages(session_id, - # timestamp)`` index; without it, large cron-only scans degrade badly. - # Writable dbs self-heal by recreating the index. Read-only or locked dbs - # fall back to the pre-aggregated cron-only path below instead of failing. + # Index creation belongs to explicit drained maintenance, never a read. messages_index_present = False if messages_has_session_id and messages_has_timestamp: try: @@ -625,21 +594,7 @@ def read_importable_agent_session_rows( messages_index_present = any(str(row[1]) == "idx_messages_session" for row in cur.fetchall()) except sqlite3.Error: messages_index_present = False - if not messages_index_present: - # Self-heal via a separate writable connection so the common - # (index-present) path keeps its read-only handle. On a truly - # read-only/locked db this fails and we degrade to the - # pre-aggregated cron-only path below, exactly as before. - try: - with closing(sqlite3.connect(str(db_path))) as _heal: - _heal.execute( - "CREATE INDEX IF NOT EXISTS idx_messages_session " - "ON messages(session_id, timestamp)" - ) - _heal.commit() - messages_index_present = True - except sqlite3.Error: - pass # read-only db / locked / older schema — degrade gracefully + if use_messages_join: actual_count_expr = f"COUNT(m.{count_col})" diff --git a/api/gateway_watcher.py b/api/gateway_watcher.py index b52f5b0c..0354c337 100644 --- a/api/gateway_watcher.py +++ b/api/gateway_watcher.py @@ -317,6 +317,12 @@ class GatewayWatcher: protects projection fields (notably role-derived CLI visibility) that the agent's existing index cannot see. """ + with self._sub_lock: + has_subscribers = bool(self._subscribers) + if not has_subscribers: + # Force fresh projection on the next subscription, without touching DB. + self._last_full_projection_at = None + return False db_path = self._state_db_path # A watcher may start before the agent has created state.db. Publishing an # empty first snapshot would make an already-rendered sidebar disappear; diff --git a/api/routes.py b/api/routes.py index 7d30ab90..0804cb2e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -46,6 +46,7 @@ from api.agent_sessions import ( _looks_like_default_cli_title, is_cli_session_row, is_cli_session_row_visible, + open_state_db_readonly, read_session_lineage_report, ) from api.compression_anchor import visible_messages_for_anchor @@ -320,7 +321,7 @@ def _latest_cron_session_info_for_jobs( if not db_path or not Path(db_path).exists(): return {jid: {"session_id": "", "message_count": None} for jid in requested} try: - with closing(sqlite3.connect(str(db_path))) as conn: + with closing(open_state_db_readonly(db_path)) as conn: conn.row_factory = sqlite3.Row cur = conn.cursor() cur.execute("PRAGMA table_info(sessions)") @@ -11897,7 +11898,7 @@ def _handle_insights(handler, parsed) -> bool: from api.models import _active_state_db_path db_path = _active_state_db_path() if db_path and db_path.exists(): - with closing(sqlite3.connect(str(db_path))) as conn: + with closing(open_state_db_readonly(db_path)) as conn: conn.row_factory = sqlite3.Row cur = conn.cursor() # cache_read_tokens may not exist on older agent state DBs; @@ -12532,7 +12533,7 @@ def _deep_health_checks(stream_check: dict | None = None) -> tuple[dict, bool]: "ms": round((time.time() - t0) * 1000, 1), } else: - with closing(sqlite3.connect(str(db_path))) as conn: + with closing(open_state_db_readonly(db_path)) as conn: conn.execute("PRAGMA schema_version").fetchone() checks["state_db"] = { "status": "ok", diff --git a/scripts/ensure_state_db_read_indexes.py b/scripts/ensure_state_db_read_indexes.py new file mode 100644 index 00000000..87aad2cd --- /dev/null +++ b/scripts/ensure_state_db_read_indexes.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Create the covering read indexes on the agent ``state.db`` in an explicit, +drained maintenance window. + +The WebUI read paths (session listing, lineage reads, gateway watcher, cron +sidebar, insights, health) never create indexes themselves: ``CREATE INDEX`` +on a multi-GiB ``messages`` table holds the SQLite writer lock for minutes, +which stalls the agent streaming into the same WAL database. Index +maintenance is therefore an operator action run while the agent is idle. + +Usage:: + + python scripts/ensure_state_db_read_indexes.py --db ~/.hermes/state.db \\ + --confirm-drained [--lock-file /path/to/agent-activity.lock] + +``--confirm-drained`` is mandatory. When ``--lock-file`` is given, the tool +takes an exclusive non-blocking ``flock`` on that path so that a deployment +which serialises agent turns on a lock file cannot start a turn mid-rebuild. + +Existing indexes are verified for table, key shape and collation and must be +covering (``EXPLAIN QUERY PLAN``); an incompatible index is reported, never +silently replaced. +""" +import argparse +from contextlib import closing, nullcontext +import fcntl +import json +from pathlib import Path +import sqlite3 + +INDEXES = { + "idx_messages_session": ("messages", (("session_id", "BINARY"), ("timestamp", "BINARY"))), + "idx_messages_session_role": ("messages", (("session_id", "BINARY"), ("role", "NOCASE"))), + "idx_sessions_webui_fingerprint": ("sessions", tuple((c, "BINARY") for c in + ("source", "id", "message_count", "last_activity_at"))), +} + + +def _exclusive_lock(lock_file): + if lock_file is None: + return nullcontext() + handle = open(lock_file, "a+") + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BaseException: + handle.close() + raise + return closing(handle) + + +def ensure_read_indexes(db_path, *, confirmed_drained=False, lock_file=None): + if not confirmed_drained: + raise RuntimeError("requires --confirm-drained") + db_path = Path(db_path).resolve(strict=True) + statuses = {} + with _exclusive_lock(lock_file): + with closing(sqlite3.connect(db_path.as_uri() + "?mode=rw", uri=True)) as db: + db.execute("BEGIN IMMEDIATE") + try: + for name, (table, keys) in INDEXES.items(): + existing = db.execute("SELECT tbl_name FROM sqlite_master WHERE name=?", (name,)).fetchone() + if existing: + actual = tuple((r[2], r[4]) for r in db.execute(f"PRAGMA index_xinfo({name})") if r[5]) + flags = next((r for r in db.execute(f"PRAGMA index_list({table})") if r[1] == name), None) + if existing[0] != table or actual != keys or not flags or flags[2] or flags[4]: + raise RuntimeError(f"Incompatible index: {name}") + statuses[name] = "existing" + else: + columns = ", ".join(f"{col} COLLATE {collation}" for col, collation in keys) + db.execute(f"CREATE INDEX {name} ON {table}({columns})") + statuses[name] = "created" + columns = ", ".join(col for col, _ in keys) + plan = db.execute(f"EXPLAIN QUERY PLAN SELECT {columns} FROM {table} INDEXED BY {name}").fetchall() + if not any(f"COVERING INDEX {name}" in row[3] for row in plan): + raise RuntimeError(f"Index is not covering: {name}") + db.commit() + except BaseException: + db.rollback() + raise + return statuses + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--db", type=Path, required=True, help="path to the agent state.db") + parser.add_argument("--lock-file", type=Path, default=None, + help="optional lock file to hold exclusively (flock) while indexes are built") + parser.add_argument("--confirm-drained", action="store_true", + help="assert that no agent turn is running against this database") + args = parser.parse_args() + result = ensure_read_indexes(args.db, confirmed_drained=args.confirm_drained, + lock_file=args.lock_file) + print(json.dumps(result, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_issue1494_state_db_fd_leak.py b/tests/test_issue1494_state_db_fd_leak.py index 1691e43d..6ba92759 100644 --- a/tests/test_issue1494_state_db_fd_leak.py +++ b/tests/test_issue1494_state_db_fd_leak.py @@ -163,9 +163,9 @@ def test_read_importable_agent_session_rows_closes_connection(tmp_path, tracking read_importable_agent_session_rows(db) _assert_all_closed(tracking_sqlite, "read_importable_agent_session_rows") - # #5455: missing-index self-heal uses one separate short-lived RW connection - # in addition to the five read-only listing connections. - assert len(tracking_sqlite.instances) == 6 + # #5455: the listing is strictly read-only, including when indexes are + # missing — five calls, five read-only connections, no self-heal writer. + assert len(tracking_sqlite.instances) == 5 def test_read_session_lineage_metadata_closes_connection(tmp_path, tracking_sqlite): diff --git a/tests/test_issue2628_cli_sessions_perf.py b/tests/test_issue2628_cli_sessions_perf.py index 70f4513f..90dbaf2c 100644 --- a/tests/test_issue2628_cli_sessions_perf.py +++ b/tests/test_issue2628_cli_sessions_perf.py @@ -212,7 +212,8 @@ def test_importable_agent_rows_push_sidebar_limit_into_sql(tmp_path): assert 'included == ("cron",)' in src assert "not messages_index_present" in src assert "PRAGMA index_list(messages)" in src - assert "CREATE INDEX IF NOT EXISTS idx_messages_session" in src + # Listing is a pure read: index creation lives in the drained maintenance tool. + assert "CREATE INDEX IF NOT EXISTS idx_messages_session" not in src assert "_CRON_PREAGGREGATE_CANDIDATE_ORDER_MIN_MESSAGES" not in src assert "MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id" in src assert "candidate_limit = max(result_limit * 8, result_limit)" in src diff --git a/tests/test_issue3506_memory_and_watcher.py b/tests/test_issue3506_memory_and_watcher.py index 5fa60884..46eb0040 100644 --- a/tests/test_issue3506_memory_and_watcher.py +++ b/tests/test_issue3506_memory_and_watcher.py @@ -534,6 +534,7 @@ def test_poll_loop_skips_projection_when_unchanged(tmp_path, monkeypatch): monkeypatch.setattr(gw, "_get_agent_sessions_from_db", fake_projection) w = gw.GatewayWatcher(state_db_path=db) + w.subscribe() # Idle watchers deliberately do not poll without observers. assert w._poll_once(now=1.0) is True assert projected == [True] diff --git a/tests/test_issue3887_index_prime.py b/tests/test_issue3887_index_prime.py index 399f3406..d3641989 100644 --- a/tests/test_issue3887_index_prime.py +++ b/tests/test_issue3887_index_prime.py @@ -74,28 +74,38 @@ def _messages_indexes(path): return {r[0] for r in rows} -def test_prime_creates_missing_index(tmp_path): - """A db missing idx_messages_session gets it primed on the first scan.""" +def test_listing_does_not_create_missing_index(tmp_path, monkeypatch): + """A missing index must never turn a sidebar read into a schema write. + + CREATE INDEX on a multi-GiB ``messages`` table holds the SQLite writer lock + for minutes; index maintenance belongs to the explicit drained tool + (``scripts/ensure_state_db_read_indexes.py``), not to a listing. + """ db = tmp_path / "state.db" _full_schema_db(db) assert "idx_messages_session" not in _messages_indexes(db) + connect_calls = [] + real_connect = agent_sessions.sqlite3.connect + + def recording_connect(*args, **kwargs): + connect_calls.append((args, kwargs)) + return real_connect(*args, **kwargs) + + monkeypatch.setattr(agent_sessions.sqlite3, "connect", recording_connect) rows = agent_sessions.read_importable_agent_session_rows( db, limit=20, exclude_sources=None ) + # Snapshot before the verifier below opens its own (non-listing) handle. + listing_connect_calls = list(connect_calls) # Listing still returns the sessions ... assert {r["id"] for r in rows} == {"sess0", "sess1", "sess2"} - # ... and the index now exists on (session_id, timestamp). - assert "idx_messages_session" in _messages_indexes(db) - conn = sqlite3.connect(str(db)) - try: - sql = conn.execute( - "SELECT sql FROM sqlite_master WHERE name='idx_messages_session'" - ).fetchone()[0] - finally: - conn.close() - assert "session_id" in sql and "timestamp" in sql + # ... without mutating the schema or opening a write-capable connection. + assert "idx_messages_session" not in _messages_indexes(db) + assert len(listing_connect_calls) == 1 + assert "mode=ro" in str(listing_connect_calls[0][0][0]) + assert listing_connect_calls[0][1].get("uri") is True def test_prime_is_noop_when_index_exists(tmp_path): diff --git a/tests/test_issue5455_lineage_readonly_reads.py b/tests/test_issue5455_lineage_readonly_reads.py index 35d3af71..be2ec777 100644 --- a/tests/test_issue5455_lineage_readonly_reads.py +++ b/tests/test_issue5455_lineage_readonly_reads.py @@ -9,7 +9,6 @@ lineage-metadata reads, and the gateway-watcher fingerprint projection (a 5s poll), were still opening a read-WRITE connection. This shared them onto the same ``open_state_db_readonly`` helper. """ -import logging import sqlite3 from contextlib import closing @@ -98,25 +97,25 @@ def test_helper_encodes_special_path_chars(tmp_path, monkeypatch): assert calls[0]["target"].endswith("?mode=ro") -def test_helper_falls_back_to_writable_and_logs(tmp_path, monkeypatch, caplog): +def test_helper_never_falls_back_to_writable(tmp_path, monkeypatch): + """A read-only open failure propagates; the helper never retries with a + write-capable handle on the live WAL database.""" db = tmp_path / "state.db" _make_lineage_db(db) - real_connect = sqlite3.connect + calls = [] def fail_read_only(target, *args, **kwargs): - if kwargs.get("uri"): - raise sqlite3.OperationalError("synthetic read-only URI failure") - return real_connect(target, *args, **kwargs) + calls.append({"target": str(target), "uri": bool(kwargs.get("uri"))}) + raise sqlite3.OperationalError("synthetic read-only URI failure") monkeypatch.setattr(agent_sessions.sqlite3, "connect", fail_read_only) - with caplog.at_level(logging.WARNING, logger="api.agent_sessions"): - conn = open_state_db_readonly(db) - try: - assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 2 - finally: - conn.close() - assert "read-only open failed" in caplog.text - assert "synthetic read-only URI failure" in caplog.text + with pytest.raises(sqlite3.OperationalError, match="synthetic read-only URI failure"): + open_state_db_readonly(db) + + # Exactly one attempt, and it was the read-only URI form. + assert len(calls) == 1 + assert calls[0]["uri"] is True + assert calls[0]["target"].endswith("?mode=ro") def test_helper_raises_on_missing_db_instead_of_creating_a_ghost(tmp_path): diff --git a/tests/test_issue5455_listing_readonly_connection.py b/tests/test_issue5455_listing_readonly_connection.py index fc2cb263..06bdaa9e 100644 --- a/tests/test_issue5455_listing_readonly_connection.py +++ b/tests/test_issue5455_listing_readonly_connection.py @@ -6,13 +6,12 @@ re-run a defensive ``CREATE INDEX`` self-heal on every sidebar build. Holding a write-capable handle while the agent streams into the same DB adds needless checkpoint/lock surface. -The listing path now opens the DB read-only (``file:...?mode=ro``) and only -self-heals a missing ``idx_messages_session`` through a SEPARATE short-lived -writable connection. With the index present (the normal case) the read path -performs zero writes; when the index is missing the self-heal still runs and -rows still come back. +The listing path opens the DB read-only (``file:...?mode=ro``) and never +upgrades to a writable handle: a read-only open failure propagates, and a +missing ``idx_messages_session`` degrades to the pre-aggregated path instead +of being self-healed. Index maintenance is an explicit drained operation +(``scripts/ensure_state_db_read_indexes.py``). """ -import logging import sqlite3 import api.agent_sessions as agent_sessions @@ -92,24 +91,27 @@ def test_listing_read_only_uri_encodes_special_path_chars(tmp_path, monkeypatch) assert calls[0]["target"].endswith("?mode=ro") -def test_read_only_open_fallback_is_logged(tmp_path, monkeypatch, caplog): +def test_read_only_open_failure_never_retries_with_writable_connection(tmp_path, monkeypatch): db = tmp_path / "state.db" _make_db(db, with_index=True) - real_connect = sqlite3.connect + calls = [] def fail_read_only(target, *args, **kwargs): - if kwargs.get("uri"): - raise sqlite3.OperationalError("synthetic read-only URI failure") - return real_connect(target, *args, **kwargs) + calls.append({"target": str(target), "uri": bool(kwargs.get("uri"))}) + raise sqlite3.OperationalError("synthetic read-only URI failure") monkeypatch.setattr(agent_sessions.sqlite3, "connect", fail_read_only) - with caplog.at_level(logging.WARNING, logger="api.agent_sessions"): - out = read_importable_agent_session_rows(db, exclude_sources=None) + try: + read_importable_agent_session_rows(db, exclude_sources=None) + raise AssertionError("read-only open failure unexpectedly recovered") + except sqlite3.OperationalError as exc: + assert "synthetic read-only URI failure" in str(exc) - assert "cli-1" in {r["id"] for r in out} - assert "read-only open failed" in caplog.text - assert "synthetic read-only URI failure" in caplog.text + # Exactly one attempt, and it was the read-only URI form. + assert len(calls) == 1 + assert calls[0]["uri"] is True + assert "mode=ro" in calls[0]["target"] def test_index_present_performs_no_writable_connection(tmp_path, monkeypatch): @@ -124,7 +126,7 @@ def test_index_present_performs_no_writable_connection(tmp_path, monkeypatch): assert all(c["uri"] and "mode=ro" in c["target"] for c in calls), calls -def test_missing_index_self_heals_via_separate_connection(tmp_path, monkeypatch): +def test_missing_index_degrades_without_writable_connection(tmp_path, monkeypatch): db = tmp_path / "state.db" _make_db(db, with_index=False) calls = _record_connects(monkeypatch) @@ -133,12 +135,13 @@ def test_missing_index_self_heals_via_separate_connection(tmp_path, monkeypatch) # Rows still come back... assert "cli-1" in {r["id"] for r in out} - # ...and the self-heal ran through a separate writable (non-ro) connection. - assert any((not c["uri"]) and "mode=ro" not in c["target"] for c in calls), calls - # The index now exists on disk. + # ...without any implicit schema-maintenance writer. + assert calls + assert all(c["uri"] and "mode=ro" in c["target"] for c in calls), calls + # A missing index stays a maintenance concern, not a listing side effect. verify = sqlite3.connect(str(db)) try: names = {row[1] for row in verify.execute("PRAGMA index_list(messages)")} finally: verify.close() - assert "idx_messages_session" in names + assert "idx_messages_session" not in names diff --git a/tests/test_state_db_readonly_readers.py b/tests/test_state_db_readonly_readers.py new file mode 100644 index 00000000..25dd8d20 --- /dev/null +++ b/tests/test_state_db_readonly_readers.py @@ -0,0 +1,124 @@ +"""state.db readers stay strictly read-only and idle watchers do not poll. + +Contract under test: + +* ``open_state_db_readonly`` opens ``file:...?mode=ro`` once and propagates a + failure — it never retries with a write-capable handle; +* the gateway watcher does not touch the database while nobody is subscribed; +* the session listing never runs ``CREATE INDEX`` (a writer lock held for + minutes on a large ``messages`` table) — a missing index degrades instead; +* index maintenance is an explicit drained operation + (``scripts/ensure_state_db_read_indexes.py``); +* the cron sidebar read in ``api/routes.py`` uses the read-only opener. +""" +import fcntl +import sqlite3 +from contextlib import closing + +import pytest + + +def test_readonly_open_failure_never_retries_writable(tmp_path, monkeypatch): + from api import agent_sessions + path = tmp_path / "state.db" + path.touch() + calls = [] + + def connect(*args, **kwargs): + calls.append((args, kwargs)) + raise sqlite3.OperationalError("readonly unavailable") + + monkeypatch.setattr(agent_sessions.sqlite3, "connect", connect) + with pytest.raises(sqlite3.OperationalError): + agent_sessions.open_state_db_readonly(path) + assert len(calls) == 1 + assert calls[0][1] == {"uri": True} + + +def test_no_subscribers_means_no_database_poll(tmp_path, monkeypatch): + from api import gateway_watcher as watcher + path = tmp_path / "state.db" + path.touch() + instance = watcher.GatewayWatcher(hermes_home=tmp_path) + monkeypatch.setattr(watcher, "_cheap_change_fingerprint", lambda *a: pytest.fail("polled DB")) + monkeypatch.setattr(watcher, "_get_agent_sessions_from_db", lambda *a: pytest.fail("projected payload")) + assert instance._poll_once(now=500) is False + + +def test_listing_does_not_create_indexes(tmp_path): + from api.agent_sessions import read_importable_agent_session_rows + path = tmp_path / "state.db" + with closing(sqlite3.connect(path)) as db: + db.executescript(""" + CREATE TABLE sessions(id TEXT PRIMARY KEY, title TEXT, model TEXT, + message_count INTEGER, started_at REAL, source TEXT); + CREATE TABLE messages(id INTEGER PRIMARY KEY, session_id TEXT, timestamp REAL, role TEXT); + INSERT INTO sessions VALUES ('one', 'Test', 'test', 2, 1, 'cli'); + INSERT INTO messages VALUES (1, 'one', 2, 'user'); + INSERT INTO messages VALUES (2, 'one', 3, 'assistant'); + """) + rows = read_importable_agent_session_rows(path) + assert rows[0]["id"] == "one" + with closing(sqlite3.connect(path)) as db: + assert db.execute("PRAGMA index_list(messages)").fetchall() == [] + + +def _maintenance_schema(path): + with closing(sqlite3.connect(path)) as db: + db.executescript(""" + CREATE TABLE sessions(source TEXT, id TEXT, message_count INTEGER, last_activity_at REAL); + CREATE TABLE messages(session_id TEXT, timestamp REAL, role TEXT); + """) + + +def test_index_maintenance_requires_drain_and_is_idempotent(tmp_path): + from scripts.ensure_state_db_read_indexes import ensure_read_indexes + path = tmp_path / "state.db" + _maintenance_schema(path) + with pytest.raises(RuntimeError, match="confirm-drained"): + ensure_read_indexes(path) + lock = tmp_path / "turns.lock" + first = ensure_read_indexes(path, confirmed_drained=True, lock_file=lock) + second = ensure_read_indexes(path, confirmed_drained=True, lock_file=lock) + assert set(first.values()) == {"created"} + assert set(second.values()) == {"existing"} + assert "idx_messages_session_role" in first + + +def test_index_maintenance_without_lock_file_still_requires_drain(tmp_path): + from scripts.ensure_state_db_read_indexes import ensure_read_indexes + path = tmp_path / "state.db" + _maintenance_schema(path) + result = ensure_read_indexes(path, confirmed_drained=True) + assert set(result.values()) == {"created"} + + +def test_index_maintenance_refuses_when_lock_is_held(tmp_path): + from scripts.ensure_state_db_read_indexes import ensure_read_indexes + path = tmp_path / "state.db" + _maintenance_schema(path) + lock = tmp_path / "turns.lock" + with open(lock, "a+") as holder: + fcntl.flock(holder, fcntl.LOCK_EX | fcntl.LOCK_NB) + with pytest.raises(BlockingIOError): + ensure_read_indexes(path, confirmed_drained=True, lock_file=lock) + with closing(sqlite3.connect(path)) as db: + assert db.execute("PRAGMA index_list(messages)").fetchall() == [] + + +def test_cron_sidebar_read_uses_readonly_connection(tmp_path, monkeypatch): + from api import routes + path = tmp_path / 'state.db' + with closing(sqlite3.connect(path)) as db: + db.execute('CREATE TABLE sessions(id TEXT, source TEXT)') + monkeypatch.setattr(routes, '_active_state_db_path', lambda: path) + connect = sqlite3.connect + calls = [] + def recording(*args, **kwargs): + calls.append((args, kwargs)) + return connect(*args, **kwargs) + monkeypatch.setattr(sqlite3, 'connect', recording) + routes._latest_cron_session_info_for_jobs(['test']) + assert len(calls) == 1 + assert 'mode=ro' in calls[0][0][0] + assert calls[0][1].get('uri') is True From f5a7ef2049808e8621266eb2997b8cc44688d2fd Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Wed, 16 Sep 2026 22:44:55 +0000 Subject: [PATCH 099/446] fix(sqlite): pre-aggregate every missing-index listing, UNC-safe read-only URI, portable index tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #7445 (nesquena-hermes CHANGES_REQUESTED + Greptile). api/agent_sessions.py - The read-only pre-aggregated `latest_messages` candidate ordering now applies to every missing-index projection (default sidebar, gateway watcher, cron/webhook/kanban views), not only `include_sources == ("cron",)`. Since the listing no longer self-heals the index, the correlated `MAX(mx.timestamp)` scan re-ran on every load: 9.86 s on a 5,000-session / 20,000-message DB vs 0.044 s after this change, with identical selected rows and order. The indexed seek is unchanged. - `state_db_file_uri()` / `state_db_readonly_uri()` replace `Path.as_uri()`: a UNC `HERMES_HOME` produced `file://server/share/...` and CPython's bundled SQLite (3.11-3.13 Windows builds, no SQLITE_ALLOW_URI_AUTHORITY) rejected the authority, so every agent-backed session vanished. The builder emits the empty-authority `file:////server/share/state.db?mode=ro`, keeps `file:///C:/...` for drive letters, strips `\\?\` / `\\?\UNC\` prefixes, quotes `?`/`#`, and leaves POSIX output byte-identical. Strict `mode=ro`, no writable fallback. scripts/ensure_state_db_read_indexes.py - `fcntl` / `msvcrt` are imported conditionally; the module imports and the no-`--lock-file` path runs on native Windows. `--lock-file` uses `flock` on POSIX, `msvcrt.locking(LK_NBLCK)` on Windows, and fails with a clear RuntimeError when neither exists. The rw open reuses the UNC-aware URI builder. docs/troubleshooting.md - Operator section: read-only contract, degraded missing-index listing, drained maintenance prerequisites, lock behaviour, failure modes, UNC. Tests: tests/test_pr7445_readonly_review_followups.py (19 tests, red before this change: correlated-subquery plan, missing URI builder, ImportError on fcntl). Query-plan contract via EXPLAIN QUERY PLAN — no CORRELATED SCALAR SUBQUERY on missing-index listings, identical rows to the indexed listing; platform-parametrised URI cases; maintenance module imported with `sys.modules["fcntl"] = None`. test_issue2628 no longer pins the cron-only gate; the flock-held test skips without fcntl. --- api/agent_sessions.py | 59 +++- docs/troubleshooting.md | 30 ++ scripts/ensure_state_db_read_indexes.py | 35 ++- tests/test_issue2628_cli_sessions_perf.py | 3 +- .../test_pr7445_readonly_review_followups.py | 262 ++++++++++++++++++ tests/test_state_db_readonly_readers.py | 2 +- 6 files changed, 379 insertions(+), 12 deletions(-) create mode 100644 tests/test_pr7445_readonly_review_followups.py diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 310df64d..6c5691d9 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -1,8 +1,10 @@ """Shared helpers for reading Hermes Agent sessions from state.db.""" import logging import sqlite3 +import sys from contextlib import closing -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath +from urllib.parse import quote logger = logging.getLogger(__name__) @@ -18,6 +20,49 @@ logger = logging.getLogger(__name__) _SOURCE_COLUMN_WARNED_DB_PATHS: set[str] = set() +def state_db_file_uri(db_path, platform: str | None = None) -> str: + """Build the ``file:`` URI (no query string) for an absolute ``state.db`` path. + + ``Path.as_uri()`` is not usable here: for a UNC share it emits + ``file://server/share/state.db`` and SQLite rejects any non-local URI + authority unless compiled with ``SQLITE_ALLOW_URI_AUTHORITY`` (the + CPython 3.11-3.13 Windows builds are not). SQLite instead accepts the + *empty*-authority spelling ``file:////server/share/state.db``, whose path + ``//server/share/...`` the Windows VFS opens as the UNC name. Drive-letter + paths keep the documented ``file:///C:/...`` form and POSIX paths are + unchanged. Only ``/`` survives unescaped, so ``?`` and ``#`` in path + components cannot leak into the query string. + + ``platform`` defaults to the running interpreter; tests pass it explicitly + so the Windows shapes are checked from any host. + """ + platform = platform or sys.platform + if platform == "win32": + win = PureWindowsPath(str(db_path)) + drive = win.drive + if drive.startswith("\\\\?\\"): + # Extended-length prefix: "\\?\C:" or "\\?\UNC\server\share". + drive = drive[4:] + if drive.upper().startswith("UNC\\"): + drive = "\\\\" + drive[4:] + parts = win.parts[1:] # drop the anchor, keep the path components + if drive.startswith("\\\\"): + host_share = drive[2:].replace("\\", "/") + posix_path = "//" + "/".join((host_share, *parts)) + else: + posix_path = "/" + "/".join((drive, *parts)) + # ``:`` stays literal so the drive letter keeps SQLite's documented + # ``file:///C:/...`` shape (``Path.as_uri()`` leaves it unescaped too). + return "file://" + quote(posix_path, safe="/:") + posix_path = PurePosixPath(str(db_path)).as_posix() + return "file://" + quote(posix_path, safe="/") + + +def state_db_readonly_uri(db_path, platform: str | None = None) -> str: + """Strict read-only (``mode=ro``) form of :func:`state_db_file_uri`.""" + return state_db_file_uri(db_path, platform=platform) + "?mode=ro" + + def open_state_db_readonly(db_path: Path, log: logging.Logger | None = None) -> sqlite3.Connection: """Open the live agent ``state.db`` read-only for a pure-read projection. @@ -33,8 +78,7 @@ def open_state_db_readonly(db_path: Path, log: logging.Logger | None = None) -> """ if not db_path.exists(): raise FileNotFoundError(f"agent state.db not found: {db_path}") - read_only_uri = f"{db_path.resolve().as_uri()}?mode=ro" - return sqlite3.connect(read_only_uri, uri=True) + return sqlite3.connect(state_db_readonly_uri(db_path.resolve()), uri=True) MESSAGING_SOURCES = { @@ -620,7 +664,6 @@ def read_importable_agent_session_rows( where_clauses = ["s.source IS NOT NULL"] params: list[object] = [] - included = () if include_sources: included = tuple(str(source) for source in include_sources if source) if included: @@ -634,10 +677,16 @@ def read_importable_agent_session_rows( where_clauses.append(f"s.source NOT IN ({placeholders})") params.extend(excluded) + # Without ``idx_messages_session`` the correlated ``MAX(mx.timestamp)`` + # candidate ordering rescans ``messages`` once per session (seconds on + # a few thousand sessions). Every missing-index projection — default + # sidebar, gateway watcher, cron/webhook/kanban views — orders through + # one read-only pre-aggregation pass instead; the listing never creates + # the index itself (that is drained maintenance, see + # ``scripts/ensure_state_db_read_indexes.py``). use_preaggregated_candidate_order = ( use_messages_join and messages_has_timestamp - and included == ("cron",) and not messages_index_present ) if use_preaggregated_candidate_order: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f3335f98..c6fcffd8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -211,6 +211,36 @@ For a foreground `python3 bootstrap.py`, stop it with Ctrl-C and start it again. --- +## Agent sessions list slowly (or the `state.db` read index is missing) + +**Symptom.** The sidebar's imported/CLI session list takes seconds per refresh on a large Hermes profile, or a log line says a `state.db` read failed. Sessions still appear; nothing is lost. + +**Why.** Every WebUI reader of the agent's `state.db` (session listing, transcript reads, lineage, gateway watcher, cron sidebar, insights, health) opens it strictly read-only (`file:...?mode=ro`). A reader never upgrades to a write-capable handle and never creates an index: on a multi-GiB `messages` table `CREATE INDEX` holds the SQLite writer lock for minutes and stalls the agent streaming into the same WAL database. When the agent's standard `idx_messages_session` index is missing (older agent, hand-rebuilt or re-imported DB), listings degrade to a bounded one-pass pre-aggregation — slower than the indexed seek, but read-only. A read-only open failure propagates to the caller's existing error boundary (the listing returns empty for that profile) instead of silently reopening the file writable. + +**Diagnostic.** + +```bash +sqlite3 "file:$HOME/.hermes/state.db?mode=ro" "PRAGMA index_list(messages)" +``` + +`idx_messages_session` should be listed. If it is not, the agent has not created it and WebUI will not create it for you. + +**Fix.** Create the covering read indexes in an explicit drained maintenance window: stop the agent (and any gateway/cron runner) writing to that `state.db`, then run: + +```bash +python3 scripts/ensure_state_db_read_indexes.py --db ~/.hermes/state.db --confirm-drained +``` + +- `--confirm-drained` is mandatory: it is your assertion that no agent turn is running against the database. The tool does not verify it. +- `--lock-file PATH` (optional) additionally holds an exclusive non-blocking lock on `PATH` (`flock` on POSIX, `msvcrt.locking` on Windows) for deployments that serialise agent turns on a lock file; a held lock makes the tool exit without touching the database. Without `--lock-file` no lock primitive is required, so the script runs on native Windows as well. +- The tool opens the database `mode=rw` (never `rwc`): a mistyped path raises instead of creating an empty database. Indexes are created inside one `BEGIN IMMEDIATE` transaction and rolled back on any error. +- It is idempotent and prints a JSON status per index (`created` / `existing`). An existing index with a different table, key shape or collation is reported as `Incompatible index` and never replaced; an index that is not covering (`EXPLAIN QUERY PLAN`) is reported as `Index is not covering`. +- Windows UNC profiles (`HERMES_HOME=\\server\share\...`) are supported: readers and this tool build the empty-authority URI `file:////server/share/state.db` that the bundled SQLite accepts. + +**When to file a bug.** File a WebUI bug if the listing stays slow after the tool reports `existing` for `idx_messages_session`, if the tool reports `Incompatible index` on an untouched agent-created database, or if a read-only open fails on a local path. Include the tool's JSON output, the `PRAGMA index_list(messages)` result, and the sanitized error text. + +--- + ## 404 after login when password auth is enabled **Symptom.** After enabling password authentication (`HERMES_WEBUI_PASSWORD`), logging in redirects to `/sessions` and the browser shows a `404 not found` error instead of the chat interface. diff --git a/scripts/ensure_state_db_read_indexes.py b/scripts/ensure_state_db_read_indexes.py index 87aad2cd..2513b0fc 100644 --- a/scripts/ensure_state_db_read_indexes.py +++ b/scripts/ensure_state_db_read_indexes.py @@ -14,8 +14,10 @@ Usage:: --confirm-drained [--lock-file /path/to/agent-activity.lock] ``--confirm-drained`` is mandatory. When ``--lock-file`` is given, the tool -takes an exclusive non-blocking ``flock`` on that path so that a deployment -which serialises agent turns on a lock file cannot start a turn mid-rebuild. +takes an exclusive non-blocking lock on that path (``flock`` on POSIX, +``msvcrt.locking`` on Windows) so that a deployment which serialises agent +turns on a lock file cannot start a turn mid-rebuild. Without ``--lock-file`` +no lock primitive is needed, so the script runs on every supported platform. Existing indexes are verified for table, key shape and collation and must be covering (``EXPLAIN QUERY PLAN``); an incompatible index is reported, never @@ -23,10 +25,24 @@ silently replaced. """ import argparse from contextlib import closing, nullcontext -import fcntl import json from pathlib import Path import sqlite3 +import sys + +try: # POSIX + import fcntl +except ImportError: # native Windows + fcntl = None +try: # Windows + import msvcrt +except ImportError: # POSIX + msvcrt = None + +_REPO_ROOT = Path(__file__).resolve().parents[1] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) +from api.agent_sessions import state_db_file_uri # same UNC-aware URI as the readers INDEXES = { "idx_messages_session": ("messages", (("session_id", "BINARY"), ("timestamp", "BINARY"))), @@ -39,9 +55,17 @@ INDEXES = { def _exclusive_lock(lock_file): if lock_file is None: return nullcontext() + if fcntl is None and msvcrt is None: + raise RuntimeError("--lock-file needs fcntl (POSIX) or msvcrt (Windows); " + "neither is available on this interpreter") handle = open(lock_file, "a+") try: - fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + if fcntl is not None: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + else: + # Lock one byte at offset 0 without blocking; raises OSError when held. + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) except BaseException: handle.close() raise @@ -54,7 +78,8 @@ def ensure_read_indexes(db_path, *, confirmed_drained=False, lock_file=None): db_path = Path(db_path).resolve(strict=True) statuses = {} with _exclusive_lock(lock_file): - with closing(sqlite3.connect(db_path.as_uri() + "?mode=rw", uri=True)) as db: + # mode=rw (not rwc): never create a ghost database at a mistyped path. + with closing(sqlite3.connect(state_db_file_uri(db_path) + "?mode=rw", uri=True)) as db: db.execute("BEGIN IMMEDIATE") try: for name, (table, keys) in INDEXES.items(): diff --git a/tests/test_issue2628_cli_sessions_perf.py b/tests/test_issue2628_cli_sessions_perf.py index 90dbaf2c..556e5509 100644 --- a/tests/test_issue2628_cli_sessions_perf.py +++ b/tests/test_issue2628_cli_sessions_perf.py @@ -209,7 +209,8 @@ def test_importable_agent_rows_push_sidebar_limit_into_sql(tmp_path): assert "JOIN candidates c ON c.id = s.id" in src assert "latest_messages AS" in src assert "LEFT JOIN latest_messages lm ON lm.session_id = s.id" in src - assert 'included == ("cron",)' in src + # Pre-aggregation covers every missing-index projection, not only cron (#7445 review). + assert 'included == ("cron",)' not in src assert "not messages_index_present" in src assert "PRAGMA index_list(messages)" in src # Listing is a pure read: index creation lives in the drained maintenance tool. diff --git a/tests/test_pr7445_readonly_review_followups.py b/tests/test_pr7445_readonly_review_followups.py new file mode 100644 index 00000000..a2ecd444 --- /dev/null +++ b/tests/test_pr7445_readonly_review_followups.py @@ -0,0 +1,262 @@ +"""Review follow-ups for the strictly read-only ``state.db`` readers (PR #7445). + +Contract under test: + +* every missing-index projection (default sidebar, webhook, kanban, …), not + only the cron view, orders its candidate window through the read-only + pre-aggregated ``latest_messages`` CTE — never through the correlated + ``MAX(mx.timestamp)`` scalar subquery that rescans ``messages`` per session; + the selected rows and their order stay identical to the indexed listing; +* ``state_db_readonly_uri`` builds a ``file:`` URI SQLite accepts on every + supported platform: POSIX unchanged, Windows drive letters as + ``file:///C:/...``, UNC shares with an *empty* authority + (``file:////server/share/...``) because CPython's bundled SQLite rejects any + non-local authority, extended-length ``\\\\?\\`` prefixes stripped; the + query string is always the strict ``mode=ro``; +* ``scripts/ensure_state_db_read_indexes.py`` imports and runs its no-lock path + on a platform without ``fcntl`` (native Windows), and refuses ``--lock-file`` + there with a clear error instead of an ``ImportError`` at import time. +""" +import importlib +import sqlite3 +import sys +from contextlib import closing + +import pytest + +import api.agent_sessions as agent_sessions + + +# -------------------------------------------------------------------------- +# 1. Missing-index projections use the pre-aggregated candidate ordering. +# -------------------------------------------------------------------------- + +def _make_state_db(path, *, sessions, source, with_index): + with closing(sqlite3.connect(str(path))) as conn: + conn.executescript( + """ + CREATE TABLE sessions( + id TEXT PRIMARY KEY, title TEXT, model TEXT, message_count INTEGER, + started_at REAL, source TEXT, session_source TEXT, + parent_session_id TEXT, ended_at REAL, end_reason TEXT); + CREATE TABLE messages( + id INTEGER PRIMARY KEY, session_id TEXT, role TEXT, + content TEXT, timestamp REAL); + """ + ) + for i in range(sessions): + sid = f"{source}-{i:04d}" + conn.execute( + "INSERT INTO sessions VALUES (?,?,?,?,?,?,?,NULL,NULL,NULL)", + (sid, f"Session {i}", "m", 3, 1000.0 + i, source, source), + ) + for j in range(3): + conn.execute( + "INSERT INTO messages(session_id, role, content, timestamp) VALUES (?,?,?,?)", + (sid, "user" if j == 0 else "assistant", "x", 1000.0 + i + j / 10), + ) + # A resumed old session: started first, but its last message is newest. + conn.execute( + "INSERT INTO sessions VALUES (?,?,?,?,?,?,?,NULL,NULL,NULL)", + (f"{source}-resumed", "Resumed", "m", 2, 1.0, source, source), + ) + conn.execute( + "INSERT INTO messages(session_id, role, content, timestamp) VALUES (?,?,?,?)", + (f"{source}-resumed", "user", "old", 1.0), + ) + conn.execute( + "INSERT INTO messages(session_id, role, content, timestamp) VALUES (?,?,?,?)", + (f"{source}-resumed", "assistant", "recent", 5000.0), + ) + if with_index: + conn.execute("CREATE INDEX idx_messages_session ON messages(session_id, timestamp)") + conn.commit() + + +def _capture_listing_sql(monkeypatch): + """Record the SQL statements the listing runs (trace callback, read-only).""" + statements = [] + real_connect = sqlite3.connect + + def tracing_connect(*args, **kwargs): + conn = real_connect(*args, **kwargs) + conn.set_trace_callback(statements.append) + return conn + + monkeypatch.setattr(agent_sessions.sqlite3, "connect", tracing_connect) + return statements + + +def _candidate_query_plan(db_path, statements): + listing_sql = [s for s in statements if "FROM sessions s" in s] + assert len(listing_sql) == 1, listing_sql + with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as conn: + return [row[3] for row in conn.execute("EXPLAIN QUERY PLAN " + listing_sql[0])] + + +@pytest.mark.parametrize( + ("source", "kwargs"), + [ + ("cli", {}), # default /api/sessions projection + ("webhook", {"exclude_sources": None, "include_sources": ("webhook",)}), + ("kanban", {"exclude_sources": None, "include_sources": ("kanban",)}), + ("cli", {"exclude_sources": None}), # gateway watcher / diagnostic view + ], +) +def test_missing_index_projection_uses_preaggregated_ordering(tmp_path, monkeypatch, source, kwargs): + indexed = tmp_path / "indexed.db" + unindexed = tmp_path / "unindexed.db" + _make_state_db(indexed, sessions=60, source=source, with_index=True) + _make_state_db(unindexed, sessions=60, source=source, with_index=False) + + expected = agent_sessions.read_importable_agent_session_rows(indexed, limit=20, **kwargs) + assert expected[0]["id"] == f"{source}-resumed" + assert len(expected) == 20 + + statements = _capture_listing_sql(monkeypatch) + actual = agent_sessions.read_importable_agent_session_rows(unindexed, limit=20, **kwargs) + + # Same rows, same order as the indexed listing. + assert [r["id"] for r in actual] == [r["id"] for r in expected] + assert [r["actual_message_count"] for r in actual] == [r["actual_message_count"] for r in expected] + assert [r["last_activity"] for r in actual] == [r["last_activity"] for r in expected] + + plan = _candidate_query_plan(unindexed, statements) + # One pre-aggregation pass over messages, never a per-candidate rescan. + assert not any("CORRELATED SCALAR SUBQUERY" in step for step in plan), plan + assert any("latest_messages" in step for step in plan), plan + # The listing stayed a pure read: no index appeared. + with closing(sqlite3.connect(str(unindexed))) as conn: + assert conn.execute("PRAGMA index_list(messages)").fetchall() == [] + + +def test_indexed_projection_keeps_the_correlated_index_seek(tmp_path, monkeypatch): + """With ``idx_messages_session`` present the indexed seek stays in place.""" + db = tmp_path / "state.db" + _make_state_db(db, sessions=10, source="cli", with_index=True) + statements = _capture_listing_sql(monkeypatch) + rows = agent_sessions.read_importable_agent_session_rows(db, limit=20) + assert rows[0]["id"] == "cli-resumed" + plan = _candidate_query_plan(db, statements) + assert not any("latest_messages" in step for step in plan), plan + assert any("idx_messages_session" in step for step in plan), plan + + +# -------------------------------------------------------------------------- +# 2. Read-only URI construction is platform-aware and strictly mode=ro. +# -------------------------------------------------------------------------- + +@pytest.mark.parametrize( + ("platform", "path", "expected"), + [ + ("linux", "/home/me/.hermes/state.db", "file:///home/me/.hermes/state.db?mode=ro"), + ("darwin", "/Users/me/state dir #1/state?.db", + "file:///Users/me/state%20dir%20%231/state%3F.db?mode=ro"), + ("win32", r"C:\Users\me\.hermes\state.db", "file:///C:/Users/me/.hermes/state.db?mode=ro"), + ("win32", r"C:\a b\state#?.db", "file:///C:/a%20b/state%23%3F.db?mode=ro"), + ("win32", r"\\server\share\hermes\state.db", "file:////server/share/hermes/state.db?mode=ro"), + ("win32", r"\\server\share\a b\state#.db", "file:////server/share/a%20b/state%23.db?mode=ro"), + ("win32", r"\\?\C:\x\state.db", "file:///C:/x/state.db?mode=ro"), + ("win32", r"\\?\UNC\server\share\state.db", "file:////server/share/state.db?mode=ro"), + ], +) +def test_state_db_readonly_uri_by_platform(platform, path, expected): + assert agent_sessions.state_db_readonly_uri(path, platform=platform) == expected + + +def test_state_db_readonly_uri_never_carries_a_host_authority(): + uri = agent_sessions.state_db_readonly_uri(r"\\server\share\state.db", platform="win32") + assert uri.startswith("file:////") + assert "file://server" not in uri + assert uri.endswith("?mode=ro") + + +def test_open_state_db_readonly_uses_platform_uri_builder(tmp_path, monkeypatch): + path = tmp_path / "state.db" + with closing(sqlite3.connect(str(path))) as conn: + conn.execute("CREATE TABLE t(x)") + calls = [] + real_connect = sqlite3.connect + + def recording(target, *args, **kwargs): + calls.append((str(target), kwargs)) + return real_connect(target, *args, **kwargs) + + monkeypatch.setattr(agent_sessions.sqlite3, "connect", recording) + with closing(agent_sessions.open_state_db_readonly(path)) as conn: + assert conn.execute("SELECT COUNT(*) FROM t").fetchone() == (0,) + assert calls == [(agent_sessions.state_db_readonly_uri(path.resolve()), {"uri": True})] + assert calls[0][0].endswith("?mode=ro") + + +def test_empty_authority_uri_opens_on_this_platform(tmp_path): + """SQLite accepts the empty-authority form the UNC path relies on.""" + path = tmp_path / "state.db" + with closing(sqlite3.connect(str(path))) as conn: + conn.execute("CREATE TABLE t(x)") + uri = agent_sessions.state_db_readonly_uri(path.resolve()) + with closing(sqlite3.connect(uri, uri=True)) as conn: + assert conn.execute("SELECT COUNT(*) FROM t").fetchone() == (0,) + with pytest.raises(sqlite3.OperationalError): + conn.execute("INSERT INTO t VALUES (1)") + + +# -------------------------------------------------------------------------- +# 3. The index maintenance script imports and runs without fcntl. +# -------------------------------------------------------------------------- + +def _maintenance_schema(path): + with closing(sqlite3.connect(str(path))) as conn: + conn.executescript( + """ + CREATE TABLE sessions(source TEXT, id TEXT, message_count INTEGER, last_activity_at REAL); + CREATE TABLE messages(session_id TEXT, timestamp REAL, role TEXT); + """ + ) + + +@pytest.fixture +def maintenance_without_fcntl(monkeypatch): + """Import the maintenance module as a platform without ``fcntl`` sees it.""" + name = "scripts.ensure_state_db_read_indexes" + monkeypatch.setitem(sys.modules, "fcntl", None) + monkeypatch.delitem(sys.modules, name, raising=False) # restores a prior real import + module = importlib.import_module(name) + try: + yield module + finally: + # Drop the fcntl-less copy before monkeypatch restores the original entry. + if sys.modules.get(name) is module: + del sys.modules[name] + + +def test_maintenance_imports_and_runs_without_fcntl(tmp_path, maintenance_without_fcntl): + module = maintenance_without_fcntl + path = tmp_path / "state.db" + _maintenance_schema(path) + result = module.ensure_read_indexes(path, confirmed_drained=True) + assert set(result.values()) == {"created"} + with closing(sqlite3.connect(str(path))) as conn: + names = {row[1] for row in conn.execute("PRAGMA index_list(messages)")} + assert "idx_messages_session" in names + + +def test_maintenance_refuses_lock_file_without_a_lock_primitive(tmp_path, maintenance_without_fcntl, monkeypatch): + module = maintenance_without_fcntl + monkeypatch.setitem(sys.modules, "msvcrt", None) + path = tmp_path / "state.db" + _maintenance_schema(path) + with pytest.raises(RuntimeError, match="--lock-file"): + module.ensure_read_indexes(path, confirmed_drained=True, lock_file=tmp_path / "turns.lock") + with closing(sqlite3.connect(str(path))) as conn: + assert conn.execute("PRAGMA index_list(messages)").fetchall() == [] + + +def test_maintenance_cli_without_lock_file_works_without_fcntl(tmp_path, maintenance_without_fcntl, monkeypatch, capsys): + module = maintenance_without_fcntl + path = tmp_path / "state.db" + _maintenance_schema(path) + monkeypatch.setattr(sys, "argv", ["ensure_state_db_read_indexes.py", "--db", str(path), "--confirm-drained"]) + module.main() + out = capsys.readouterr().out + assert '"idx_messages_session": "created"' in out diff --git a/tests/test_state_db_readonly_readers.py b/tests/test_state_db_readonly_readers.py index 25dd8d20..c7d1c7a0 100644 --- a/tests/test_state_db_readonly_readers.py +++ b/tests/test_state_db_readonly_readers.py @@ -11,7 +11,6 @@ Contract under test: (``scripts/ensure_state_db_read_indexes.py``); * the cron sidebar read in ``api/routes.py`` uses the read-only opener. """ -import fcntl import sqlite3 from contextlib import closing @@ -94,6 +93,7 @@ def test_index_maintenance_without_lock_file_still_requires_drain(tmp_path): def test_index_maintenance_refuses_when_lock_is_held(tmp_path): + fcntl = pytest.importorskip("fcntl", reason="flock-held scenario needs a POSIX interpreter") from scripts.ensure_state_db_read_indexes import ensure_read_indexes path = tmp_path / "state.db" _maintenance_schema(path) From 70146b74d9555036dde7d9e691d5bf343d342f07 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Thu, 17 Sep 2026 22:26:45 +0000 Subject: [PATCH 100/446] fix(sqlite): initialise an empty --lock-file before the Windows byte lock On the msvcrt branch of scripts/ensure_state_db_read_indexes.py the tool locked byte 0 of --lock-file right after open(..., "a+"). When that open had just created the file (or the file was empty) the lock range lay beyond EOF, which Windows refuses, so the documented locked-maintenance workflow raised instead of acquiring the lock (greptile P1). Write one byte when the lock file is empty, then seek(0) and lock as before. Only the Windows branch changes; POSIX flock is untouched and an existing (deployment-owned) lock file is never rewritten. Tests (tests/test_pr7445_readonly_review_followups.py): a strict msvcrt stand-in that refuses to lock past EOF proves the lock is taken on byte 0 of a fresh empty file, that an existing lock file keeps its content, and that a held lock still propagates before the database is touched. --- docs/troubleshooting.md | 2 +- scripts/ensure_state_db_read_indexes.py | 7 ++ .../test_pr7445_readonly_review_followups.py | 91 ++++++++++++++++++- 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c6fcffd8..618efa9c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -232,7 +232,7 @@ python3 scripts/ensure_state_db_read_indexes.py --db ~/.hermes/state.db --confir ``` - `--confirm-drained` is mandatory: it is your assertion that no agent turn is running against the database. The tool does not verify it. -- `--lock-file PATH` (optional) additionally holds an exclusive non-blocking lock on `PATH` (`flock` on POSIX, `msvcrt.locking` on Windows) for deployments that serialise agent turns on a lock file; a held lock makes the tool exit without touching the database. Without `--lock-file` no lock primitive is required, so the script runs on native Windows as well. +- `--lock-file PATH` (optional) additionally holds an exclusive non-blocking lock on `PATH` (`flock` on POSIX, `msvcrt.locking` on Windows) for deployments that serialise agent turns on a lock file; a held lock makes the tool exit without touching the database. Without `--lock-file` no lock primitive is required, so the script runs on native Windows as well. On Windows the lock covers byte 0 of `PATH`; a new or empty lock file is initialised with one byte first (an existing lock file is never rewritten). - The tool opens the database `mode=rw` (never `rwc`): a mistyped path raises instead of creating an empty database. Indexes are created inside one `BEGIN IMMEDIATE` transaction and rolled back on any error. - It is idempotent and prints a JSON status per index (`created` / `existing`). An existing index with a different table, key shape or collation is reported as `Incompatible index` and never replaced; an index that is not covering (`EXPLAIN QUERY PLAN`) is reported as `Index is not covering`. - Windows UNC profiles (`HERMES_HOME=\\server\share\...`) are supported: readers and this tool build the empty-authority URI `file:////server/share/state.db` that the bundled SQLite accepts. diff --git a/scripts/ensure_state_db_read_indexes.py b/scripts/ensure_state_db_read_indexes.py index 2513b0fc..2eedc9a4 100644 --- a/scripts/ensure_state_db_read_indexes.py +++ b/scripts/ensure_state_db_read_indexes.py @@ -26,6 +26,7 @@ silently replaced. import argparse from contextlib import closing, nullcontext import json +import os from pathlib import Path import sqlite3 import sys @@ -64,6 +65,12 @@ def _exclusive_lock(lock_file): fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) else: # Lock one byte at offset 0 without blocking; raises OSError when held. + # ``a+`` may have just created an empty file, and Windows cannot lock + # a range beyond EOF, so make sure byte 0 exists first. An existing + # (deployment-owned) lock file is left untouched. + if os.fstat(handle.fileno()).st_size == 0: + handle.write("\n") + handle.flush() handle.seek(0) msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) except BaseException: diff --git a/tests/test_pr7445_readonly_review_followups.py b/tests/test_pr7445_readonly_review_followups.py index a2ecd444..f99c1548 100644 --- a/tests/test_pr7445_readonly_review_followups.py +++ b/tests/test_pr7445_readonly_review_followups.py @@ -15,9 +15,13 @@ Contract under test: query string is always the strict ``mode=ro``; * ``scripts/ensure_state_db_read_indexes.py`` imports and runs its no-lock path on a platform without ``fcntl`` (native Windows), and refuses ``--lock-file`` - there with a clear error instead of an ``ImportError`` at import time. + there with a clear error instead of an ``ImportError`` at import time; +* on the ``msvcrt`` branch the tool locks byte 0 of the lock file only after + making sure that byte exists, so a fresh (empty) ``--lock-file`` is acquired + without relying on the CRT's beyond-EOF locking behaviour. """ import importlib +import os import sqlite3 import sys from contextlib import closing @@ -260,3 +264,88 @@ def test_maintenance_cli_without_lock_file_works_without_fcntl(tmp_path, mainten module.main() out = capsys.readouterr().out assert '"idx_messages_session": "created"' in out + + +# -------------------------------------------------------------------------- +# 4. The Windows lock branch acquires a fresh (empty) lock file. +# -------------------------------------------------------------------------- + +class _StrictMsvcrt: + """Stand-in for ``msvcrt`` that, like Windows, refuses to lock past EOF. + + Records every ``locking`` call with the file size at the current offset so + the test can prove byte 0 existed when the lock was taken. + """ + + LK_NBLCK = 2 + LK_UNLCK = 0 + + def __init__(self): + self.calls = [] + + def locking(self, fd, mode, nbytes): + offset = os.lseek(fd, 0, os.SEEK_CUR) + size = os.fstat(fd).st_size + self.calls.append((mode, offset, nbytes, size)) + if mode == self.LK_NBLCK and offset + nbytes > size: + raise OSError(36, "Resource deadlock avoided: lock region beyond end of file") + + +def test_windows_lock_branch_acquires_a_fresh_empty_lock_file(tmp_path, maintenance_without_fcntl, monkeypatch): + module = maintenance_without_fcntl + assert module.fcntl is None # the fixture already routes the tool to the msvcrt branch + fake = _StrictMsvcrt() + monkeypatch.setattr(module, "msvcrt", fake) + + path = tmp_path / "state.db" + _maintenance_schema(path) + lock = tmp_path / "turns.lock" + assert not lock.exists() + + result = module.ensure_read_indexes(path, confirmed_drained=True, lock_file=lock) + + assert set(result.values()) == {"created"} + # Exactly one non-blocking lock on byte 0, taken while that byte existed. + assert fake.calls == [(fake.LK_NBLCK, 0, 1, 1)] + assert lock.stat().st_size == 1 + with closing(sqlite3.connect(str(path))) as conn: + names = {row[1] for row in conn.execute("PRAGMA index_list(messages)")} + assert "idx_messages_session" in names + + +def test_windows_lock_branch_keeps_an_existing_lock_file_intact(tmp_path, maintenance_without_fcntl, monkeypatch): + module = maintenance_without_fcntl + fake = _StrictMsvcrt() + monkeypatch.setattr(module, "msvcrt", fake) + + path = tmp_path / "state.db" + _maintenance_schema(path) + lock = tmp_path / "turns.lock" + lock.write_bytes(b"deployment-owned\n") + + module.ensure_read_indexes(path, confirmed_drained=True, lock_file=lock) + + assert fake.calls == [(fake.LK_NBLCK, 0, 1, len(b"deployment-owned\n"))] + assert lock.read_bytes() == b"deployment-owned\n" + + +def test_windows_lock_branch_reports_a_held_lock_without_touching_the_db(tmp_path, maintenance_without_fcntl, monkeypatch): + module = maintenance_without_fcntl + + class _HeldMsvcrt(_StrictMsvcrt): + def locking(self, fd, mode, nbytes): + super().locking(fd, mode, nbytes) + raise PermissionError(13, "Permission denied: lock held") + + fake = _HeldMsvcrt() + monkeypatch.setattr(module, "msvcrt", fake) + path = tmp_path / "state.db" + _maintenance_schema(path) + lock = tmp_path / "turns.lock" + + with pytest.raises(PermissionError): + module.ensure_read_indexes(path, confirmed_drained=True, lock_file=lock) + + assert fake.calls == [(fake.LK_NBLCK, 0, 1, 1)] + with closing(sqlite3.connect(str(path))) as conn: + assert conn.execute("PRAGMA index_list(messages)").fetchall() == [] From f04e3ca8b2fade9ecec90a96c69d6277ffda49b7 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Thu, 17 Sep 2026 22:59:02 +0000 Subject: [PATCH 101/446] fix(sqlite): write the Windows lock byte in binary; drop source-text asserts On native Windows the text-mode ``handle.write("\n")`` that seeds an empty ``--lock-file`` is translated to ``\r\n``, so a fresh lock file is two bytes and the msvcrt regression test fails on the platform it protects. Seed the file with ``os.write(fd, b"\0")`` on the descriptor instead: exactly one byte whatever the text-mode newline translation. The Windows-branch tests now run under a ``\n`` -> ``\r\n`` translating ``open`` so this failure reproduces on any host. Remove the two ``... not in src`` assertions added for the previous review from test_issue2628: the behaviours they hinted at (every missing-index projection uses the pre-aggregated ordering; listing never creates an index) are already asserted through query plans, result equivalence and ``PRAGMA index_list`` in test_pr7445_readonly_review_followups.py, test_state_db_readonly_readers.py and test_issue3887_index_prime.py. --- scripts/ensure_state_db_read_indexes.py | 6 +++-- tests/test_issue2628_cli_sessions_perf.py | 4 ---- .../test_pr7445_readonly_review_followups.py | 24 +++++++++++++++---- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/scripts/ensure_state_db_read_indexes.py b/scripts/ensure_state_db_read_indexes.py index 2eedc9a4..31dce342 100644 --- a/scripts/ensure_state_db_read_indexes.py +++ b/scripts/ensure_state_db_read_indexes.py @@ -67,9 +67,11 @@ def _exclusive_lock(lock_file): # Lock one byte at offset 0 without blocking; raises OSError when held. # ``a+`` may have just created an empty file, and Windows cannot lock # a range beyond EOF, so make sure byte 0 exists first. An existing - # (deployment-owned) lock file is left untouched. + # (deployment-owned) lock file is left untouched. Write the byte on + # the descriptor: a text-mode ``handle.write("\n")`` becomes two + # bytes (``\r\n``) on native Windows. if os.fstat(handle.fileno()).st_size == 0: - handle.write("\n") + os.write(handle.fileno(), b"\0") handle.flush() handle.seek(0) msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) diff --git a/tests/test_issue2628_cli_sessions_perf.py b/tests/test_issue2628_cli_sessions_perf.py index 556e5509..950a6028 100644 --- a/tests/test_issue2628_cli_sessions_perf.py +++ b/tests/test_issue2628_cli_sessions_perf.py @@ -209,12 +209,8 @@ def test_importable_agent_rows_push_sidebar_limit_into_sql(tmp_path): assert "JOIN candidates c ON c.id = s.id" in src assert "latest_messages AS" in src assert "LEFT JOIN latest_messages lm ON lm.session_id = s.id" in src - # Pre-aggregation covers every missing-index projection, not only cron (#7445 review). - assert 'included == ("cron",)' not in src assert "not messages_index_present" in src assert "PRAGMA index_list(messages)" in src - # Listing is a pure read: index creation lives in the drained maintenance tool. - assert "CREATE INDEX IF NOT EXISTS idx_messages_session" not in src assert "_CRON_PREAGGREGATE_CANDIDATE_ORDER_MIN_MESSAGES" not in src assert "MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id" in src assert "candidate_limit = max(result_limit * 8, result_limit)" in src diff --git a/tests/test_pr7445_readonly_review_followups.py b/tests/test_pr7445_readonly_review_followups.py index f99c1548..4239afaa 100644 --- a/tests/test_pr7445_readonly_review_followups.py +++ b/tests/test_pr7445_readonly_review_followups.py @@ -18,7 +18,9 @@ Contract under test: there with a clear error instead of an ``ImportError`` at import time; * on the ``msvcrt`` branch the tool locks byte 0 of the lock file only after making sure that byte exists, so a fresh (empty) ``--lock-file`` is acquired - without relying on the CRT's beyond-EOF locking behaviour. + without relying on the CRT's beyond-EOF locking behaviour; that byte is + written as exactly one byte even under the ``\\n`` → ``\\r\\n`` translation + native Windows applies to text-mode handles. """ import importlib import os @@ -291,7 +293,20 @@ class _StrictMsvcrt: raise OSError(36, "Resource deadlock avoided: lock region beyond end of file") -def test_windows_lock_branch_acquires_a_fresh_empty_lock_file(tmp_path, maintenance_without_fcntl, monkeypatch): +@pytest.fixture +def windows_text_mode(maintenance_without_fcntl, monkeypatch): + """Make the tool's text-mode ``open`` translate ``\\n`` to ``\\r\\n`` as on native Windows.""" + real_open = open + + def crlf_text_open(file, mode="r", *args, **kwargs): + if "b" not in mode: + kwargs.setdefault("newline", "\r\n") + return real_open(file, mode, *args, **kwargs) + + monkeypatch.setattr(maintenance_without_fcntl, "open", crlf_text_open, raising=False) + + +def test_windows_lock_branch_acquires_a_fresh_empty_lock_file(tmp_path, maintenance_without_fcntl, windows_text_mode, monkeypatch): module = maintenance_without_fcntl assert module.fcntl is None # the fixture already routes the tool to the msvcrt branch fake = _StrictMsvcrt() @@ -305,7 +320,8 @@ def test_windows_lock_branch_acquires_a_fresh_empty_lock_file(tmp_path, maintena result = module.ensure_read_indexes(path, confirmed_drained=True, lock_file=lock) assert set(result.values()) == {"created"} - # Exactly one non-blocking lock on byte 0, taken while that byte existed. + # Exactly one non-blocking lock on byte 0, taken while that byte existed + # and was the only one: no newline translation may inflate the file. assert fake.calls == [(fake.LK_NBLCK, 0, 1, 1)] assert lock.stat().st_size == 1 with closing(sqlite3.connect(str(path))) as conn: @@ -329,7 +345,7 @@ def test_windows_lock_branch_keeps_an_existing_lock_file_intact(tmp_path, mainte assert lock.read_bytes() == b"deployment-owned\n" -def test_windows_lock_branch_reports_a_held_lock_without_touching_the_db(tmp_path, maintenance_without_fcntl, monkeypatch): +def test_windows_lock_branch_reports_a_held_lock_without_touching_the_db(tmp_path, maintenance_without_fcntl, windows_text_mode, monkeypatch): module = maintenance_without_fcntl class _HeldMsvcrt(_StrictMsvcrt): From 9cb0235f5e6ad980a6204f527d2de52b9a5570cd Mon Sep 17 00:00:00 2001 From: webtecnica Date: Thu, 10 Sep 2026 15:23:28 -0300 Subject: [PATCH 102/446] fix(commands): keep disabled skills out of the slash-command picker `skills.disabled` entries are already excluded from the backend skill-command map, but the composer still offered them as selectable slash commands and as `/use` sub-args: both picker surfaces read `/api/skills` and ignored the `disabled` flag the endpoint reports per skill. Add one `_isSkillDisabled()` gate in static/commands.js and apply it at the two places that turn `/api/skills` rows into completions: * `_buildSkillCommandEntry()` returns null for a disabled skill, which keeps `_skillCommandCache` (and every consumer of it, including the dropdown) clean; * `_loadSlashSkillSubArgs()` skips disabled skills when building `/use` sub-args. Both caches still rebuild from `/api/skills`, so a skill toggled off in the skills panel disappears from the picker on the next load. Adds tests/test_issue7509_disabled_skills_slash_picker.py, which runs the real static/commands.js in a vm context with a mocked `api()` and asserts on `getSlashAutocompleteMatches()` for both surfaces, over 0 / 1 / many skills, enabled / disabled / flag-absent entries and a disable-then-refresh cycle. Fixes #7509 --- static/commands.js | 9 + ..._issue7509_disabled_skills_slash_picker.py | 184 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 tests/test_issue7509_disabled_skills_slash_picker.py diff --git a/static/commands.js b/static/commands.js index e322c1d8..f8513b53 100644 --- a/static/commands.js +++ b/static/commands.js @@ -351,6 +351,13 @@ async function _loadSlashPersonalitySubArgs(force=false){ return _slashPersonalityCachePromise; } +// Disabled skills are excluded from the backend skill-command map, so the slash +// picker surfaces must agree. Single gate for every place that turns /api/skills +// entries into selectable completions. +function _isSkillDisabled(skill){ + return !!(skill&&skill.disabled); +} + async function _loadSlashSkillSubArgs(force=false){ if(_slashSkillCache&&!force) return _slashSkillCache; if(_slashSkillCachePromise&&!force) return _slashSkillCachePromise; @@ -359,6 +366,7 @@ async function _loadSlashSkillSubArgs(force=false){ const data=await api('/api/skills'); const values=[]; for(const skill of (data&&data.skills)||[]){ + if(_isSkillDisabled(skill)) continue; const name=_normalizeSlashSubArg(skill&&skill.name); if(name) values.push(name); } @@ -2096,6 +2104,7 @@ function _getReservedSlashCommandSlugs(){ return reserved; } function _buildSkillCommandEntry(skill){ + if(_isSkillDisabled(skill))return null; const skillName=String(skill&&skill.name||'').trim(); const slug=_skillCommandSlug(skillName); if(!slug)return null; diff --git a/tests/test_issue7509_disabled_skills_slash_picker.py b/tests/test_issue7509_disabled_skills_slash_picker.py new file mode 100644 index 00000000..f5048e0c --- /dev/null +++ b/tests/test_issue7509_disabled_skills_slash_picker.py @@ -0,0 +1,184 @@ +"""Issue #7509: disabled skills must not be offered by the slash-command picker. + +``skills.disabled`` entries are already excluded from the backend skill-command +map (``scan_skill_commands`` in the hermes-agent runtime), so the WebUI picker is +the surface that leaks them. This suite runs the real ``static/commands.js`` +inside a ``vm`` context with a mocked ``api()`` and asserts on +``getSlashAutocompleteMatches()`` -- the entry point the composer calls -- for +both surfaces reported in the issue: + +* skill suggestions (``/partial``) +* ``/use`` sub-args (``/use partial``) + +State space covered: 0 / 1 / many skills, enabled / disabled / flag-absent +entries, both surfaces, and a cache-refresh cycle after a skill is disabled. +""" + +from __future__ import annotations + +import json +import subprocess +import tempfile +import textwrap +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +COMMANDS_JS = (ROOT / "static" / "commands.js").read_text(encoding="utf-8") + +# `window` is intentionally absent, so commands.js keeps its module scope local. +_PRELUDE = """ +const skillsOffered = (matches) => matches.filter((m) => m && m.source === 'skill').map((m) => m.name).sort(); +const subArgsOffered = (matches) => matches.map((m) => String(m && m.value || '')).sort(); +const loadPicker = async () => { + await loadSkillCommands(true); + await loadBundleCommands(true); +}; +""" + + +def _run_commands_js(script_body: str, skills: list) -> dict: + """Run `script_body` against static/commands.js with /api/skills mocked.""" + script = textwrap.dedent( + f""" + const vm = require('vm'); + let skillsPayload = {json.dumps(skills)}; + const ctx = {{ + console, + localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }}, + t: (key) => key, + api: async (path) => {{ + if (path === '/api/skills') return {{ skills: skillsPayload }}; + if (path === '/api/commands') return {{ commands: [] }}; + if (path === '/api/commands/bundles') return {{ bundles: [] }}; + throw new Error('unexpected api path: ' + path); + }}, + // Test-only knob, same idiom as the other commands.js harnesses: lets the + // in-context script swap the mocked /api/skills payload mid-run. + __setSkills: (next) => {{ skillsPayload = next; }} + }}; + vm.createContext(ctx); + vm.runInContext({json.dumps(COMMANDS_JS)}, ctx); + (async () => {{ + const result = await vm.runInContext(`(async () => {{ {_PRELUDE} {script_body} }})()`, ctx); + process.stdout.write(JSON.stringify(result)); + }})().catch(err => {{ + console.error(err && err.stack || err); + process.exit(1); + }}); + """ + ) + with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as handle: + handle.write(script) + script_path = Path(handle.name) + try: + proc = subprocess.run(["node", str(script_path)], capture_output=True, text=True) + finally: + script_path.unlink(missing_ok=True) + if proc.returncode != 0: + raise RuntimeError(f"node harness failed (exit {proc.returncode}):\n{proc.stderr}") + return json.loads(proc.stdout) + + +MIXED_SKILLS = [ + {"name": "gamma-live", "description": "Enabled skill", "disabled": False}, + {"name": "beta-gone", "description": "Disabled skill", "disabled": True}, + {"name": "delta-gone", "description": "Disabled skill", "disabled": True}, + {"name": "alpha-legacy", "description": "Entry without a disabled flag"}, +] + +ALL_DISABLED_SKILLS = [ + {"name": "solo-one", "description": "Disabled skill", "disabled": True}, + {"name": "solo-two", "description": "Disabled skill", "disabled": True}, +] + + +def test_disabled_skills_are_not_offered_as_skill_commands(): + result = _run_commands_js( + """ + await loadPicker(); + return { + enabled: skillsOffered(await getSlashAutocompleteMatches('/gam')), + disabled_beta: skillsOffered(await getSlashAutocompleteMatches('/bet')), + disabled_delta: skillsOffered(await getSlashAutocompleteMatches('/del')), + flag_absent: skillsOffered(await getSlashAutocompleteMatches('/alp')), + }; + """, + MIXED_SKILLS, + ) + + assert result["enabled"] == ["gamma-live"], result + assert result["disabled_beta"] == [], result + assert result["disabled_delta"] == [], result + assert result["flag_absent"] == ["alpha-legacy"], result + + +def test_disabled_skills_are_not_offered_as_use_sub_args(): + result = _run_commands_js( + """ + await loadPicker(); + return { + all: subArgsOffered(await getSlashAutocompleteMatches('/use ')), + disabled_prefix: subArgsOffered(await getSlashAutocompleteMatches('/use bet')), + enabled_prefix: subArgsOffered(await getSlashAutocompleteMatches('/use gam')), + }; + """, + MIXED_SKILLS, + ) + + assert result["all"] == ["alpha-legacy", "gamma-live"], result + assert result["disabled_prefix"] == [], result + assert result["enabled_prefix"] == ["gamma-live"], result + + +def test_picker_stays_empty_when_every_skill_is_disabled(): + result = _run_commands_js( + """ + await loadPicker(); + return { + commands: skillsOffered(await getSlashAutocompleteMatches('/so')), + sub_args: subArgsOffered(await getSlashAutocompleteMatches('/use ')), + }; + """, + ALL_DISABLED_SKILLS, + ) + + assert result == {"commands": [], "sub_args": []}, result + + +def test_picker_handles_an_empty_skill_list(): + result = _run_commands_js( + """ + await loadPicker(); + return { + commands: skillsOffered(await getSlashAutocompleteMatches('/an')), + sub_args: subArgsOffered(await getSlashAutocompleteMatches('/use ')), + }; + """, + [], + ) + + assert result == {"commands": [], "sub_args": []}, result + + +def test_newly_disabled_skill_disappears_after_cache_refresh(): + result = _run_commands_js( + """ + await loadPicker(); + const before = { + commands: skillsOffered(await getSlashAutocompleteMatches('/ze')), + sub_args: subArgsOffered(await getSlashAutocompleteMatches('/use ')), + }; + __setSkills([{ name: 'zeta-live', description: 'Disabled later', disabled: true }]); + invalidateSlashSkillCaches(); + await loadPicker(); + const after = { + commands: skillsOffered(await getSlashAutocompleteMatches('/ze')), + sub_args: subArgsOffered(await getSlashAutocompleteMatches('/use ')), + }; + return { before, after }; + """, + [{"name": "zeta-live", "description": "Enabled skill"}], + ) + + assert result["before"] == {"commands": ["zeta-live"], "sub_args": ["zeta-live"]}, result + assert result["after"] == {"commands": [], "sub_args": []}, result From 65e968c447623c60391159c290e2721ce630a762 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Fri, 11 Sep 2026 17:24:11 -0300 Subject: [PATCH 103/446] fix(commands): drop slash-skill caches on profile switch The slash-skill caches (_slashSkillCache, _slashSkillCachePromise, _skillCommandCache) outlived a profile switch, so the outgoing profile's /api/skills payload kept hiding skills that the incoming profile enables (#7509). - call invalidateSlashSkillCaches() from both switch handlers (static/panels.js switchToProfile, static/sessions.js _switchProfileForSessionLoad), after the /api/profile/switch request - bump a generation counter in invalidateSlashSkillCaches() and capture it in both loaders: a reply issued for the outgoing profile can no longer commit once the switch has landed - tests: in-flight race and cache-refresh regression on both picker surfaces, plus a check that both switch paths call the invalidation --- static/commands.js | 31 +++- static/panels.js | 9 ++ static/sessions.js | 4 + ..._issue7509_disabled_skills_slash_picker.py | 141 +++++++++++++++++- 4 files changed, 179 insertions(+), 6 deletions(-) diff --git a/static/commands.js b/static/commands.js index f8513b53..c857e896 100644 --- a/static/commands.js +++ b/static/commands.js @@ -250,8 +250,15 @@ let _slashPersonalityCachePromise=null; let _bundleCommandCache=[]; let _bundleCommandLoadPromise=null; let _bundleCommandCacheReady=false; +// Bumped by invalidateSlashSkillCaches(). The two skill-cache loaders below capture +// it when they issue /api/skills and refuse to commit when it moved while their +// response was in flight: a reply generated for the previous profile would otherwise +// land after a profile switch and repopulate the caches with that profile's +// disabled-filtered payload, keeping a skill that is enabled in the new profile +// hidden (#7509). let _slashSkillCache=null; let _slashSkillCachePromise=null; +let _slashSkillCacheGen=0; let _agentCommandCache=null; let _agentCommandCachePromise=null; @@ -361,6 +368,7 @@ function _isSkillDisabled(skill){ async function _loadSlashSkillSubArgs(force=false){ if(_slashSkillCache&&!force) return _slashSkillCache; if(_slashSkillCachePromise&&!force) return _slashSkillCachePromise; + const gen=_slashSkillCacheGen; _slashSkillCachePromise=(async()=>{ try{ const data=await api('/api/skills'); @@ -371,19 +379,26 @@ async function _loadSlashSkillSubArgs(force=false){ if(name) values.push(name); } const deduped=Array.from(new Set(values)).sort((a,b)=>a.localeCompare(b)); + // A profile switch during the request bumped the generation: this payload + // belongs to the previous profile, so leave the cache empty for the fresh + // read instead of publishing stale names (#7509). + if(gen!==_slashSkillCacheGen) return _slashSkillCache||[]; _slashSkillCache=deduped; return deduped; }catch(_){ - _slashSkillCache=null; + if(gen===_slashSkillCacheGen) _slashSkillCache=null; return []; }finally{ - _slashSkillCachePromise=null; + if(gen===_slashSkillCacheGen) _slashSkillCachePromise=null; } })(); return _slashSkillCachePromise; } function invalidateSlashSkillCaches(){ + // Bump before dropping the caches: an /api/skills response still in flight was + // generated for the previous profile and must not commit once it lands (#7509). + _slashSkillCacheGen++; _slashSkillCache=null; _slashSkillCachePromise=null; _skillCommandCache=[]; @@ -2126,14 +2141,22 @@ function _buildBundleCommandEntry(bundle){ async function loadSkillCommands(force=false){ if(_skillCommandCacheReady&&!force)return _skillCommandCache; if(_skillCommandLoadPromise&&!force)return _skillCommandLoadPromise; + const gen=_slashSkillCacheGen; _skillCommandLoadPromise=(async()=>{ try{ const data=await api('/api/skills'); const deduped=new Map(); for(const skill of (data&&data.skills)||[]){const entry=_buildSkillCommandEntry(skill);if(entry&&!deduped.has(entry.name))deduped.set(entry.name,entry);} + // Bumped by a profile switch while we were awaiting: keep the cache empty + // (and not "ready") so the composer's next pass loads the new profile (#7509). + if(gen!==_slashSkillCacheGen) return _skillCommandCache; _skillCommandCache=Array.from(deduped.values()).sort((a,b)=>a.name.localeCompare(b.name)); - }catch(_){_skillCommandCache=[];} - finally{_skillCommandCacheReady=true;_skillCommandLoadPromise=null;} + }catch(_){ + if(gen===_slashSkillCacheGen)_skillCommandCache=[]; + } + finally{ + if(gen===_slashSkillCacheGen){_skillCommandCacheReady=true;_skillCommandLoadPromise=null;} + } return _skillCommandCache; })(); return _skillCommandLoadPromise; diff --git a/static/panels.js b/static/panels.js index b53a89c6..248a7853 100644 --- a/static/panels.js +++ b/static/panels.js @@ -7070,6 +7070,15 @@ async function switchToProfile(name) { if (typeof _resetCronUnreadForProfileSwitch === 'function') { _resetCronUnreadForProfileSwitch(); } + // #7509: the slash-skill caches hold the previous profile's disabled-filtered + // /api/skills payload, so drop them once the switch has actually succeeded — + // otherwise a skill that is enabled in the new profile stays hidden behind the + // old payload. Invalidating here (rather than before the POST) also kills any + // response still in flight, so the previous profile's reply can't repopulate + // the caches after this point (see invalidateSlashSkillCaches in commands.js). + if (typeof window !== 'undefined' && typeof window.invalidateSlashSkillCaches === 'function') { + window.invalidateSlashSkillCaches(); + } const targetActiveProfile = S.activeProfile || 'default'; let sessionProfileMatchesTarget = true; if (!sessionInProgress && S.session) { diff --git a/static/sessions.js b/static/sessions.js index 7be28623..c6a302a4 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1660,6 +1660,10 @@ async function _switchProfileForSessionLoad(profile){ if(typeof _resetCronUnreadForProfileSwitch==='function'){ _resetCronUnreadForProfileSwitch(); } + // #7509: mirror the canonical switch in panels.js — the slash-skill caches still + // hold the previous profile's /api/skills payload, so drop them (and any reply + // still in flight) once the switch has succeeded. + if(typeof window!=='undefined'&&typeof window.invalidateSlashSkillCaches==='function') window.invalidateSlashSkillCaches(); if(typeof _clearPersistedModelState==='function') _clearPersistedModelState(); else localStorage.removeItem('hermes-webui-model'); if(data.default_model) window._defaultModel=data.default_model; diff --git a/tests/test_issue7509_disabled_skills_slash_picker.py b/tests/test_issue7509_disabled_skills_slash_picker.py index f5048e0c..99f033f0 100644 --- a/tests/test_issue7509_disabled_skills_slash_picker.py +++ b/tests/test_issue7509_disabled_skills_slash_picker.py @@ -12,11 +12,17 @@ both surfaces reported in the issue: State space covered: 0 / 1 / many skills, enabled / disabled / flag-absent entries, both surfaces, and a cache-refresh cycle after a skill is disabled. + +Profile-switch coverage (#7509 follow-up): a skill that is disabled in the outgoing +profile and enabled in the incoming one must become visible on the next picker pass, +including when the outgoing profile's ``/api/skills`` reply is still in flight while +the switch lands -- that stale reply must not repopulate the caches. """ from __future__ import annotations import json +import re import subprocess import tempfile import textwrap @@ -42,19 +48,36 @@ def _run_commands_js(script_body: str, skills: list) -> dict: f""" const vm = require('vm'); let skillsPayload = {json.dumps(skills)}; + // In-flight control: __holdSkills(true) parks every /api/skills reply until + // __releaseHeldSkills(), so a test can land a profile switch mid-request. + let skillRequestCount = 0; + let holdSkills = false; + const heldSkills = []; const ctx = {{ console, localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }}, t: (key) => key, api: async (path) => {{ - if (path === '/api/skills') return {{ skills: skillsPayload }}; + if (path === '/api/skills') {{ + skillRequestCount++; + // Snapshot at request time: a reply produced for the outgoing profile + // must not be able to read a payload that only exists after the switch. + const snapshot = skillsPayload; + if (holdSkills) {{ + await new Promise((resolve) => {{ heldSkills.push(resolve); }}); + }} + return {{ skills: snapshot }}; + }} if (path === '/api/commands') return {{ commands: [] }}; if (path === '/api/commands/bundles') return {{ bundles: [] }}; throw new Error('unexpected api path: ' + path); }}, // Test-only knob, same idiom as the other commands.js harnesses: lets the // in-context script swap the mocked /api/skills payload mid-run. - __setSkills: (next) => {{ skillsPayload = next; }} + __setSkills: (next) => {{ skillsPayload = next; }}, + __holdSkills: (on) => {{ holdSkills = !!on; }}, + __releaseHeldSkills: () => {{ heldSkills.splice(0).forEach((resolve) => resolve()); }}, + __skillRequests: () => skillRequestCount }}; vm.createContext(ctx); vm.runInContext({json.dumps(COMMANDS_JS)}, ctx); @@ -182,3 +205,117 @@ def test_newly_disabled_skill_disappears_after_cache_refresh(): assert result["before"] == {"commands": ["zeta-live"], "sub_args": ["zeta-live"]}, result assert result["after"] == {"commands": [], "sub_args": []}, result + +# A payload shaped like the one the outgoing profile served: the skill the incoming +# profile enables is present here, flagged disabled, so a stale cache entry (or a +# stale in-flight reply) keeps it hidden. +_DISABLED_SHARED_SKILL = [ + {"name": "shared-skill", "description": "Disabled in the outgoing profile", "disabled": True}, +] +_ENABLED_SHARED_SKILL = [ + {"name": "shared-skill", "description": "Enabled in the incoming profile"}, +] + + +def test_skill_enabled_in_new_profile_appears_in_both_surfaces_after_switch(): + result = _run_commands_js( + """ + await loadPicker(); + const before = { + commands: skillsOffered(await getSlashAutocompleteMatches('/sh')), + sub_args: subArgsOffered(await getSlashAutocompleteMatches('/use ')), + }; + // The profile switch drops the caches (invalidateSlashSkillCaches) and + // nothing forces a reload afterwards, so the picker's own non-forced load + // is what has to serve the incoming profile's payload. + __setSkills([{ name: 'shared-skill', description: 'Enabled in the incoming profile' }]); + invalidateSlashSkillCaches(); + await loadSkillCommands(); + const after = { + commands: skillsOffered(await getSlashAutocompleteMatches('/sh')), + sub_args: subArgsOffered(await getSlashAutocompleteMatches('/use ')), + }; + return { before, after }; + """, + _DISABLED_SHARED_SKILL, + ) + + assert result["before"] == {"commands": [], "sub_args": []}, result + assert result["after"] == {"commands": ["shared-skill"], "sub_args": ["shared-skill"]}, result + + +def test_inflight_skills_reply_cannot_repopulate_caches_after_switch(): + result = _run_commands_js( + """ + await loadPicker(); + const before = { + commands: skillsOffered(await getSlashAutocompleteMatches('/sh')), + sub_args: subArgsOffered(await getSlashAutocompleteMatches('/use ')), + }; + // The caches are empty (as they are after the picker's own refresh), so the + // next picker pass issues one /api/skills request per surface for the + // outgoing profile. Park both of them, then land the switch. + invalidateSlashSkillCaches(); + __holdSkills(true); + const inflightCommands = loadSkillCommands(); + const inflightSubArgs = getSlashAutocompleteMatches('/use '); + __setSkills([{ name: 'shared-skill', description: 'Enabled in the incoming profile' }]); + invalidateSlashSkillCaches(); + __holdSkills(false); + __releaseHeldSkills(); + await inflightCommands; + await inflightSubArgs; + await loadSkillCommands(); + const after = { + commands: skillsOffered(await getSlashAutocompleteMatches('/sh')), + sub_args: subArgsOffered(await getSlashAutocompleteMatches('/use ')), + requests: __skillRequests(), + }; + return { before, after }; + """, + _DISABLED_SHARED_SKILL, + ) + + assert result["before"] == {"commands": [], "sub_args": []}, result + assert result["after"]["commands"] == ["shared-skill"], result + assert result["after"]["sub_args"] == ["shared-skill"], result + # 1 from loadPicker(), 1 held reply that must be discarded, 1 fresh fetch: a + # cache that simply stayed empty would be wrong, the picker has to reload. + assert result["after"]["requests"] >= 3, result + + +PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _top_level_function_body(source: str, name: str) -> str: + """Return the source of a column-0 `function name(...)` declaration.""" + match = re.search(r"^(?:async\s+)?function\s+" + re.escape(name) + r"\s*\(", source, re.MULTILINE) + assert match, f"{name} not found" + end = source.find("\n}\n", match.start()) + assert end != -1, f"{name}: closing brace not found" + return source[match.start():end + 3] + + +def test_both_profile_switch_paths_drop_the_slash_skill_caches(): + """The switch handlers must call the invalidation once the POST has succeeded. + + The handlers cannot be executed in isolation here (they touch most of the app + shell), so this is a structural check: the invalidation call has to live inside + the switch function, after the /api/profile/switch request -- dropping the + caches before the request would let a reply from the outgoing profile commit + once the switch is done. + """ + for label, source, function_name in ( + ("panels.switchToProfile", PANELS_JS, "switchToProfile"), + ("sessions._switchProfileForSessionLoad", SESSIONS_JS, "_switchProfileForSessionLoad"), + ): + body = _top_level_function_body(source, function_name) + switch_call = body.find("/api/profile/switch") + invalidate_call = body.find("invalidateSlashSkillCaches()") + assert switch_call != -1, f"{label}: no /api/profile/switch call in {function_name}" + assert invalidate_call != -1, ( + f"{label}: {function_name} does not drop the slash-skill caches, so the " + "previous profile's /api/skills payload keeps hiding the new profile's skills" + ) + assert invalidate_call > switch_call, f"{label}: caches dropped before the switch request" From a89808886f67c210b89b9d3a2580c695e30b03fd Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 18 Sep 2026 02:39:35 +0000 Subject: [PATCH 104/446] fix(commands): do not latch an empty skill-command cache after a failed load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadSkillCommands() published readiness in a finally block that ran even when /api/skills rejected, so one transient failure left _skillCommandCacheReady=true with an empty cache. Because ensureSkillCommandsLoadedForAutocomplete() only re-loads while !_skillCommandCacheReady && !_skillCommandLoadPromise, no later picker pass ever retried: skill commands stayed missing until a page reload even after the API recovered. Readiness is now set only after a successful current-generation commit; the promise is cleared either way so the next pass can retry. The stale-generation fence is unchanged (verified a reply from the outgoing profile still refuses to commit). This wedge is PRE-EXISTING on master (its finally sets ready unconditionally), not introduced by this PR — fixed here because this change already reworks the surrounding generation handling. Adds a reject-once/recover regression plus a __failSkillsOnce() harness knob; reverting the fix turns it red. Co-authored-by: webtecnica --- static/commands.js | 12 ++++- ..._issue7509_disabled_skills_slash_picker.py | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/static/commands.js b/static/commands.js index c857e896..d431a37c 100644 --- a/static/commands.js +++ b/static/commands.js @@ -2142,6 +2142,7 @@ async function loadSkillCommands(force=false){ if(_skillCommandCacheReady&&!force)return _skillCommandCache; if(_skillCommandLoadPromise&&!force)return _skillCommandLoadPromise; const gen=_slashSkillCacheGen; + let _committed=false; _skillCommandLoadPromise=(async()=>{ try{ const data=await api('/api/skills'); @@ -2151,11 +2152,20 @@ async function loadSkillCommands(force=false){ // (and not "ready") so the composer's next pass loads the new profile (#7509). if(gen!==_slashSkillCacheGen) return _skillCommandCache; _skillCommandCache=Array.from(deduped.values()).sort((a,b)=>a.name.localeCompare(b.name)); + _committed=true; }catch(_){ if(gen===_slashSkillCacheGen)_skillCommandCache=[]; } finally{ - if(gen===_slashSkillCacheGen){_skillCommandCacheReady=true;_skillCommandLoadPromise=null;} + // Only publish the cache as "ready" after a SUCCESSFUL current-generation + // commit. Marking it ready in the failure path (what master does + // unconditionally) permanently wedges the picker: a single transient + // /api/skills rejection leaves ready=true + cache=[], and + // ensureSkillCommandsLoadedForAutocomplete() only retries when + // !ready && !promise — so skill commands stay missing until a page + // reload even after the API recovers. Clear the promise either way so the + // next picker pass can retry. + if(gen===_slashSkillCacheGen){_skillCommandCacheReady=_committed;_skillCommandLoadPromise=null;} } return _skillCommandCache; })(); diff --git a/tests/test_issue7509_disabled_skills_slash_picker.py b/tests/test_issue7509_disabled_skills_slash_picker.py index 99f033f0..a101789e 100644 --- a/tests/test_issue7509_disabled_skills_slash_picker.py +++ b/tests/test_issue7509_disabled_skills_slash_picker.py @@ -52,6 +52,7 @@ def _run_commands_js(script_body: str, skills: list) -> dict: // __releaseHeldSkills(), so a test can land a profile switch mid-request. let skillRequestCount = 0; let holdSkills = false; + let failSkillsOnce = false; const heldSkills = []; const ctx = {{ console, @@ -66,6 +67,10 @@ def _run_commands_js(script_body: str, skills: list) -> dict: if (holdSkills) {{ await new Promise((resolve) => {{ heldSkills.push(resolve); }}); }} + // Transient-failure injection: __failSkillsOnce() makes exactly the + // next /api/skills reject, so a test can prove the loader recovers + // instead of latching an empty cache as "ready". + if (failSkillsOnce) {{ failSkillsOnce = false; throw new Error('simulated /api/skills failure'); }} return {{ skills: snapshot }}; }} if (path === '/api/commands') return {{ commands: [] }}; @@ -77,6 +82,7 @@ def _run_commands_js(script_body: str, skills: list) -> dict: __setSkills: (next) => {{ skillsPayload = next; }}, __holdSkills: (on) => {{ holdSkills = !!on; }}, __releaseHeldSkills: () => {{ heldSkills.splice(0).forEach((resolve) => resolve()); }}, + __failSkillsOnce: () => {{ failSkillsOnce = true; }}, __skillRequests: () => skillRequestCount }}; vm.createContext(ctx); @@ -319,3 +325,47 @@ def test_both_profile_switch_paths_drop_the_slash_skill_caches(): "previous profile's /api/skills payload keeps hiding the new profile's skills" ) assert invalidate_call > switch_call, f"{label}: caches dropped before the switch request" + + +def test_transient_skills_failure_does_not_wedge_the_picker(): + """A failed /api/skills must not latch an empty cache as authoritative. + + ``loadSkillCommands()`` publishes its result by setting + ``_skillCommandCacheReady``, and ``ensureSkillCommandsLoadedForAutocomplete()`` + only re-loads while ``!_skillCommandCacheReady && !_skillCommandLoadPromise``. + So marking the cache "ready" on the failure path permanently wedges the + picker: one transient rejection leaves ready=true with an empty cache, and no + later picker pass ever retries -- skill commands stay missing until a page + reload even after the API recovers. + + Readiness must therefore be set only after a successful current-generation + commit. This regression pins the recovery; reverting the fix (marking ready + unconditionally in ``finally``) turns the second assertion red. + """ + result = _run_commands_js( + """ + // Bundle commands are a separate cache that getSlashAutocompleteMatches() + // also awaits; load it up front so this test isolates the skill path. + await loadBundleCommands(true); + __failSkillsOnce(); + // First pass rejects. Model the composer's own entry point: it only + // re-loads while the cache is neither ready nor in flight. + await loadSkillCommands(); + const afterFailure = skillsOffered(await getSlashAutocompleteMatches('/gam')); + // API is healthy again. A NON-forced load is what the picker actually + // issues, so the retry has to happen without force=true. + await loadSkillCommands(); + const afterRecovery = skillsOffered(await getSlashAutocompleteMatches('/gam')); + return {afterFailure, afterRecovery, requests: __skillRequests()}; + """, + MIXED_SKILLS, + ) + assert result["afterFailure"] == [], ( + "a rejected /api/skills should leave no skill commands offered" + ) + assert result["afterRecovery"] == ["gamma-live"], ( + "the picker must recover once /api/skills succeeds again; got " + f"{result['afterRecovery']!r} -- an empty list means the failure path " + "latched _skillCommandCacheReady and no retry ever happens" + ) + assert result["requests"] >= 2, "the loader must actually re-issue /api/skills" From 262236147eb7c99d2f3e52797c93147e0bf3b74b Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 18 Sep 2026 03:00:14 +0000 Subject: [PATCH 105/446] changelog: /skills write-approval fall-through (#7623) + disabled-skill picker filter (#7511) --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c61bece8..671f4e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +### Fixed + +- **`/skills pending` and the other write-approval subcommands reach the agent again.** The WebUI's local `/skills` handler swallowed every argument into its own skill-name search, so the agent-owned write-approval subcommands never reached their handler — `/skills pending`, `/skills approve 3`, `/skills diff` and friends silently ran a name search instead of doing anything. Those subcommands now fall through to the normal send path using the existing opt-out contract that `/reasoning` already uses. Covers every alias the agent handler accepts, including `apply`, `deny` and `drop`, which a first pass missed. Thanks @totalitarian. (#7623) +- **Disabled skills are no longer offered by the slash-command picker.** Skills in `skills.disabled` are already excluded from the backend skill-command map, but the WebUI listed every `/api/skills` entry regardless — so a disabled skill stayed selectable in both `/use ` sub-args and the `/`-prefix skill suggestions. Both surfaces now share one filter. Switching profiles is also fenced: an `/api/skills` reply still in flight when the switch lands can no longer commit, so a payload generated for the outgoing profile cannot keep hiding a skill that is enabled in the new one. Separately, a transient `/api/skills` failure no longer wedges the picker — readiness was previously published even when the request rejected, which left an empty cache marked authoritative and stopped the composer from ever retrying until a page reload. Thanks @webtecnica. (#7511, #7509) + ### Documentation - **`AGENTS.md` now routes contributors to the references that match their change.** The old "read first" list asked for four files up front regardless of what was being changed, and carried a compressed copy of the ten change guidelines that `docs/GUIDELINES.md` owns. It now maps each reference to the kind of work it applies to and states explicit completion/verification criteria instead. No information is lost — the ten rules remain in `docs/GUIDELINES.md`, which the new version still points to. Thanks @steveafrost. (#7593) From d24d6377f906f5e7621237fe60d2b591c3b0564f Mon Sep 17 00:00:00 2001 From: happy5318 Date: Fri, 18 Sep 2026 11:24:51 +0800 Subject: [PATCH 106/446] 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 107/446] 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 108/446] 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 46e60616f62ca3b9db839288ec113510adefc965 Mon Sep 17 00:00:00 2001 From: carlotestor Date: Fri, 18 Sep 2026 09:13:09 +0000 Subject: [PATCH 109/446] fix(projects): tag scan-minted system projects to the scanned profile The all-profiles sidebar scan walks every profile's state.db in one request. When it lazily minted a "Webhooks" / "Cron Jobs" project for a profile that had webhook/cron rows, ensure_webhook_project() and ensure_cron_project() tagged the new entry with get_active_profile_name() -- the UI-selected profile -- instead of the profile whose rows triggered it. A webhook-only profile therefore leaked a "Webhooks" project onto whichever profile was selected; deleting it did nothing because the next sidebar poll re-minted it, and switching profiles minted one per profile. ensure_webhook_project / ensure_cron_project / _profile_has_user_projects now take an explicit profile (default: active profile), and the scan passes _cli_profile through, so the opt-in gate and the tag both key on the scanned profile. Existing stubs in tests updated to accept the kwargs. --- api/models.py | 24 ++- ...st_cross_profile_system_project_tagging.py | 178 ++++++++++++++++++ .../test_issue3930_source_filter_pushdown.py | 4 +- .../test_issue4385_cron_archive_reappears.py | 2 +- tests/test_webhook_project_sessions.py | 8 +- 5 files changed, 200 insertions(+), 16 deletions(-) create mode 100644 tests/test_cross_profile_system_project_tagging.py diff --git a/api/models.py b/api/models.py index eca27ab5..8f4dc0da 100644 --- a/api/models.py +++ b/api/models.py @@ -6712,7 +6712,7 @@ CRON_PROJECT_NAME = 'Cron Jobs' _CRON_PROJECT_LOCK = threading.Lock() -def ensure_cron_project(create: bool = True) -> str | None: +def ensure_cron_project(create: bool = True, profile: str | None = None) -> str | None: """Return the project_id of the system "Cron Jobs" project for the active profile. Each profile gets its own "Cron Jobs" project so cron-spawned sessions in @@ -6734,7 +6734,10 @@ def ensure_cron_project(create: bool = True) -> str | None: """ from api.profiles import get_active_profile_name, _is_root_profile - active = get_active_profile_name() or 'default' + # `profile` is the owner of the state.db being projected; the active + # profile is only a fallback so a cross-profile scan never tags another + # profile's system project onto the one currently selected. + active = profile or get_active_profile_name() or 'default' with _CRON_PROJECT_LOCK: projects = load_projects() # Look for an existing per-profile cron project. Match either an exact @@ -6774,11 +6777,12 @@ WEBHOOK_PROJECT_NAME = 'Webhooks' _WEBHOOK_PROJECT_LOCK = threading.Lock() -def ensure_webhook_project() -> str: - """Return the project_id of the system "Webhooks" project for the active profile.""" +def ensure_webhook_project(profile: str | None = None) -> str: + """Return the project_id of the system "Webhooks" project for `profile` (default: active).""" from api.profiles import get_active_profile_name, _is_root_profile - active = get_active_profile_name() or 'default' + # Owner of the scanned state.db wins over the selected profile (see ensure_cron_project). + active = profile or get_active_profile_name() or 'default' with _WEBHOOK_PROJECT_LOCK: projects = load_projects() for p in projects: @@ -6806,7 +6810,7 @@ def ensure_webhook_project() -> str: return project_id -def _profile_has_user_projects() -> bool: +def _profile_has_user_projects(profile: str | None = None) -> bool: """True if the active profile already has at least one real (non-system) project. "Opted into project organization" means `load_projects()` contains a @@ -6819,7 +6823,7 @@ def _profile_has_user_projects() -> bool: """ from api.profiles import get_active_profile_name, _is_root_profile - active = get_active_profile_name() or 'default' + active = profile or get_active_profile_name() or 'default' reserved = {CRON_PROJECT_NAME, WEBHOOK_PROJECT_NAME} for p in load_projects(): if p.get('name') in reserved: @@ -7746,7 +7750,9 @@ def _load_cli_sessions_uncached( def _cron_pid(): if not _cron_pid_cache[0]: _cron_pid_cache[0] = True - _cron_pid_cache[1] = ensure_cron_project(create=_profile_has_user_projects()) + _cron_pid_cache[1] = ensure_cron_project( + create=_profile_has_user_projects(_cli_profile), profile=_cli_profile, + ) return _cron_pid_cache[1] # Memoize the cron jobs.json job_id -> name map for this scan. The two row @@ -7797,7 +7803,7 @@ def _load_cli_sessions_uncached( _webhook_pid_cache: list[str | None] = [None] def _webhook_pid(): if _webhook_pid_cache[0] is None: - _webhook_pid_cache[0] = ensure_webhook_project() + _webhook_pid_cache[0] = ensure_webhook_project(profile=_cli_profile) return _webhook_pid_cache[0] def _state_row_project_id(sid: str, source: str | None) -> str | None: diff --git a/tests/test_cross_profile_system_project_tagging.py b/tests/test_cross_profile_system_project_tagging.py new file mode 100644 index 00000000..8f1bdd9e --- /dev/null +++ b/tests/test_cross_profile_system_project_tagging.py @@ -0,0 +1,178 @@ +"""System projects minted by the all-profiles sidebar scan must belong to the +profile whose state.db produced the rows, not the profile currently selected. + +`get_cli_sessions(all_profiles=True)` walks every profile's state.db in one +request. When it reached a profile with webhook (or cron) sessions, the lazily +created "Webhooks" / "Cron Jobs" system project used to be tagged with +`get_active_profile_name()` — the UI-selected profile — so a webhook-only +profile leaked a "Webhooks" project onto whichever profile the user happened +to be viewing. Deleting the stray entry did not help: the next sidebar poll +re-minted it, and switching profiles minted another one per profile. +""" + +import json +import sqlite3 +import threading +import time + +import pytest + + +def _make_state_db(path, *, source, count=1): + conn = sqlite3.connect(str(path)) + conn.executescript( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + session_source TEXT, + title TEXT, + model TEXT, + started_at REAL NOT NULL, + message_count INTEGER DEFAULT 0, + parent_session_id TEXT, + ended_at REAL, + end_reason TEXT + ); + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT, + role TEXT, + content TEXT, + timestamp REAL + ); + """ + ) + now = time.time() + for i in range(count): + sid = f"{source}_{i:04d}_{int(now) + i}" + conn.execute( + "INSERT INTO sessions (id, source, session_source, title, model," + " started_at, message_count) VALUES (?, ?, ?, ?, 'test-model', ?, 1)", + (sid, source, source, f"{source} run", now + i), + ) + conn.execute( + "INSERT INTO messages (id, session_id, role, content, timestamp)" + " VALUES (?, ?, 'user', 'payload', ?)", + (f"{source}_msg_{i:04d}", sid, now + i), + ) + conn.commit() + conn.close() + + +@pytest.fixture(autouse=True) +def _isolate_projects(tmp_path, monkeypatch): + import api.config as cfg + import api.models as models + import api.profiles as profiles + + projects_file = tmp_path / "projects.json" + monkeypatch.setattr(cfg, "PROJECTS_FILE", projects_file) + monkeypatch.setattr(models, "PROJECTS_FILE", projects_file) + monkeypatch.setattr(models, "_projects_migrated", True) + monkeypatch.setattr(models, "_CRON_PROJECT_LOCK", threading.Lock()) + monkeypatch.setattr(models, "_WEBHOOK_PROJECT_LOCK", threading.Lock()) + monkeypatch.setattr(models, "get_last_workspace", lambda *_a, **_kw: tmp_path) + monkeypatch.setattr(profiles, "list_profiles_api", lambda: []) + monkeypatch.setattr(profiles, "_active_profile", "default") + profiles._invalidate_root_profile_cache() + yield projects_file + profiles._invalidate_root_profile_cache() + + +def _projects(path): + return json.loads(path.read_text(encoding="utf-8")) if path.exists() else [] + + +def _scan(models, home, db, profile): + return models._load_cli_sessions_uncached( + home, db, profile, + source_filter=None, + visible_session_limit=None, + cron_project_limit=None, + webhook_project_limit=None, + kanban_project_limit=None, + include_claude_code=False, + ) + + +def test_webhook_project_is_tagged_to_scanned_profile_not_selected_one(tmp_path, monkeypatch): + import api.models as models + import api.profiles as profiles + + projects_file = tmp_path / "projects.json" + hook_home = tmp_path / "profiles" / "hooks" + hook_home.mkdir(parents=True) + hook_db = hook_home / "state.db" + _make_state_db(hook_db, source="webhook", count=3) + + # UI has `default` selected; the all-profiles walk reaches the hooks profile. + monkeypatch.setattr(profiles, "_active_profile", "default") + rows = _scan(models, hook_home, hook_db, "hooks") + assert sum(1 for r in rows if r["source_tag"] == "webhook") == 3 + + webhook_projects = [p for p in _projects(projects_file) if p["name"] == models.WEBHOOK_PROJECT_NAME] + assert [p["profile"] for p in webhook_projects] == ["hooks"], ( + "Webhooks project must be tagged to the profile that owns the sessions" + ) + assert all(r["project_id"] == webhook_projects[0]["project_id"] + for r in rows if r["source_tag"] == "webhook") + + # Switching the selected profile and rescanning must reuse the same project + # rather than minting one per selected profile. + monkeypatch.setattr(profiles, "_active_profile", "deepseek") + _scan(models, hook_home, hook_db, "hooks") + webhook_projects = [p for p in _projects(projects_file) if p["name"] == models.WEBHOOK_PROJECT_NAME] + assert [p["profile"] for p in webhook_projects] == ["hooks"] + + +def test_scanning_a_profile_without_webhook_rows_mints_nothing(tmp_path, monkeypatch): + import api.models as models + import api.profiles as profiles + + projects_file = tmp_path / "projects.json" + home = tmp_path / "profiles" / "quiet" + home.mkdir(parents=True) + db = home / "state.db" + _make_state_db(db, source="cli", count=2) + + monkeypatch.setattr(profiles, "_active_profile", "default") + _scan(models, home, db, "quiet") + _scan(models, home, db, "default") + + assert not [p for p in _projects(projects_file) if p["name"] == models.WEBHOOK_PROJECT_NAME] + + +def test_cron_project_gate_and_tag_follow_scanned_profile(tmp_path, monkeypatch): + """The opt-in gate and the tag for Cron Jobs must both key on the scanned + profile: a user project on the *selected* profile must not unlock cron + project creation for a different scanned profile.""" + import api.models as models + import api.profiles as profiles + + projects_file = tmp_path / "projects.json" + projects_file.write_text(json.dumps([ + {"project_id": "u1", "name": "Mine", "profile": "default", "created_at": 1.0}, + ]), encoding="utf-8") + + cron_home = tmp_path / "profiles" / "jobs" + cron_home.mkdir(parents=True) + cron_db = cron_home / "state.db" + _make_state_db(cron_db, source="cron", count=1) + + monkeypatch.setattr(profiles, "_active_profile", "default") + rows = _scan(models, cron_home, cron_db, "jobs") + cron_rows = [r for r in rows if r["source_tag"] == "cron"] + assert len(cron_rows) == 1 + assert cron_rows[0]["project_id"] is None + assert not [p for p in _projects(projects_file) if p["name"] == models.CRON_PROJECT_NAME] + + # Once the scanned profile itself opts in, its cron project is tagged to it. + projects_file.write_text(json.dumps([ + {"project_id": "u1", "name": "Mine", "profile": "default", "created_at": 1.0}, + {"project_id": "u2", "name": "Theirs", "profile": "jobs", "created_at": 2.0}, + ]), encoding="utf-8") + rows = _scan(models, cron_home, cron_db, "jobs") + cron_projects = [p for p in _projects(projects_file) if p["name"] == models.CRON_PROJECT_NAME] + assert [p["profile"] for p in cron_projects] == ["jobs"] + assert rows[0]["project_id"] == cron_projects[0]["project_id"] diff --git a/tests/test_issue3930_source_filter_pushdown.py b/tests/test_issue3930_source_filter_pushdown.py index ade4885d..61c39f23 100644 --- a/tests/test_issue3930_source_filter_pushdown.py +++ b/tests/test_issue3930_source_filter_pushdown.py @@ -242,7 +242,7 @@ def test_load_cli_sessions_uncached_pushes_specific_source_into_state_db_scan(mo monkeypatch.setattr(models, "get_claude_code_sessions", lambda: claude_calls.append(True) or []) monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows) monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path) - monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False) + monkeypatch.setattr(models, "_profile_has_user_projects", lambda *_a, **_kw: False) monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project-id") monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) @@ -284,7 +284,7 @@ def test_cron_source_filter_uses_cron_rescue_limit(monkeypatch, tmp_path): monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows) monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path) - monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False) + monkeypatch.setattr(models, "_profile_has_user_projects", lambda *_a, **_kw: False) monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project-id") monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) diff --git a/tests/test_issue4385_cron_archive_reappears.py b/tests/test_issue4385_cron_archive_reappears.py index f2fe6052..c1ea8209 100644 --- a/tests/test_issue4385_cron_archive_reappears.py +++ b/tests/test_issue4385_cron_archive_reappears.py @@ -123,7 +123,7 @@ def test_cron_state_projection_preserves_archived_sidecar(monkeypatch, tmp_path) encoding="utf-8", ) monkeypatch.setattr(models, "SESSION_DIR", session_dir) - monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False) + monkeypatch.setattr(models, "_profile_has_user_projects", lambda *_a, **_kw: False) monkeypatch.setattr(models, "ensure_cron_project", lambda **_: "cron-project") models.clear_sidecar_metadata_cache() diff --git a/tests/test_webhook_project_sessions.py b/tests/test_webhook_project_sessions.py index 0370e059..0afdd5ff 100644 --- a/tests/test_webhook_project_sessions.py +++ b/tests/test_webhook_project_sessions.py @@ -109,8 +109,8 @@ def test_webhook_rows_get_webhook_project_id(monkeypatch, tmp_path): monkeypatch.setattr(models, "read_importable_agent_session_rows", lambda *_a, **_kw: [_agent_row()]) monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path) - monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project-id") - monkeypatch.setattr(models, "ensure_webhook_project", lambda: "webhook-project-id", raising=False) + monkeypatch.setattr(models, "ensure_cron_project", lambda **_kw: "cron-project-id") + monkeypatch.setattr(models, "ensure_webhook_project", lambda **_kw: "webhook-project-id", raising=False) monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) rows = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None, cron_project_limit=False) @@ -138,8 +138,8 @@ def test_webhook_second_pass_keeps_older_project_rows_available(monkeypatch, tmp monkeypatch.setattr(models, "read_importable_agent_session_rows", fake_read_rows) monkeypatch.setattr(models, "get_last_workspace", lambda profile=None: tmp_path) - monkeypatch.setattr(models, "ensure_cron_project", lambda: "cron-project-id") - monkeypatch.setattr(models, "ensure_webhook_project", lambda: "webhook-project-id", raising=False) + monkeypatch.setattr(models, "ensure_cron_project", lambda **_kw: "cron-project-id") + monkeypatch.setattr(models, "ensure_webhook_project", lambda **_kw: "webhook-project-id", raising=False) monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None) rows = models._load_cli_sessions_uncached(tmp_path, db, _cli_profile=None, cron_project_limit=False) From c748049bf7291737caa1d3e9fe1fc555cc9b2f1b Mon Sep 17 00:00:00 2001 From: James Drake Date: Tue, 11 Aug 2026 23:30:08 -0500 Subject: [PATCH 110/446] fix: clean up failed chat worker launches --- api/routes.py | 25 +++++ .../test_issue6869_gateway_launch_failure.py | 93 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 tests/test_issue6869_gateway_launch_failure.py diff --git a/api/routes.py b/api/routes.py index 7d30ab90..b73ec9af 100644 --- a/api/routes.py +++ b/api/routes.py @@ -22810,6 +22810,30 @@ def _prepare_chat_start_session_for_stream( s.save() +def _cleanup_chat_start_launch_failure(session, stream_id: str) -> None: + """Release state registered before a worker thread successfully starts.""" + clear_session_writeback_owner_if_owned(session.session_id, stream_id) + unregister_stream_owner(stream_id) + with STREAMS_LOCK: + STREAMS.pop(stream_id, None) + STREAM_GOAL_RELATED.pop(stream_id, None) + if getattr(session, "active_stream_id", None) != stream_id: + return + session.active_stream_id = None + session.pending_user_message = None + session.pending_attachments = [] + session.pending_started_at = None + session.pending_user_source = None + try: + session.save() + except Exception: + logger.debug( + "Failed to persist chat-start cleanup after worker launch failure for %s", + stream_id, + exc_info=True, + ) + + def _is_hidden_empty_session(s) -> bool: return ( getattr(s, "title", "Untitled") == "Untitled" @@ -23393,6 +23417,7 @@ def _start_chat_stream_for_session( _clear_gateway_run_starting(stream_id) except Exception: logger.debug("Failed to record gateway run-start failure for stream %s", stream_id, exc_info=True) + _cleanup_chat_start_launch_failure(s, stream_id) raise response = { "stream_id": stream_id, diff --git a/tests/test_issue6869_gateway_launch_failure.py b/tests/test_issue6869_gateway_launch_failure.py new file mode 100644 index 00000000..7d8c6fe0 --- /dev/null +++ b/tests/test_issue6869_gateway_launch_failure.py @@ -0,0 +1,93 @@ +"""Regression tests for #6869: failed Gateway worker launch cleanup.""" + +from types import SimpleNamespace + +import api.config as config +import api.routes as routes +from api import turn_journal + + +def test_gateway_thread_start_failure_releases_writeback_owner_and_stream_state(monkeypatch): + """A thread-start exception must not leave one owner per failed Gateway launch.""" + class FailingThread: + def __init__(self, *args, **kwargs): + pass + + def start(self): + raise RuntimeError("thread launch failed") + + def fake_prepare(session, *, stream_id, **kwargs): + session.active_stream_id = stream_id + session.pending_user_message = kwargs["msg"] + session.pending_started_at = 1.0 + config.register_session_writeback_owner(session.session_id, stream_id) + + monkeypatch.setattr(routes.threading, "Thread", FailingThread) + monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", fake_prepare) + monkeypatch.setattr(routes, "set_last_workspace", lambda _workspace: None) + monkeypatch.setattr(routes, "_is_hidden_empty_session", lambda _session: False) + monkeypatch.setattr( + turn_journal, + "append_turn_journal_event", + lambda *_args, **_kwargs: {}, + ) + + for index in range(3): + session = SimpleNamespace( + session_id=f"session-launch-failure-{index}", + title="Existing", + active_stream_id=None, + pending_user_message=None, + pending_attachments=[], + pending_started_at=None, + pending_user_source=None, + messages=[{"role": "user", "content": "old"}], + workspace="/tmp", + model="old-model", + model_provider=None, + worktree_path=None, + profile=None, + save=lambda: None, + ) + + try: + routes._start_chat_stream_for_session( + session, + msg="start gateway turn", + attachments=[], + workspace="/tmp", + model="test-model", + external_runtime_owned=True, + ) + except RuntimeError as exc: + assert str(exc) == "thread launch failed" + else: + raise AssertionError("thread-start failure must propagate") + + assert config.SESSION_WRITEBACK_OWNERS == {} + assert config.STREAM_SESSION_OWNERS == {} + assert config.STREAMS == {} + + +def test_gateway_launch_failure_cleanup_does_not_clear_successor_owner(): + """Cleanup must be compare-and-clear when a successor already took over.""" + session = SimpleNamespace( + session_id="session-launch-successor", + active_stream_id="new-stream", + pending_user_message="new prompt", + pending_attachments=["new.txt"], + pending_started_at=2.0, + pending_user_source="webui", + save=lambda: None, + ) + config.register_session_writeback_owner(session.session_id, "old-stream") + config.register_session_writeback_owner(session.session_id, "new-stream") + config.register_stream_owner("old-stream", session.session_id) + config.STREAMS["old-stream"] = object() + + routes._cleanup_chat_start_launch_failure(session, "old-stream") + + assert config.session_writeback_owner(session.session_id) == "new-stream" + assert session.active_stream_id == "new-stream" + assert session.pending_user_message == "new prompt" + assert "old-stream" not in config.STREAMS From b5c0136545a2ed65aeb98163a954f2275721d452 Mon Sep 17 00:00:00 2001 From: James Drake Date: Thu, 27 Aug 2026 08:34:52 -0500 Subject: [PATCH 111/446] fix: run launch-failure session reset under session lock The session-field half of _cleanup_chat_start_launch_failure mutated and saved the passed-in session object without holding the per-session lock and without re-resolving the canonical session. A concurrent successor turn (same session_id, new stream_id) or a delete could be clobbered: pending fields wiped or a deleted session resurrected via save(). Mirror the established #1533 pattern (routes.py:3077) and anchor-scene write guard (routes.py:5140): hold _get_session_agent_lock, re-resolve with get_session(), early-return on KeyError (session deleted) or when the canonical no longer points at the failed stream, then reset pending fields on the canonical object only. Add deterministic race coverage: successor admitted during the check-then-clear window keeps its pending fields and is not saved away, and a deleted session is not resurrected by save(). Both tests fail against the previous implementation. --- api/routes.py | 42 +++-- .../test_issue6869_gateway_launch_failure.py | 150 ++++++++++++++---- 2 files changed, 144 insertions(+), 48 deletions(-) diff --git a/api/routes.py b/api/routes.py index b73ec9af..da9537b3 100644 --- a/api/routes.py +++ b/api/routes.py @@ -22817,21 +22817,33 @@ def _cleanup_chat_start_launch_failure(session, stream_id: str) -> None: with STREAMS_LOCK: STREAMS.pop(stream_id, None) STREAM_GOAL_RELATED.pop(stream_id, None) - if getattr(session, "active_stream_id", None) != stream_id: - return - session.active_stream_id = None - session.pending_user_message = None - session.pending_attachments = [] - session.pending_started_at = None - session.pending_user_source = None - try: - session.save() - except Exception: - logger.debug( - "Failed to persist chat-start cleanup after worker launch failure for %s", - stream_id, - exc_info=True, - ) + # The session-field reset needs the same concurrency discipline as the + # registry half: hold the per-session lock and re-resolve the canonical + # session before clearing anything. Mutating the passed-in stale object + # could wipe a concurrent successor turn's pending fields, and saving it + # could resurrect a session deleted while the launch was failing. Same + # pattern as the #1533 race fix (routes.py:3077) and the anchor-scene + # write guard (routes.py:5140). + with _get_session_agent_lock(session.session_id): + try: + canonical = get_session(session.session_id) + except KeyError: + return # session deleted while the thread launch was failing + if getattr(canonical, "active_stream_id", None) != stream_id: + return # a successor turn already owns the session + canonical.active_stream_id = None + canonical.pending_user_message = None + canonical.pending_attachments = [] + canonical.pending_started_at = None + canonical.pending_user_source = None + try: + canonical.save() + except Exception: + logger.debug( + "Failed to persist chat-start cleanup after worker launch failure for %s", + stream_id, + exc_info=True, + ) def _is_hidden_empty_session(s) -> bool: diff --git a/tests/test_issue6869_gateway_launch_failure.py b/tests/test_issue6869_gateway_launch_failure.py index 7d8c6fe0..703209b9 100644 --- a/tests/test_issue6869_gateway_launch_failure.py +++ b/tests/test_issue6869_gateway_launch_failure.py @@ -7,6 +7,32 @@ import api.routes as routes from api import turn_journal +def _make_session(session_id, *, active_stream_id=None, pending_user_message=None, save=None): + return SimpleNamespace( + session_id=session_id, + title="Existing", + active_stream_id=active_stream_id, + pending_user_message=pending_user_message, + pending_attachments=[], + pending_started_at=None, + pending_user_source=None, + messages=[{"role": "user", "content": "old"}], + workspace="/tmp", + model="old-model", + model_provider=None, + worktree_path=None, + profile=None, + save=save or (lambda: None), + ) + + +def _register_failed_stream(session_id, stream_id): + """Mirror the registry state _prepare_chat_start_session_for_stream installs.""" + config.register_session_writeback_owner(session_id, stream_id) + config.register_stream_owner(stream_id, session_id) + config.STREAMS[stream_id] = object() + + def test_gateway_thread_start_failure_releases_writeback_owner_and_stream_state(monkeypatch): """A thread-start exception must not leave one owner per failed Gateway launch.""" class FailingThread: @@ -16,16 +42,24 @@ def test_gateway_thread_start_failure_releases_writeback_owner_and_stream_state( def start(self): raise RuntimeError("thread launch failed") + canonical_by_id = {} + def fake_prepare(session, *, stream_id, **kwargs): session.active_stream_id = stream_id session.pending_user_message = kwargs["msg"] session.pending_started_at = 1.0 - config.register_session_writeback_owner(session.session_id, stream_id) + _register_failed_stream(session.session_id, stream_id) + canonical_by_id[session.session_id] = session monkeypatch.setattr(routes.threading, "Thread", FailingThread) monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", fake_prepare) monkeypatch.setattr(routes, "set_last_workspace", lambda _workspace: None) monkeypatch.setattr(routes, "_is_hidden_empty_session", lambda _session: False) + monkeypatch.setattr( + routes, + "get_session", + lambda sid, metadata_only=False: canonical_by_id[sid], + ) monkeypatch.setattr( turn_journal, "append_turn_journal_event", @@ -33,23 +67,7 @@ def test_gateway_thread_start_failure_releases_writeback_owner_and_stream_state( ) for index in range(3): - session = SimpleNamespace( - session_id=f"session-launch-failure-{index}", - title="Existing", - active_stream_id=None, - pending_user_message=None, - pending_attachments=[], - pending_started_at=None, - pending_user_source=None, - messages=[{"role": "user", "content": "old"}], - workspace="/tmp", - model="old-model", - model_provider=None, - worktree_path=None, - profile=None, - save=lambda: None, - ) - + session = _make_session(f"session-launch-failure-{index}") try: routes._start_chat_stream_for_session( session, @@ -63,31 +81,97 @@ def test_gateway_thread_start_failure_releases_writeback_owner_and_stream_state( assert str(exc) == "thread launch failed" else: raise AssertionError("thread-start failure must propagate") + assert session.active_stream_id is None + assert session.pending_user_message is None + assert session.pending_started_at is None assert config.SESSION_WRITEBACK_OWNERS == {} assert config.STREAM_SESSION_OWNERS == {} assert config.STREAMS == {} -def test_gateway_launch_failure_cleanup_does_not_clear_successor_owner(): - """Cleanup must be compare-and-clear when a successor already took over.""" - session = SimpleNamespace( - session_id="session-launch-successor", +def test_gateway_launch_failure_cleanup_does_not_clear_successor_owner(monkeypatch): + """A successor fully installed before cleanup keeps its registry ownership.""" + canonical = _make_session( + "session-launch-successor", active_stream_id="new-stream", pending_user_message="new prompt", - pending_attachments=["new.txt"], - pending_started_at=2.0, - pending_user_source="webui", save=lambda: None, ) - config.register_session_writeback_owner(session.session_id, "old-stream") - config.register_session_writeback_owner(session.session_id, "new-stream") - config.register_stream_owner("old-stream", session.session_id) - config.STREAMS["old-stream"] = object() + monkeypatch.setattr( + routes, + "get_session", + lambda sid, metadata_only=False: canonical, + ) + stale = _make_session("session-launch-successor") + _register_failed_stream(stale.session_id, "old-stream") + config.register_session_writeback_owner(stale.session_id, "new-stream") - routes._cleanup_chat_start_launch_failure(session, "old-stream") + routes._cleanup_chat_start_launch_failure(stale, "old-stream") - assert config.session_writeback_owner(session.session_id) == "new-stream" - assert session.active_stream_id == "new-stream" - assert session.pending_user_message == "new prompt" + assert config.session_writeback_owner(stale.session_id) == "new-stream" + assert canonical.active_stream_id == "new-stream" + assert canonical.pending_user_message == "new prompt" assert "old-stream" not in config.STREAMS + + +def test_gateway_launch_failure_cleanup_does_not_wipe_successor_admitted_during_cleanup(monkeypatch): + """A successor admitted mid-cleanup survives: the guard runs on the re-resolved + canonical session, not on the stale passed-in object that still shows the + failed stream.""" + saved = [] + + canonical = _make_session( + "session-launch-race", + active_stream_id="new-stream", + pending_user_message="successor prompt", + save=lambda: saved.append("canonical"), + ) + monkeypatch.setattr( + routes, + "get_session", + lambda sid, metadata_only=False: canonical, + ) + stale = _make_session( + "session-launch-race", + active_stream_id="old-stream", + pending_user_message="failed-stream prompt", + save=lambda: saved.append("stale"), + ) + _register_failed_stream(stale.session_id, "old-stream") + config.register_session_writeback_owner(stale.session_id, "new-stream") + + routes._cleanup_chat_start_launch_failure(stale, "old-stream") + + assert config.session_writeback_owner(stale.session_id) == "new-stream" + assert canonical.active_stream_id == "new-stream" + assert canonical.pending_user_message == "successor prompt" + assert "old-stream" not in config.STREAMS + assert saved == [], "cleanup must not save a session it early-returns on" + + +def test_gateway_launch_failure_cleanup_does_not_resurrect_deleted_session(monkeypatch): + """A session deleted while the launch was failing must not be recreated: + the resolver raises KeyError and cleanup returns without saving anything.""" + saved = [] + + def deleted_resolver(sid, metadata_only=False): + raise KeyError(sid) + + monkeypatch.setattr(routes, "get_session", deleted_resolver) + stale = _make_session( + "session-launch-deleted", + active_stream_id="old-stream", + pending_user_message="pending prompt", + save=lambda: saved.append("stale"), + ) + _register_failed_stream(stale.session_id, "old-stream") + + routes._cleanup_chat_start_launch_failure(stale, "old-stream") + + assert config.session_writeback_owner(stale.session_id) is None + assert "old-stream" not in config.STREAM_SESSION_OWNERS + assert "old-stream" not in config.STREAMS + assert stale.active_stream_id == "old-stream" + assert stale.pending_user_message == "pending prompt" + assert saved == [], "cleanup must not save a deleted session" \ No newline at end of file From f444cd1d2e0eeb8f9d5a516a4a308f34d583f9d5 Mon Sep 17 00:00:00 2001 From: James Drake Date: Fri, 18 Sep 2026 08:23:29 -0500 Subject: [PATCH 112/446] test: accept the profile kwarg in the launch-failure stub Master added a profile= kwarg to set_last_workspace; the monkeypatched stub in this test took only the workspace arg and broke on the rebase. --- tests/test_issue6869_gateway_launch_failure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_issue6869_gateway_launch_failure.py b/tests/test_issue6869_gateway_launch_failure.py index 703209b9..fdb731bb 100644 --- a/tests/test_issue6869_gateway_launch_failure.py +++ b/tests/test_issue6869_gateway_launch_failure.py @@ -53,7 +53,7 @@ def test_gateway_thread_start_failure_releases_writeback_owner_and_stream_state( monkeypatch.setattr(routes.threading, "Thread", FailingThread) monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", fake_prepare) - monkeypatch.setattr(routes, "set_last_workspace", lambda _workspace: None) + monkeypatch.setattr(routes, "set_last_workspace", lambda _workspace, **_kw: None) monkeypatch.setattr(routes, "_is_hidden_empty_session", lambda _session: False) monkeypatch.setattr( routes, From 218164f6c889fa3e1cf53b2bad41492933e5361d Mon Sep 17 00:00:00 2001 From: James Drake Date: Fri, 18 Sep 2026 08:28:03 -0500 Subject: [PATCH 113/446] test: scope the launch-failure registry asserts to this test's own streams scripts/test.sh runs the whole selection in one process (no per-file subprocess isolation), so asserting the global registries are empty made this test fail on unrelated files that leave entries behind. --- tests/test_issue6869_gateway_launch_failure.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/test_issue6869_gateway_launch_failure.py b/tests/test_issue6869_gateway_launch_failure.py index fdb731bb..d4ea0ea0 100644 --- a/tests/test_issue6869_gateway_launch_failure.py +++ b/tests/test_issue6869_gateway_launch_failure.py @@ -43,6 +43,7 @@ def test_gateway_thread_start_failure_releases_writeback_owner_and_stream_state( raise RuntimeError("thread launch failed") canonical_by_id = {} + registered_stream_ids = [] def fake_prepare(session, *, stream_id, **kwargs): session.active_stream_id = stream_id @@ -50,6 +51,7 @@ def test_gateway_thread_start_failure_releases_writeback_owner_and_stream_state( session.pending_started_at = 1.0 _register_failed_stream(session.session_id, stream_id) canonical_by_id[session.session_id] = session + registered_stream_ids.append(stream_id) monkeypatch.setattr(routes.threading, "Thread", FailingThread) monkeypatch.setattr(routes, "_prepare_chat_start_session_for_stream", fake_prepare) @@ -85,9 +87,15 @@ def test_gateway_thread_start_failure_releases_writeback_owner_and_stream_state( assert session.pending_user_message is None assert session.pending_started_at is None - assert config.SESSION_WRITEBACK_OWNERS == {} - assert config.STREAM_SESSION_OWNERS == {} - assert config.STREAMS == {} + # Scope these to the streams this test registered. scripts/test.sh runs the + # whole selection in one process, so asserting the global registries are + # empty makes this test fail on unrelated files that leave entries behind. + assert registered_stream_ids, "the launch path must register state before failing" + for stream_id in registered_stream_ids: + assert stream_id not in config.STREAM_SESSION_OWNERS + assert stream_id not in config.STREAMS + for session_id in canonical_by_id: + assert config.session_writeback_owner(session_id) is None def test_gateway_launch_failure_cleanup_does_not_clear_successor_owner(monkeypatch): From f4b28346e5d2050fc9051f4cd981cab3cbf50924 Mon Sep 17 00:00:00 2001 From: happy5318 Date: Fri, 18 Sep 2026 22:00:45 +0800 Subject: [PATCH 114/446] 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 e781542bf44ac575423784e9f87747ed6e3dd796 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 18 Sep 2026 16:36:46 +0100 Subject: [PATCH 115/446] Cache profile home resolution to fix N+1 slowdown in session list _resolve_profile_home_param() called Path.resolve() on the profile's home directory with no caching. The webui session list builds one Session per CLI session row, and Session.__init__ calls this function once per row, so with ~484 sessions on the same profile it redundantly re-resolved the identical path hundreds of times per request. Individually cheap (~1ms), but under host load this compounded into multi-second sidebar hangs, confirmed via journalctl showing repeated 'Slow WebUI request still running' warnings (5-17s) pinned to this exact call site. The mapping from a profile argument to its home path is a pure function of process-startup constants (_DEFAULT_HERMES_HOME / _INITIAL_HERMES_HOME) that are resolved once at import time and never mutated afterwards, so memoizing per profile argument for the life of the process is safe. Profile creation only creates the directory at the already-deterministic path; it does not change what path a profile argument resolves to. Co-Authored-By: Claude Sonnet 5 --- api/workspace.py | 34 ++++++++++++++++++++-- tests/test_profile_home_resolve_caching.py | 34 ++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/test_profile_home_resolve_caching.py diff --git a/api/workspace.py b/api/workspace.py index d8ffb282..ebeaacaa 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -207,6 +207,36 @@ def _is_remote_terminal_backend(terminal_cfg: dict | None) -> bool: return backend not in ('', 'local') +# Per-profile-argument cache for `_resolve_profile_home_param`'s filesystem +# `.resolve()` call. The webui's session list builds one `Session` per CLI +# session row (`_load_cli_sessions_uncached`), and `Session.__init__` calls +# `_resolve_profile_home_param(profile)` once per row -- with only one +# profile in play (the common case) that is the SAME path resolved hundreds +# of times per request. Individually a resolve() is ~1ms, but at a few +# hundred sessions this compounded into multi-second sidebar hangs under +# host load (confirmed via repeated "Slow WebUI request still running" +# warnings pinned to this call site). +# +# Caching is safe for the life of the process: the mapping from a profile +# argument to its home path is a pure function of process-startup constants +# (`api.profiles._DEFAULT_HERMES_HOME`, `_INITIAL_HERMES_HOME`) that are +# resolved once at import time and never mutated afterwards. Profile +# *creation* only creates the directory at that already-deterministic path +# -- it does not change what path a given profile argument resolves to -- +# so there is no runtime path that needs to invalidate an entry here. +_PROFILE_HOME_RESOLVE_CACHE: dict[Path, Path] = {} + + +def _cached_safe_resolve_profile_home(pre_resolve: Path) -> Path: + """Memoized `_safe_resolve()` for profile-home paths (see cache doc above).""" + cached = _PROFILE_HOME_RESOLVE_CACHE.get(pre_resolve) + if cached is not None: + return cached + resolved = _safe_resolve(pre_resolve) + _PROFILE_HOME_RESOLVE_CACHE[pre_resolve] = resolved + return resolved + + def _resolve_profile_home_param(profile: str | Path | None) -> Path: """Resolve a profile parameter (name string, directory path string, or Path) to a profile home Path. @@ -244,14 +274,14 @@ def _resolve_profile_home_param(profile: str | Path | None) -> Path: raw = str(profile).strip() if isinstance(profile, Path): - return _safe_resolve(profile.expanduser()) + return _cached_safe_resolve_profile_home(profile.expanduser()) # Strings are LOGICAL PROFILE IDS ONLY — no path-shaped strings, ever. if not _PROFILE_NAME_RE.fullmatch(raw): raise ValueError(f"invalid profile name: {raw!r}") from api.profiles import get_hermes_home_for_profile - return _safe_resolve(get_hermes_home_for_profile(raw)) + return _cached_safe_resolve_profile_home(get_hermes_home_for_profile(raw)) def _is_default_profile_home(profile_home: Path) -> bool: diff --git a/tests/test_profile_home_resolve_caching.py b/tests/test_profile_home_resolve_caching.py new file mode 100644 index 00000000..f2a16587 --- /dev/null +++ b/tests/test_profile_home_resolve_caching.py @@ -0,0 +1,34 @@ +"""Regression test: `_resolve_profile_home_param` memoizes its filesystem +`.resolve()` call per profile argument (N+1 sidebar-hang fix). + +Building the CLI session list calls `_resolve_profile_home_param(profile)` +once per session row (`Session.__init__`), so with hundreds of sessions on +the same profile the old code redundantly re-resolved the identical path via +a filesystem `.resolve()` on every single row. This asserts repeated calls +with the same profile argument return an equal Path while invoking the +underlying `_safe_resolve()` filesystem call only once. +""" +import sys +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import api.workspace as ws # noqa: E402 + + +def test_resolve_profile_home_param_caches_repeated_path_argument(tmp_path): + ws._PROFILE_HOME_RESOLVE_CACHE.clear() + home_dir = tmp_path / "profile_home" + home_dir.mkdir() + + with patch.object(ws, "_safe_resolve", wraps=ws._safe_resolve) as spy: + first = ws._resolve_profile_home_param(home_dir) + second = ws._resolve_profile_home_param(home_dir) + third = ws._resolve_profile_home_param(home_dir) + + assert first == second == third == home_dir.resolve() + assert spy.call_count == 1, ( + "expected the filesystem resolve() to run once and be served from " + f"cache thereafter, but it ran {spy.call_count} times" + ) From 67d6c3ff50182fec756023f8d310251f5ac23ca3 Mon Sep 17 00:00:00 2001 From: hermes Date: Fri, 18 Sep 2026 16:57:17 +0100 Subject: [PATCH 116/446] Add regression test for cached profile-name-string resolution branch _resolve_profile_home_param has two branches feeding the same _cached_safe_resolve_profile_home() memoization: an explicit Path, and a logical profile-name string resolved via get_hermes_home_for_profile(). The existing regression test only covered the Path branch. Add a matching test that repeatedly resolves a profile-name string and confirms it returns the correct home and that _safe_resolve is invoked only once across repeated calls, i.e. the cache is genuinely hit for the string-keyed path too. Co-Authored-By: Claude Sonnet 5 --- tests/test_profile_home_resolve_caching.py | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_profile_home_resolve_caching.py b/tests/test_profile_home_resolve_caching.py index f2a16587..ccc8f63f 100644 --- a/tests/test_profile_home_resolve_caching.py +++ b/tests/test_profile_home_resolve_caching.py @@ -32,3 +32,30 @@ def test_resolve_profile_home_param_caches_repeated_path_argument(tmp_path): "expected the filesystem resolve() to run once and be served from " f"cache thereafter, but it ran {spy.call_count} times" ) + + +def test_resolve_profile_home_param_caches_repeated_profile_name_argument(tmp_path): + """Same fix, second branch: a logical profile-NAME STRING (not a Path) + + resolves through `get_hermes_home_for_profile()` before hitting the same + `_cached_safe_resolve_profile_home()` memoization. This mirrors the + Path-argument test above but drives the string branch (workspace.py's + ``isinstance(profile, Path)`` check is False), confirming the cache is + genuinely hit -- not just for explicit Path callers -- and that the + resolved home is correct. + """ + ws._PROFILE_HOME_RESOLVE_CACHE.clear() + home_dir = tmp_path / "named_profile_home" + home_dir.mkdir() + + with patch("api.profiles.get_hermes_home_for_profile", return_value=home_dir), \ + patch.object(ws, "_safe_resolve", wraps=ws._safe_resolve) as spy: + first = ws._resolve_profile_home_param("myprofile") + second = ws._resolve_profile_home_param("myprofile") + third = ws._resolve_profile_home_param("myprofile") + + assert first == second == third == home_dir.resolve() + assert spy.call_count == 1, ( + "expected the filesystem resolve() to run once and be served from " + f"cache thereafter, but it ran {spy.call_count} times" + ) From 4e7079175784226ee088f68faf99e3a8111e8bc4 Mon Sep 17 00:00:00 2001 From: n Date: Fri, 18 Sep 2026 22:12:03 +0000 Subject: [PATCH 117/446] chore: trim trailing blank line at EOF (pre-release whitespace gate) --- tests/test_renderer_js_behaviour.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py index 61f919cf..d8f41790 100644 --- a/tests/test_renderer_js_behaviour.py +++ b/tests/test_renderer_js_behaviour.py @@ -926,4 +926,3 @@ class TestBulletListItalicCollision: assert "<strong>" not in out assert "Label:" in out assert "italic" in out - From 75d51d5ee24d871e9116be7588a20f8b879b5193 Mon Sep 17 00:00:00 2001 From: n Date: Fri, 18 Sep 2026 22:24:45 +0000 Subject: [PATCH 118/446] fix(renderer): repair two gate-found defects in the #7618 fix The release gate (Codex, adversarial pass) reproduced two SILENT regressions introduced by the original implementation. Both are fixed here with revert-sensitive regressions. 1. Collidable sentinel. Table-row
was stashed as a fixed \x00BR\x00 token and that token was unconditionally rewritten back to
afterwards, so user-supplied text containing the literal token was corrupted -- in prose it became a
, and inside a link URL it broke the anchor apart. Replaced with conditional conversion:
becomes a newline only on non-table-row lines, so no placeholder is ever introduced and nothing user-suppliable is rewritten. 2. Lost HTML emphasis. The stricter italic regex (which correctly stops 'a * b * c' from italicising) also rejected the '* x *' produced by the / pre-pass, so ' italic ' degraded to literal asterisks -- and to a bullet list at line start. Boundary whitespace is now kept OUTSIDE the generated delimiters. Verified against the real renderMd(): output for both cases now matches master, while the table-cell
and spaced-asterisk fixes still hold. A revert probe confirms all five new regressions fail against the defective implementation. Co-authored-by: JayaMegelar --- static/ui.js | 28 +++++++++------ tests/test_renderer_js_behaviour.py | 56 +++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/static/ui.js b/static/ui.js index 6d47aacd..66061b57 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7774,18 +7774,24 @@ function renderMd(raw){ }); s=s.replace(/([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**'); s=s.replace(/([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**'); - s=s.replace(/([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*'); - s=s.replace(/([\s\S]*?)<\/i>/gi,(_,t)=>'*'+t+'*'); + // Keep boundary whitespace OUTSIDE the generated *...* delimiters: the inline + // emphasis regex below deliberately rejects a leading/trailing space (so + // `a * b * c` is not italicised), and ` x ` would otherwise degrade + // into literal asterisks (or a bullet list at line start). + const _emphasis=(t)=>{ + const m=String(t).match(/^(\s*)([\s\S]*?)(\s*)$/); + return (m && m[2]) ? m[1]+'*'+m[2]+'*'+m[3] : t; + }; + s=s.replace(/([\s\S]*?)<\/em>/gi,(_,t)=>_emphasis(t)); + s=s.replace(/([\s\S]*?)<\/i>/gi,(_,t)=>_emphasis(t)); s=s.replace(/([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`'); - // Protect
inside markdown table rows so converting
to \n does not destroy the table - s=s.split('\n').map(line=>{ - if(/^\s*\|.*\|\s*$/.test(line)){ - return line.replace(//gi,'\x00BR\x00'); - } - return line; - }).join('\n'); - s=s.replace(//gi,'\n'); - s=s.replace(/\x00BR\x00/g,'
'); + // Convert
to a newline ONLY outside markdown table rows — inside a row a + // newline would split the row and destroy the table. No sentinel token is used + // here on purpose: any fixed placeholder is attacker-suppliable in message text + // and would be rewritten on the way out. + s=s.split('\n').map(line=>( + /^\s*\|.*\|\s*$/.test(line) ? line : line.replace(//gi,'\n') + )).join('\n'); // ── Glued-bold-heading lift (issue #1446) ──────────────────────────────── // LLMs in thinking/reasoning mode frequently emit a "section header" glued // to the end of the previous paragraph with no whitespace, like: diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py index d8f41790..0793fbaa 100644 --- a/tests/test_renderer_js_behaviour.py +++ b/tests/test_renderer_js_behaviour.py @@ -926,3 +926,59 @@ class TestBulletListItalicCollision: assert "<strong>" not in out assert "Label:" in out assert "italic" in out + + +class TestRendererGateRegressions7618: + """Regressions for the two defects the release gate found in the #7618 fix. + + Both were SILENT: they produced wrong output with no error, and neither was + covered by the PR's own tests. + """ + + def test_literal_br_sentinel_in_prose_is_not_rewritten(self, driver_path): + """A user typing the sentinel must not have it turned into a
. + + The first implementation stashed table-row
as a fixed \\x00BR\\x00 + token and unconditionally rewrote that token back to
afterwards, so + attacker/user-supplied text containing the literal token was corrupted. + """ + out = _render(driver_path, "literal \x00BR\x00 text") + assert "
" not in out + assert "\x00BR\x00" in out + + def test_literal_br_sentinel_inside_link_does_not_corrupt_anchor(self, driver_path): + """The sentinel inside a URL must not break out of the href attribute.""" + out = _render(driver_path, '[x](https://example.test/\x00BR\x00tail)') + # The anchor must stay well-formed: no attribute text leaking into the body. + assert 'target="_blank"' not in out + assert '>tail" target=' not in out + + def test_literal_br_sentinel_in_table_cell_stays_literal(self, driver_path): + src = "| a | b |\n|---|---|\n| \x00BR\x00 | c |" + out = _render(driver_path, src) + assert "
" in out + assert "
" not in out + + def test_html_em_with_boundary_whitespace_still_italicises(self, driver_path): + """` x ` must stay emphasis. + + The stricter italic regex (which correctly stops `a * b * c` from + italicising) also rejected the `* x *` that the HTML pre-pass produced + for ` x `, degrading supported emphasis into literal asterisks + — and into a bullet list when it started a line. + """ + out = _render(driver_path, " italic ") + assert "italic" in out + assert "
    " not in out + assert "
  • " not in out + + def test_html_i_with_boundary_whitespace_still_italicises(self, driver_path): + out = _render(driver_path, " spaced ") + assert "spaced" in out + assert "
      " not in out + + def test_spaced_asterisks_still_not_italicised(self, driver_path): + """The original #7618 fix must survive the boundary-whitespace repair.""" + out = _render(driver_path, "2 * 3 * 4 = 24") + assert "" not in out + assert "2 * 3 * 4 = 24" in out From 49d4aab4e2cddce8e1f8f9fa597ceed55968c0e8 Mon Sep 17 00:00:00 2001 From: n Date: Fri, 18 Sep 2026 22:33:38 +0000 Subject: [PATCH 119/446] fix(renderer): match the table-row guard to the real table grammar Third gate finding: the table-row predicate accepted ANY pipe-wrapped line, but the downstream table parser only accepts a run of pipe-lines whose SECOND line is a separator. So pipe-delimited prose such as '| note
      # heading |' was treated as a table row and kept its literal
      , silently losing heading/list rendering that master renders correctly. The guard now classifies complete blocks with the parser's own grammar (row regex + separator check) and converts
      on every unclassified line. Verified: all non-table cases now match master byte-for-byte, while genuine multi-row tables keep
      in every data cell. Co-authored-by: JayaMegelar --- static/ui.js | 33 +++++++++++++++++++++++------ tests/test_renderer_js_behaviour.py | 32 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/static/ui.js b/static/ui.js index 66061b57..595f0b59 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7785,13 +7785,32 @@ function renderMd(raw){ s=s.replace(/([\s\S]*?)<\/em>/gi,(_,t)=>_emphasis(t)); s=s.replace(/([\s\S]*?)<\/i>/gi,(_,t)=>_emphasis(t)); s=s.replace(/([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`'); - // Convert
      to a newline ONLY outside markdown table rows — inside a row a - // newline would split the row and destroy the table. No sentinel token is used - // here on purpose: any fixed placeholder is attacker-suppliable in message text - // and would be rewritten on the way out. - s=s.split('\n').map(line=>( - /^\s*\|.*\|\s*$/.test(line) ? line : line.replace(//gi,'\n') - )).join('\n'); + // Convert
      to a newline, EXCEPT inside genuine markdown table rows — there a + // newline would split the row and destroy the table. No sentinel token is used on + // purpose: any fixed placeholder is attacker-suppliable in message text and would be + // rewritten on the way out. + // The "is this a table row" test must match the DOWNSTREAM table parser's grammar + // exactly (a run of pipe-lines whose SECOND line is a separator). A looser per-line + // test would treat pipe-wrapped prose like `| note
      # heading |` as a table row and + // silently strip its heading/list rendering. + { + const _rowRe=/^ {0,3}\|.+\|[ \t]*$/; + const _sepRe=/^\|[\s|:-]+\|$/; + const _lines=s.split('\n'); + const _isTableRow=new Array(_lines.length).fill(false); + for(let i=0;i<_lines.length;){ + if(!_rowRe.test(_lines[i])){ i++; continue; } + let j=i; + while(j<_lines.length && _rowRe.test(_lines[j])) j++; + // A block qualifies only if the parser would accept it: >=2 rows and a separator + // in the second position. + if(j-i>=2 && _sepRe.test(_lines[i+1].trim())){ + for(let k=i;k_isTableRow[i]?line:line.replace(//gi,'\n')).join('\n'); + } // ── Glued-bold-heading lift (issue #1446) ──────────────────────────────── // LLMs in thinking/reasoning mode frequently emit a "section header" glued // to the end of the previous paragraph with no whitespace, like: diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py index 0793fbaa..80bfecda 100644 --- a/tests/test_renderer_js_behaviour.py +++ b/tests/test_renderer_js_behaviour.py @@ -982,3 +982,35 @@ class TestRendererGateRegressions7618: out = _render(driver_path, "2 * 3 * 4 = 24") assert "" not in out assert "2 * 3 * 4 = 24" in out + + def test_pipe_wrapped_prose_keeps_heading_rendering(self, driver_path): + """`| note
      # heading |` is NOT a table — it must keep heading rendering. + + The table-row guard must use the same grammar as the downstream table + parser (a pipe-line run whose SECOND line is a separator). A naive + per-line "looks pipe-wrapped" test silently stripped heading/list + rendering from pipe-delimited prose. + """ + out = _render(driver_path, "| prose
      # heading |") + assert "

      " in out + + def test_pipe_wrapped_prose_keeps_list_rendering(self, driver_path): + out = _render(driver_path, "| prose
      - item |") + assert "
        " in out and "
      • " in out + + def test_pipe_rows_without_separator_are_not_treated_as_table(self, driver_path): + """Two pipe lines with no separator row are prose, so
        still converts.""" + out = _render(driver_path, "| a | b |\n| c | d |") + assert "

" not in out + + def test_multi_row_table_preserves_br_in_every_data_row(self, driver_path): + src = ( + "| Feature | Notes | Status |\n" + "|---|---|---|\n" + "| Auth | OAuth
API keys
tokens | shipped |\n" + "| Cache | LRU
60s TTL | in review |" + ) + out = _render(driver_path, src) + assert out.count("
") == 3 + assert "OAuth
API keys
tokens" in out + assert "LRU
60s TTL" in out From 27bed49ee47e56fa60e3ae33d46ff51ca56d55d0 Mon Sep 17 00:00:00 2001 From: n Date: Fri, 18 Sep 2026 22:41:56 +0000 Subject: [PATCH 120/446] test(renderer): pin master-parity for malformed trailing-text table rows Round-3 gate finding: the downstream table regex lacks an end-of-line anchor, so it accepts a prefix that the physical-line guard rejects (e.g. '| h
x | n |\n|---|---| trailing'). Measured against MASTER rather than against the intermediate commit: master does not render that input as a table either, so the stage is at parity, not regressed. Across the edge-case matrix (indented, CRLF, trailing-space, padded separator, blank-line delimited, mid-document) the stage is equal to master on the two malformed inputs and strictly better on six genuine tables. Pinned as a regression so any future change to the table guard has to make a deliberate decision about this input instead of changing it silently. --- tests/test_renderer_js_behaviour.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py index 80bfecda..d3e5c3b1 100644 --- a/tests/test_renderer_js_behaviour.py +++ b/tests/test_renderer_js_behaviour.py @@ -1014,3 +1014,19 @@ class TestRendererGateRegressions7618: assert out.count("
") == 3 assert "OAuth
API keys
tokens" in out assert "LRU
60s TTL" in out + + def test_row_with_trailing_text_after_closing_pipe_matches_master(self, driver_path): + """Pins parity with master for a malformed row that has text past the last pipe. + + The downstream table regex lacks an end-of-line anchor, so it accepts a + prefix the physical-line guard rejects. Master does NOT render this as a + table either, so preserving prose here is parity, not a regression. Pinned + so a future table-guard change has to make a deliberate decision about it. + """ + out = _render(driver_path, "| h
x | n |\n|---|---| trailing") + assert "
" not in out + + def test_indented_and_padded_table_still_preserves_br(self, driver_path): + out = _render(driver_path, " | a | b |\n |---|---|\n | x
y | z |") + assert "
" in out + assert "x
y" in out From 8a335d5af202edbdf26c1c0c011ba68c5e7924d5 Mon Sep 17 00:00:00 2001 From: hermes Date: Sat, 19 Sep 2026 00:20:05 +0100 Subject: [PATCH 121/446] fix(webui): scope profile-home resolve cache to one session-list build The process-lifetime cache from the previous commit was flagged in PR review (nesquena-hermes): Path.resolve() is a filesystem call, not a pure function of process-startup constants. A profile-home symlink can be retargeted while the long-running webui process stays up, and _safe_resolve() deliberately falls back to the unresolved input after a transient OSError/RuntimeError/ValueError -- a process-lifetime cache would serve either a stale symlink target or a stale fallback forever. Redesign: replace the module-level dict with a contextvars.ContextVar that defaults to None (no active scope -> resolve fresh, exactly like pre-PR behavior). profile_home_resolve_cache_scope() sets it to a fresh dict for the duration of one call and resets it in a finally block, so no cache entry survives past that call. It now wraps only _load_cli_sessions_uncached as a decorator -- the actual N+1 hot loop (hundreds of Session.__init__ calls per sidebar build, each resolving the same profile argument). All other call sites of _resolve_profile_home_param (confirmed via repo-wide grep: all internal to api/workspace.py, none elsewhere) get fresh resolution on every call, same as before this PR existed. Rewrote tests/test_profile_home_resolve_caching.py to prove: (a) repeated resolves within one scope still hit the cache once, (b) two separate scopes (simulating two requests) do not share state -- a symlink change between them is observed on the second scope, and (c) calls outside any scope never cache at all. Co-Authored-By: Claude Sonnet 5 --- api/models.py | 3 +- api/workspace.py | 70 ++++++++-- tests/test_profile_home_resolve_caching.py | 146 ++++++++++++++++----- 3 files changed, 175 insertions(+), 44 deletions(-) diff --git a/api/models.py b/api/models.py index eca27ab5..270e50b8 100644 --- a/api/models.py +++ b/api/models.py @@ -35,7 +35,7 @@ from api.config import ( LOCK, STREAMS, STREAMS_LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME, get_effective_default_model, _get_session_agent_lock, ) -from api.workspace import get_last_workspace, _resolve_path +from api.workspace import get_last_workspace, _resolve_path, profile_home_resolve_cache_scope from api.usage import prompt_cache_hit_percent from api.agent_sessions import ( _is_continuation_session, @@ -7708,6 +7708,7 @@ def _state_projection_sidecar_metadata(sid: str) -> dict: return dict(metadata) +@profile_home_resolve_cache_scope() def _load_cli_sessions_uncached( hermes_home: Path, db_path: Path, diff --git a/api/workspace.py b/api/workspace.py index ebeaacaa..511aa9f1 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -19,6 +19,8 @@ import stat import subprocess import sys import concurrent.futures +import contextlib +import contextvars import threading import time from collections.abc import Callable @@ -207,7 +209,7 @@ def _is_remote_terminal_backend(terminal_cfg: dict | None) -> bool: return backend not in ('', 'local') -# Per-profile-argument cache for `_resolve_profile_home_param`'s filesystem +# Call-scoped cache for `_resolve_profile_home_param`'s filesystem # `.resolve()` call. The webui's session list builds one `Session` per CLI # session row (`_load_cli_sessions_uncached`), and `Session.__init__` calls # `_resolve_profile_home_param(profile)` once per row -- with only one @@ -217,23 +219,67 @@ def _is_remote_terminal_backend(terminal_cfg: dict | None) -> bool: # host load (confirmed via repeated "Slow WebUI request still running" # warnings pinned to this call site). # -# Caching is safe for the life of the process: the mapping from a profile -# argument to its home path is a pure function of process-startup constants -# (`api.profiles._DEFAULT_HERMES_HOME`, `_INITIAL_HERMES_HOME`) that are -# resolved once at import time and never mutated afterwards. Profile -# *creation* only creates the directory at that already-deterministic path -# -- it does not change what path a given profile argument resolves to -- -# so there is no runtime path that needs to invalidate an entry here. -_PROFILE_HOME_RESOLVE_CACHE: dict[Path, Path] = {} +# IMPORTANT: this is deliberately NOT a process-lifetime cache. An earlier +# revision of this fix cached forever, on the premise that a profile +# argument always resolves to the same path for the life of the process -- +# but `Path.resolve()` is a filesystem call, not a pure function of +# startup constants: a profile-home symlink can be retargeted while the +# webui process keeps running for days/weeks, and `_safe_resolve()` below +# deliberately falls back to returning the UNRESOLVED input after a +# transient `OSError`/`RuntimeError`/`ValueError`, which a process-lifetime +# cache would then serve forever. Both are real correctness bugs (caught in +# PR #7636 review), not style nitpicks. +# +# Instead the cache is scoped to exactly ONE invocation of the hot loop +# (`_load_cli_sessions_uncached`, wrapped below in `profile_home_resolve_cache_scope`) +# via a `ContextVar` that holds a fresh dict only while that single +# sidebar-list build is running, and is `None` everywhere else. Every other +# call site -- and every request that isn't actively inside that one +# build -- resolves fresh every time, exactly like pre-PR behavior, so a +# symlink retarget or a transient resolve error is never masked past the +# single call that would have paid for the redundant resolves anyway. +_PROFILE_HOME_RESOLVE_SCOPE: "contextvars.ContextVar[dict[Path, Path] | None]" = ( + contextvars.ContextVar("_PROFILE_HOME_RESOLVE_SCOPE", default=None) +) + + +@contextlib.contextmanager +def profile_home_resolve_cache_scope(): + """Enable memoization of `_resolve_profile_home_param` for one call. + + Use as a decorator (or a `with` block) around the single hot-path + operation that resolves the SAME profile argument hundreds of times in a + tight loop -- currently `_load_cli_sessions_uncached` building the + CLI/cron session list. While active, repeated resolves of the same + profile argument are served from a dict that lives ONLY for the + duration of the call. Outside of it -- including before/after, and any + call site other than the one wrapped -- `_resolve_profile_home_param` + resolves fresh every time, with no caching, matching pre-PR behavior + exactly. Nested/reentrant use restores the previous (outer) value on + exit rather than clobbering it, so this is safe to nest. + """ + token = _PROFILE_HOME_RESOLVE_SCOPE.set({}) + try: + yield + finally: + _PROFILE_HOME_RESOLVE_SCOPE.reset(token) def _cached_safe_resolve_profile_home(pre_resolve: Path) -> Path: - """Memoized `_safe_resolve()` for profile-home paths (see cache doc above).""" - cached = _PROFILE_HOME_RESOLVE_CACHE.get(pre_resolve) + """`_safe_resolve()` for profile-home paths. + + Memoized only while a `profile_home_resolve_cache_scope()` is active + (see module doc above); otherwise resolves fresh every call, matching + pre-PR behavior. + """ + cache = _PROFILE_HOME_RESOLVE_SCOPE.get() + if cache is None: + return _safe_resolve(pre_resolve) + cached = cache.get(pre_resolve) if cached is not None: return cached resolved = _safe_resolve(pre_resolve) - _PROFILE_HOME_RESOLVE_CACHE[pre_resolve] = resolved + cache[pre_resolve] = resolved return resolved diff --git a/tests/test_profile_home_resolve_caching.py b/tests/test_profile_home_resolve_caching.py index ccc8f63f..89836230 100644 --- a/tests/test_profile_home_resolve_caching.py +++ b/tests/test_profile_home_resolve_caching.py @@ -1,12 +1,29 @@ """Regression test: `_resolve_profile_home_param` memoizes its filesystem -`.resolve()` call per profile argument (N+1 sidebar-hang fix). +`.resolve()` call, but ONLY for the duration of a single +`profile_home_resolve_cache_scope()` (currently wrapping +`_load_cli_sessions_uncached`, the sidebar's session-list build). Building the CLI session list calls `_resolve_profile_home_param(profile)` once per session row (`Session.__init__`), so with hundreds of sessions on -the same profile the old code redundantly re-resolved the identical path via -a filesystem `.resolve()` on every single row. This asserts repeated calls -with the same profile argument return an equal Path while invoking the -underlying `_safe_resolve()` filesystem call only once. +the same profile the unscoped code redundantly re-resolved the identical +path via a filesystem `.resolve()` on every single row. + +An earlier revision of this fix cached the result in a plain process-lifetime +module dict. That was flagged in PR #7636 review: `Path.resolve()` is a +filesystem call, not a pure function of process-startup constants -- a +profile-home symlink can be retargeted while the webui process keeps +running, and `_safe_resolve()` deliberately falls back to the UNRESOLVED +input after a transient error, so a process-lifetime cache could serve a +stale or wrong path for the rest of the process's life. + +This file tests the corrected, call-scoped design: + (a) within one scope, repeated resolves of the same argument hit the cache + (single underlying `_safe_resolve()` call) -- same benefit as before. + (b) across two SEPARATE scopes (simulating two different requests/list + builds), the cache does NOT persist -- `_safe_resolve()` runs again + fresh on the second scope, proving no cross-request staleness. + (c) outside any scope, `_resolve_profile_home_param` resolves fresh every + time with no caching at all, matching pre-PR behavior exactly. """ import sys from pathlib import Path @@ -17,8 +34,84 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import api.workspace as ws # noqa: E402 -def test_resolve_profile_home_param_caches_repeated_path_argument(tmp_path): - ws._PROFILE_HOME_RESOLVE_CACHE.clear() +def test_resolve_profile_home_param_caches_within_one_scope(tmp_path): + """(a) Same as the original test, but the cache now requires an active scope.""" + home_dir = tmp_path / "profile_home" + home_dir.mkdir() + + with patch.object(ws, "_safe_resolve", wraps=ws._safe_resolve) as spy: + with ws.profile_home_resolve_cache_scope(): + first = ws._resolve_profile_home_param(home_dir) + second = ws._resolve_profile_home_param(home_dir) + third = ws._resolve_profile_home_param(home_dir) + + assert first == second == third == home_dir.resolve() + assert spy.call_count == 1, ( + "expected the filesystem resolve() to run once within one scope and " + f"be served from cache thereafter, but it ran {spy.call_count} times" + ) + + +def test_resolve_profile_home_param_caches_within_one_scope_for_profile_name(tmp_path): + """(a), second branch: a logical profile-NAME STRING (not a Path) resolves + + through `get_hermes_home_for_profile()` before hitting the same + `_cached_safe_resolve_profile_home()` memoization. Confirms the + within-scope cache is genuinely hit for named profiles too, not just + explicit Path callers. + """ + home_dir = tmp_path / "named_profile_home" + home_dir.mkdir() + + with patch("api.profiles.get_hermes_home_for_profile", return_value=home_dir), \ + patch.object(ws, "_safe_resolve", wraps=ws._safe_resolve) as spy: + with ws.profile_home_resolve_cache_scope(): + first = ws._resolve_profile_home_param("myprofile") + second = ws._resolve_profile_home_param("myprofile") + third = ws._resolve_profile_home_param("myprofile") + + assert first == second == third == home_dir.resolve() + assert spy.call_count == 1, ( + "expected the filesystem resolve() to run once within one scope and " + f"be served from cache thereafter, but it ran {spy.call_count} times" + ) + + +def test_cache_does_not_persist_across_separate_scopes(tmp_path): + """(b) Two separate scoped calls (simulating two different requests) must + + NOT share a cache -- the whole point of the PR #7636 review fix. If a + symlink were retargeted between the two "requests", the second scope + must be free to observe the new target; that's only possible if + `_safe_resolve()` is invoked again fresh on scope #2. + """ + home_dir = tmp_path / "profile_home" + home_dir.mkdir() + + with patch.object(ws, "_safe_resolve", wraps=ws._safe_resolve) as spy: + with ws.profile_home_resolve_cache_scope(): + ws._resolve_profile_home_param(home_dir) + ws._resolve_profile_home_param(home_dir) + assert spy.call_count == 1 + + # Second, separate scope -- simulates a later, independent request. + with ws.profile_home_resolve_cache_scope(): + ws._resolve_profile_home_param(home_dir) + ws._resolve_profile_home_param(home_dir) + + assert spy.call_count == 2, ( + "expected the second scope to re-resolve fresh (no cross-scope/" + f"cross-request caching), but _safe_resolve ran {spy.call_count} times total" + ) + + +def test_no_caching_outside_any_scope(tmp_path): + """(c) Outside a scope, every call resolves fresh -- matching pre-PR + + behavior exactly, since most call sites of `_resolve_profile_home_param` + are NOT the sidebar list-build hot loop and must never see a cached + (possibly stale) result. + """ home_dir = tmp_path / "profile_home" home_dir.mkdir() @@ -28,34 +121,25 @@ def test_resolve_profile_home_param_caches_repeated_path_argument(tmp_path): third = ws._resolve_profile_home_param(home_dir) assert first == second == third == home_dir.resolve() - assert spy.call_count == 1, ( - "expected the filesystem resolve() to run once and be served from " - f"cache thereafter, but it ran {spy.call_count} times" + assert spy.call_count == 3, ( + "expected every call outside an active scope to resolve fresh with " + f"no caching, but _safe_resolve ran {spy.call_count} times for 3 calls" ) -def test_resolve_profile_home_param_caches_repeated_profile_name_argument(tmp_path): - """Same fix, second branch: a logical profile-NAME STRING (not a Path) +def test_scope_decorator_use_matches_load_cli_sessions_uncached_wiring(): + """Sanity check that `profile_home_resolve_cache_scope()` is usable as a - resolves through `get_hermes_home_for_profile()` before hitting the same - `_cached_safe_resolve_profile_home()` memoization. This mirrors the - Path-argument test above but drives the string branch (workspace.py's - ``isinstance(profile, Path)`` check is False), confirming the cache is - genuinely hit -- not just for explicit Path callers -- and that the - resolved home is correct. + decorator (the way it wraps `_load_cli_sessions_uncached` in + `api/models.py`), and that the ContextVar is active only during the + decorated call. """ - ws._PROFILE_HOME_RESOLVE_CACHE.clear() - home_dir = tmp_path / "named_profile_home" - home_dir.mkdir() + assert ws._PROFILE_HOME_RESOLVE_SCOPE.get() is None - with patch("api.profiles.get_hermes_home_for_profile", return_value=home_dir), \ - patch.object(ws, "_safe_resolve", wraps=ws._safe_resolve) as spy: - first = ws._resolve_profile_home_param("myprofile") - second = ws._resolve_profile_home_param("myprofile") - third = ws._resolve_profile_home_param("myprofile") + @ws.profile_home_resolve_cache_scope() + def _inside(): + return ws._PROFILE_HOME_RESOLVE_SCOPE.get() - assert first == second == third == home_dir.resolve() - assert spy.call_count == 1, ( - "expected the filesystem resolve() to run once and be served from " - f"cache thereafter, but it ran {spy.call_count} times" - ) + cache_during_call = _inside() + assert cache_during_call is not None + assert ws._PROFILE_HOME_RESOLVE_SCOPE.get() is None From 11faed37683ba08839a5226f6439524dc5b24d44 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Fri, 18 Sep 2026 23:40:27 +0000 Subject: [PATCH 122/446] test(sqlite): assert the capped candidate window behaviourally, not via source text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the remaining source-text assertions in test_issue2628 (the Greptile P2 thread): the pre-aggregation shape, index detection, correlated seek and pure-read contracts are already pinned behaviourally by EXPLAIN QUERY PLAN, result equivalence and PRAGMA index_list in test_pr7445_readonly_review_followups.py, test_state_db_readonly_readers.py and test_issue3887_index_prime.py. The one contract only source text covered — the SQL candidate window is capped and oversampled (limit * 8) so post-projection filtering cannot starve the visible page — is now asserted behaviourally instead: * the traced statement SQLite actually runs carries a single LIMIT whose bound is the oversampled cap (limit * 8 = 160), applied inside the candidate subquery the outer projection joins; * a page still fills to limit when the 30 newest candidates are invisible CLI rows (ended, default title): they consume candidates, not result slots. Verified to fail (0 rows) against the old non-oversampled shape. --- tests/test_issue2628_cli_sessions_perf.py | 80 +++++++++++++++++++---- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/tests/test_issue2628_cli_sessions_perf.py b/tests/test_issue2628_cli_sessions_perf.py index 950a6028..6f31f031 100644 --- a/tests/test_issue2628_cli_sessions_perf.py +++ b/tests/test_issue2628_cli_sessions_perf.py @@ -1,15 +1,14 @@ """Regression coverage for capped CLI/agent session sidebar scans (#2628).""" -import pathlib import sqlite3 import time +from contextlib import contextmanager import pytest import api.agent_sessions as agent_sessions _REAL_SQLITE_CONNECT = sqlite3.connect -REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] def _make_state_db(path, *, sessions=80, messages_per_session=3, create_messages_index=True, source="cli", session_source="cli"): @@ -169,6 +168,24 @@ def _make_connect_with_progress_budget(*, budget_ops, interval=1): return _connect +@contextmanager +def _capture_listing_sql(): + """Record the SQL statements the listing runs (trace callback, read-only).""" + statements = [] + + def _tracing_connect(*args, **kwargs): + conn = _REAL_SQLITE_CONNECT(*args, **kwargs) + conn.set_trace_callback(statements.append) + return conn + + original_connect = agent_sessions.sqlite3.connect + agent_sessions.sqlite3.connect = _tracing_connect + try: + yield statements + finally: + agent_sessions.sqlite3.connect = original_connect + + def _make_connect_with_progress_counter(*, interval=1): steps = {"count": 0} @@ -198,22 +215,59 @@ def test_importable_agent_rows_push_sidebar_limit_into_sql(tmp_path): db = tmp_path / "state.db" _make_state_db(db, sessions=120, messages_per_session=5) - rows = agent_sessions.read_importable_agent_session_rows(db, limit=20, exclude_sources=("webui",)) + with _capture_listing_sql() as statements: + rows = agent_sessions.read_importable_agent_session_rows(db, limit=20, exclude_sources=("webui",)) assert len(rows) == 20 assert [row["id"] for row in rows][:3] == ["cli_perf_0119", "cli_perf_0118", "cli_perf_0117"] assert {row["actual_message_count"] for row in rows} == {5} - src = (REPO_ROOT / "api" / "agent_sessions.py").read_text() - assert "WITH candidates AS" in src - assert "JOIN candidates c ON c.id = s.id" in src - assert "latest_messages AS" in src - assert "LEFT JOIN latest_messages lm ON lm.session_id = s.id" in src - assert "not messages_index_present" in src - assert "PRAGMA index_list(messages)" in src - assert "_CRON_PREAGGREGATE_CANDIDATE_ORDER_MIN_MESSAGES" not in src - assert "MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id" in src - assert "candidate_limit = max(result_limit * 8, result_limit)" in src + candidates_sql = next( + statement + for statement in statements + if "LIMIT" in statement and "FROM sessions s" in statement + ) + # The cap is applied inside the candidate subquery, not in Python after an + # unbounded aggregate: the oversampled window (limit * 8 = 160 here) is + # part of the statement SQLite actually runs, and it bounds the candidate + # subquery that the outer projection joins. + assert candidates_sql.count("LIMIT") == 1, candidates_sql + window_end = candidates_sql.index("LIMIT") + window = candidates_sql[:window_end] + assert "JOIN candidates" not in window, candidates_sql + cap = int(candidates_sql[window_end:].split(None, 2)[1]) + assert cap == 160, candidates_sql + + +def test_importable_agent_rows_candidate_window_overfilters_then_fills(tmp_path): + """Rows dropped after projection must not shrink the returned page. + + The SQL candidate window is oversampled (limit * 8) precisely so sessions + filtered client-side of the SQL (invisible CLI rows here) only consume + candidates, not result slots: a page that would be short if the cap were + the plain ``limit`` still fills to ``limit`` visible rows. + """ + db = tmp_path / "state.db" + _make_state_db(db, sessions=120, messages_per_session=5) + + # 30 newest sessions are CLI rows that the projection hides (ended, default + # title, no user turn visible): they must fall out of the window without + # starving the visible page below them. + conn = sqlite3.connect(str(db)) + for i in range(90, 120): + conn.execute( + "UPDATE sessions SET title = 'cli session', ended_at = 1.0, end_reason = 'timeout' WHERE id = ?", + (f"cli_perf_{i:04d}",), + ) + conn.commit() + conn.close() + + rows = agent_sessions.read_importable_agent_session_rows(db, limit=20, exclude_sources=("webui",)) + + assert len(rows) == 20 + assert rows[0]["id"] == "cli_perf_0089" + assert rows[-1]["id"] == "cli_perf_0070" + assert {row["actual_message_count"] for row in rows} == {5} def test_importable_agent_rows_candidate_ordering_stays_under_progress_budget(tmp_path, monkeypatch): From 770174f8fe69b504c16ad7c81457eb252d1e6968 Mon Sep 17 00:00:00 2001 From: n Date: Fri, 18 Sep 2026 23:41:51 +0000 Subject: [PATCH 123/446] docs(changelog): stamp #7618 renderer + #7619 Extensions i18n --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 671f4e9f..30b0e58f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Markdown tables survive a `
` in a cell, and spaced asterisks stop turning italic.** Models routinely emit `
` inside a table cell to stack values on separate lines. `renderMd()` converted every `
` to a newline *before* the table parser ran, so the row split in half: the table rendered with a stub first cell and the remaining rows spilled out underneath as raw pipe text. Separately, the inline emphasis rule matched across spaces, so ordinary prose like `2 * 3 * 4 = 24` rendered as `2 3 4`. `
` is now converted only outside genuine table blocks — classified with the same grammar the table parser itself uses (a run of pipe-lines whose second line is a separator), so pipe-delimited prose such as `| note
# heading |` keeps its heading and list rendering — and emphasis no longer matches a leading or trailing space. Hardened during review: the first implementation used a fixed internal placeholder that user text could contain (corrupting prose and breaking link anchors), and its stricter emphasis rule silently dropped ` spaced `; both are fixed, with the sanitizer still stripping every attribute from a preserved `
`. Thanks @JayaMegelar. (#7618) +- **The Extensions settings pane is localized.** Gallery, Installed and Diagnostics were the last large user-facing surface still rendering hardcoded English. Every visible string in the pane now routes through `t()`, with translations supplied across all 15 shipped locales; each referenced key is present in every locale block, so no string can fall back to a raw key name. Extension names and author-supplied descriptions stay as written — they are third-party metadata, not UI chrome. Thanks @Yularzhi. (#7619, #7581) - **`/skills pending` and the other write-approval subcommands reach the agent again.** The WebUI's local `/skills` handler swallowed every argument into its own skill-name search, so the agent-owned write-approval subcommands never reached their handler — `/skills pending`, `/skills approve 3`, `/skills diff` and friends silently ran a name search instead of doing anything. Those subcommands now fall through to the normal send path using the existing opt-out contract that `/reasoning` already uses. Covers every alias the agent handler accepts, including `apply`, `deny` and `drop`, which a first pass missed. Thanks @totalitarian. (#7623) - **Disabled skills are no longer offered by the slash-command picker.** Skills in `skills.disabled` are already excluded from the backend skill-command map, but the WebUI listed every `/api/skills` entry regardless — so a disabled skill stayed selectable in both `/use ` sub-args and the `/`-prefix skill suggestions. Both surfaces now share one filter. Switching profiles is also fenced: an `/api/skills` reply still in flight when the switch lands can no longer commit, so a payload generated for the outgoing profile cannot keep hiding a skill that is enabled in the new one. Separately, a transient `/api/skills` failure no longer wedges the picker — readiness was previously published even when the request rejected, which left an empty cache marked authoritative and stopped the composer from ever retrying until a page reload. Thanks @webtecnica. (#7511, #7509) From 93814b9629491eeb86b15e9c3ec98282cb951e52 Mon Sep 17 00:00:00 2001 From: hermes Date: Sat, 19 Sep 2026 00:43:06 +0100 Subject: [PATCH 124/446] fix(webui): use the memoized workspace closure in the webhook chip pass The webhook second-pass loop in _load_cli_sessions_uncached called get_last_workspace(profile=_cli_profile) directly per row instead of the _cli_workspace() closure the main/cron/kanban passes already use, so it paid its own redundant file reads/is_dir() probe on every webhook row even though the loop already sits inside the profile-home resolve cache scope. Flagged in PR #7636 review (greptile): this is the one concrete gap in an otherwise-correct fix. Also adds an integration-level regression test proving the actual N+1 this PR targets -- Session.__init__ constructed via _state_projection_sidecar_metadata's Session.load_metadata_only() fallback on a cold sidecar-metadata cache -- is deduped by @profile_home_resolve_cache_scope() on _load_cli_sessions_uncached, with a companion test showing the same fixture scales linearly (one filesystem resolve per row) when the scope is bypassed. This directly answers the review claim that the cache scope doesn't reach any Session construction: it does, verified by counting real _safe_resolve() filesystem calls rather than call counts on the memoizing wrapper alone. Co-Authored-By: Claude Sonnet 5 --- api/models.py | 2 +- tests/test_profile_home_resolve_caching.py | 204 +++++++++++++++++++++ 2 files changed, 205 insertions(+), 1 deletion(-) diff --git a/api/models.py b/api/models.py index 270e50b8..43ae4720 100644 --- a/api/models.py +++ b/api/models.py @@ -8006,7 +8006,7 @@ def _load_cli_sessions_uncached( cli_sessions.append({ 'session_id': sid, 'title': _display_title, - 'workspace': str(get_last_workspace(profile=_cli_profile)), + 'workspace': _cli_workspace(), 'model': row['model'] or None, 'message_count': row['message_count'] or row['actual_message_count'] or 0, 'created_at': row['started_at'], diff --git a/tests/test_profile_home_resolve_caching.py b/tests/test_profile_home_resolve_caching.py index 89836230..b41cc531 100644 --- a/tests/test_profile_home_resolve_caching.py +++ b/tests/test_profile_home_resolve_caching.py @@ -32,6 +32,7 @@ from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import api.workspace as ws # noqa: E402 +import api.models as models # noqa: E402 def test_resolve_profile_home_param_caches_within_one_scope(tmp_path): @@ -143,3 +144,206 @@ def test_scope_decorator_use_matches_load_cli_sessions_uncached_wiring(): cache_during_call = _inside() assert cache_during_call is not None assert ws._PROFILE_HOME_RESOLVE_SCOPE.get() is None + + +def _make_cli_sessions_uncached_fixture(tmp_path, n, *, message_count=3): + """state.db + matching MODERN WebUI sidecars (message_count present in the + prefix, literal 'default' profile string -- exactly what real production + sidecars carry, confirmed against a live deployment's webui/sessions/*.json) + for `n` cron rows. + + A "modern" sidecar (one that carries `message_count` in its JSON-prefix) + is required to reach the fast branch of `Session.load_metadata_only()` + (a single `Session(**parsed)` construction) instead of falling back to the + legacy `cls.load()` re-parse path -- see #5854. That keeps this fixture + isolated to the exact N+1 call shape under test: one `Session.__init__` + (and therefore one `_resolve_profile_home_param` call) per sidecar row. + """ + import sqlite3 + import time + + db_path = tmp_path / "state.db" + conn = sqlite3.connect(str(db_path)) + conn.executescript( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT, + session_source TEXT, + title TEXT, + model TEXT, + started_at REAL NOT NULL, + message_count INTEGER DEFAULT 0, + parent_session_id TEXT, + ended_at REAL, + end_reason TEXT + ); + CREATE INDEX idx_sessions_started ON sessions(started_at); + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + session_id TEXT, + role TEXT, + content TEXT, + timestamp REAL + ); + CREATE INDEX idx_messages_session ON messages(session_id, timestamp); + """ + ) + now = time.time() + sids = [] + for i in range(n): + sid = f"cron_job{i:04d}_{int(now) + i}" + sids.append(sid) + conn.execute( + "INSERT INTO sessions (id, source, session_source, title, model," + " started_at, message_count, parent_session_id, ended_at, end_reason)" + " VALUES (?, 'cron', 'cron', NULL, 'deepseek/deepseek-chat', ?, 1," + " NULL, NULL, NULL)", + (sid, now + i), + ) + conn.execute( + "INSERT INTO messages (id, session_id, role, content, timestamp)" + " VALUES (?, ?, 'user', 'cron task', ?)", + (f"cron_msg_{sid}", sid, now + i), + ) + conn.commit() + conn.close() + + session_dir = tmp_path / "sessions" + session_dir.mkdir() + for sid in sids: + (session_dir / f"{sid}.json").write_text( + '{"session_id": "%s", "title": "Renamed %s", "created_at": 1.0,' + ' "updated_at": 2.0, "archived": false, "message_count": %d,' + ' "profile": "default", "messages": []}' + % (sid, sid, message_count), + encoding="utf-8", + ) + return db_path, sids + + +def _count_profile_home_resolve_calls(call_args_list, expected_profile_home): + """Filter a `_safe_resolve` spy's call list down to just the profile-home + + branch (`pre_resolve == expected_profile_home`), excluding the unrelated + per-row `_safe_resolve()` call that resolves the session's `workspace` + path (same call count either way -- not part of this PR, not what + greptile's comment or the production stack trace was about). + """ + return sum( + 1 for call in call_args_list + if call.args and call.args[0] == expected_profile_home + ) + + +def test_load_cli_sessions_uncached_dedupes_profile_home_resolve_on_sidecar_cache_miss(tmp_path): + """Real N+1 reproduction for PR #7636 review comment (greptile, + api/models.py:7711): on a COLD `_SIDECAR_METADATA_CACHE` (fresh process, + or the first time each sid's sidecar is seen), `_state_projection_sidecar_metadata` + falls through to `Session.load_metadata_only(sid)` for EVERY row that has a + sidecar -- and that constructs a real `Session` via `Session.__init__`, + which resolves the session's `profile` param through + `_resolve_path -> _remote_terminal_workspace_candidate -> + _remote_terminal_cwd -> _resolve_profile_home_param -> _safe_resolve()`, + matching the exact stack trace captured in production + ("Slow WebUI request still running", 5-17s, pinned to this call site). + Real production sidecars carry the literal string "default" for `profile` + (confirmed against a live deployment's webui/sessions/*.json), which is + what this fixture uses too. + + This is a DIFFERENT code path than the direct dict-projection rows + greptile's comment focused on -- it fires from the sidecar-metadata + overlay, not from the row-to-dict projection itself. It IS covered by + `@profile_home_resolve_cache_scope()` on `_load_cli_sessions_uncached`, + because contextvars propagate through ordinary synchronous nested calls + (no thread pool / executor / asyncio task is involved anywhere in this + call chain) -- proven here by counting only the profile-home-resolving + `_safe_resolve()` invocations (filtered by argument), not the session's + unrelated per-row workspace-path resolve, which this PR never touched and + still runs once per row regardless. + """ + from unittest import mock + + import api.profiles as profiles + import api.workspace as ws + + models.clear_sidecar_metadata_cache() + n = 60 + db_path, sids = _make_cli_sessions_uncached_fixture(tmp_path, n) + + with ( + mock.patch("api.models.get_claude_code_sessions", return_value=[]), + mock.patch("api.models.get_last_workspace", return_value=str(tmp_path)), + mock.patch("api.models.ensure_cron_project", return_value="cron-pid"), + mock.patch("api.models.SESSION_DIR", tmp_path / "sessions"), + # Isolate the measurement: `_remote_terminal_cwd()` also calls + # `get_config_for_profile_home()` right after `_resolve_profile_home_param()`, + # and THAT helper does its own two direct, uncached `_safe_resolve()` calls + # on the same profile-home path (api/config.py, a pre-existing, separate + # hot path this PR does not touch and greptile's comment did not raise). + # Left unmocked those calls alias onto the same argument value and would + # make an unrelated inefficiency look like a failure of THIS cache. + mock.patch("api.config.get_config_for_profile_home", return_value={}), + mock.patch.object(ws, "_safe_resolve", wraps=ws._safe_resolve) as safe_resolve_spy, + ): + result = models._load_cli_sessions_uncached(tmp_path, db_path, None) + + assert len(result) == n + profile_home_resolves = _count_profile_home_resolve_calls( + safe_resolve_spy.call_args_list, profiles._DEFAULT_HERMES_HOME + ) + # Every row's sidecar was a genuine cache miss (fresh clear above), so + # Session.load_metadata_only() ran -- and therefore Session.__init__() ran + # -- once per row: len(sids) separate `_resolve_profile_home_param("default")` + # calls. Without the fix each of those pays its own `_safe_resolve()` + # filesystem call; with the fix active only the FIRST one does, and the + # remaining len(sids) - 1 are served from the call-scoped cache. + assert profile_home_resolves == 1, ( + f"the profile-home _safe_resolve() ran {profile_home_resolves} times across " + f"{n} sidecar-miss rows sharing one profile -- expected exactly 1 (the rest " + "should be served from the call-scoped cache)" + ) + + +def test_load_cli_sessions_uncached_profile_home_resolve_scales_with_fix_disabled(tmp_path): + """Companion to the test above: calling the UNDECORATED function directly + + (bypassing `profile_home_resolve_cache_scope()` via `__wrapped__`, i.e. + reproducing pre-PR behavior) shows the real filesystem resolve DOES scale + linearly with row count -- confirming the dedup measured above is the + scope's doing, not an artifact of some other cache. + """ + from unittest import mock + + import api.workspace as ws + + models.clear_sidecar_metadata_cache() + n = 60 + db_path, sids = _make_cli_sessions_uncached_fixture(tmp_path, n) + + with ( + mock.patch("api.models.get_claude_code_sessions", return_value=[]), + mock.patch("api.models.get_last_workspace", return_value=str(tmp_path)), + mock.patch("api.models.ensure_cron_project", return_value="cron-pid"), + mock.patch("api.models.SESSION_DIR", tmp_path / "sessions"), + # Same isolation as the test above -- see its comment. + mock.patch("api.config.get_config_for_profile_home", return_value={}), + mock.patch.object(ws, "_cached_safe_resolve_profile_home", wraps=ws._cached_safe_resolve_profile_home) as memo_spy, + mock.patch.object(ws, "_safe_resolve", wraps=ws._safe_resolve) as safe_resolve_spy, + ): + undecorated = models._load_cli_sessions_uncached.__wrapped__ + result = undecorated(tmp_path, db_path, None) + + assert len(result) == n + # The memoizing function is still called once per row either way... + assert memo_spy.call_count == n + # ...but with no active scope it never hits its cache branch, so every + # single call pays its own real filesystem resolve. + import api.profiles as profiles + profile_home_resolves = _count_profile_home_resolve_calls( + safe_resolve_spy.call_args_list, profiles._DEFAULT_HERMES_HOME + ) + assert profile_home_resolves == n, ( + f"expected the profile-home _safe_resolve() to run once per row ({n}) with no " + f"active cache scope, but it ran {profile_home_resolves} times" + ) From d544a12ec8435b09b813f7e667d9220f46d46cc3 Mon Sep 17 00:00:00 2001 From: hermes Date: Sat, 19 Sep 2026 00:50:33 +0100 Subject: [PATCH 125/446] docs(architecture): document the profile-home resolve call-scoped cache PR #7636 review (greptile-apps) flagged the profile-home resolve cache scoping (profile_home_resolve_cache_scope() / _PROFILE_HOME_RESOLVE_SCOPE in api/workspace.py) as an undocumented runtime contract per AGENTS.md. Record what is cached, the call-scoped (not process-lifetime) lifecycle, which single call site owns it while every other caller resolves fresh, and the freshness guarantee this gives against symlink retargets and transient resolve fallbacks; index the document from docs/CONTRACTS.md, following the same pattern used for the /api/models cache invalidation contract. Docs only. Co-Authored-By: Claude Sonnet 5 --- docs/CONTRACTS.md | 8 ++ .../profile-home-resolve-cache.md | 87 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 docs/architecture/profile-home-resolve-cache.md diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 46f66fa7..2989b587 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -89,6 +89,14 @@ contributor guidance; it does not change runtime behavior or CI gates. `models_cache.json` refresh timestamps), the stat-identity fallbacks, and the schema/version stamps. Start here before changing model-catalog caching, the `/api/models` cache keys, or the Codex catalog dependency (#2443, #7540). +- [`docs/architecture/profile-home-resolve-cache.md`](architecture/profile-home-resolve-cache.md): + current contract for the call-scoped memoization around + `_resolve_profile_home_param()` in `api/workspace.py`: what it caches, the + one-hot-loop scope (`_load_cli_sessions_uncached`) versus every other call + site getting fresh resolution, and the freshness guarantee that a symlink + retarget or transient resolve fallback is always observed on the next call. + Start here before widening this cache's scope or adding a similar + call-scoped cache elsewhere (#7636). When a change touches streaming, recovery, replay, compression, context reconstruction, cancellation, approval/clarify, session metadata, or run state, diff --git a/docs/architecture/profile-home-resolve-cache.md b/docs/architecture/profile-home-resolve-cache.md new file mode 100644 index 00000000..8ed97e58 --- /dev/null +++ b/docs/architecture/profile-home-resolve-cache.md @@ -0,0 +1,87 @@ +# Profile-home resolve call-scoped cache (`api/workspace.py`) + +This document records the current scope, ownership, and freshness contract for +the call-scoped memoization added around `_resolve_profile_home_param()` in +`api/workspace.py`. It describes shipped behavior and changes no runtime +behavior. It was added after PR #7636 review flagged this cache lifecycle as +an undocumented runtime contract. + +## What is cached + +- **What:** the resolved `Path` returned by `_safe_resolve()` for a given + pre-resolve profile-home `Path`, inside `_cached_safe_resolve_profile_home()`. +- **Key:** the unresolved profile-home `Path` passed in. +- **Store:** a plain `dict[Path, Path]` held by the + `_PROFILE_HOME_RESOLVE_SCOPE` `contextvars.ContextVar`, which defaults to + `None` (no active scope). + +## Scope: one call, not the process lifetime + +The cache is deliberately **call-scoped**, not process-lifetime. It exists only +for the duration of one wrapped call to `_load_cli_sessions_uncached` — the +CLI/cron sidebar session-list build that resolves the same profile argument +once per `Session` row (hundreds of times per request in the common case, +which is the N+1 this cache fixes). `profile_home_resolve_cache_scope()` sets +the `ContextVar` to a fresh `{}` on entry and resets it to the previous value +in a `finally` block on exit, so no cache entry survives past the single call +it was created for. An earlier revision of this fix cached for the life of the +process; that was reverted in review (see #7636) because `Path.resolve()` is a +filesystem call, not a pure function of process-startup constants. + +## Ownership: which call sites get caching + +- `profile_home_resolve_cache_scope()` is applied, as a decorator, to exactly + one call site today: `_load_cli_sessions_uncached` in `api/models.py`. +- Every other caller of `_resolve_profile_home_param()` / + `_cached_safe_resolve_profile_home()` — and every call to those functions + from outside an active scope, including calls that happen before or after + the one wrapped call — gets **no caching**: `_PROFILE_HOME_RESOLVE_SCOPE.get()` + returns `None`, and `_cached_safe_resolve_profile_home()` falls straight + through to a fresh `_safe_resolve()` call, exactly matching pre-PR behavior. +- Nested/reentrant use of `profile_home_resolve_cache_scope()` restores the + previous (outer) value on exit rather than clobbering it, so nesting is safe + but still never leaks a cache entry past the scope that created it. + +## Freshness guarantee + +Because the cache is call-scoped and discarded in `finally` after each use, a +profile-home symlink retarget or a transient `_safe_resolve()` fallback +(`OSError`/`RuntimeError`/`ValueError`, which deliberately returns the +unresolved input) is always observed fresh on the **next** call — the cache +never outlives the call that populated it. The only staleness window is +*within* a single call, i.e. across the hundreds of `Session` rows built by one +`_load_cli_sessions_uncached` invocation — which is correct and intended, since +nothing about a profile's home path should change mid-call. + +## Change protocol + +1. If a new hot loop needs this memoization, wrap it with + `profile_home_resolve_cache_scope()` (as a decorator or a `with` block) at + its own call boundary — do not widen the existing scope on + `_load_cli_sessions_uncached` to also cover unrelated code. +2. Do not turn this into a process-lifetime or otherwise longer-lived cache. + The scoping is the fix for the correctness bugs described above, not an + incidental detail. +3. This is a runtime contract: changes here update this document and are + described in the PR body. + +## Tests + +`tests/test_profile_home_resolve_caching.py` covers: repeated resolves within +one scope hit the cache once +(`test_resolve_profile_home_param_caches_within_one_scope`, +`test_resolve_profile_home_param_caches_within_one_scope_for_profile_name`); +two separate scopes never share state, so a symlink change between them is +observed on the second scope +(`test_cache_does_not_persist_across_separate_scopes`); calls outside any +scope never cache at all (`test_no_caching_outside_any_scope`); the decorator +is actually wired onto `_load_cli_sessions_uncached` +(`test_scope_decorator_use_matches_load_cli_sessions_uncached_wiring`); and the +N+1 fix itself, both that it dedupes resolves on a cold sidecar-cache miss +(`test_load_cli_sessions_uncached_dedupes_profile_home_resolve_on_sidecar_cache_miss`) +and that resolve calls scale linearly with the fix disabled +(`test_load_cli_sessions_uncached_profile_home_resolve_scales_with_fix_disabled`). + +## References + +PR: #7636. From 34bc4b9cd0b6dc51c2f33ca34d13bbc48a78e6c9 Mon Sep 17 00:00:00 2001 From: George Byers II Date: Fri, 18 Sep 2026 20:35:27 -0400 Subject: [PATCH 126/446] fix: validate MPF JPEG attachments before redaction --- TESTING.md | 18 ++++++ api/helpers.py | 70 ++++++++++++++++++++- tests/fixtures/multipicture.jpg | Bin 0 -> 1372 bytes tests/test_mpf_jpeg_redaction.py | 104 +++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/multipicture.jpg create mode 100644 tests/test_mpf_jpeg_redaction.py diff --git a/TESTING.md b/TESTING.md index 6b2f2862..af111974 100644 --- a/TESTING.md +++ b/TESTING.md @@ -36,6 +36,24 @@ npm run lint:runtime npx eslint --no-config-lookup -c eslint.runtime-guard.config.mjs "static/**/*.js" ``` +## Native raster redaction boundary + +Run `./scripts/test.sh tests/test_mpf_jpeg_redaction.py tests/test_raster_data_uri_redaction.py tests/test_security_redaction.py`. +Native `messages[*].content[*].image_url.url` raster data may bypass text +credential scanning only after complete container validation. MPF JPEGs require +an APP2 MP Index with bounded TIFF entries, contiguous declared JPEG extents, +and a complete SOF/scan/EOI sequence in each image. Gaps, overlaps, malformed +frames, and trailing bytes fall back to text redaction. Image-shaped tool +metadata does not acquire this exemption. This is structural validation, not +pixel decoding or secret detection inside image metadata/pixels. + +`tests/fixtures/multipicture.jpg` contains two Pillow-generated 8x8 solid-color +frames (red and blue), not private photographs; generation instructions live in +`tests/test_mpf_jpeg_redaction.py`. The tests cover both TIFF byte orders and +credential text appended after the final EOI. Helper timing improvements do +not by themselves prove browser or shared-server responsiveness; verify those +separately with a real conversation and concurrent requests. + ## Python lint gate (ruff) — forward-looking, new-code-only The Python twin of the ESLint runtime guard. A curated `ruff` ruleset diff --git a/api/helpers.py b/api/helpers.py index 9a8e7f36..71164cce 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -724,12 +724,59 @@ _JPEG_SOF_MARKERS = { } -def _is_complete_jpeg(raw: bytes) -> bool: +def _mpf_image_ranges(data: bytes, base: int) -> list[tuple[int, int]] | None: + """Read an MP Index TIFF inside APP2; offsets are relative to its header. + + Only the declared JPEG extents are trusted, not a decoder's willingness to + ignore trailing bytes. The caller checks contiguous coverage and each JPEG. + """ + if len(data) < 8 or data[:4] not in {b"II\x2a\x00", b"MM\x00\x2a"}: + return None + order = "little" if data[:2] == b"II" else "big" + + def uint(pos: int, size: int = 4) -> int: + return int.from_bytes(data[pos:pos + size], order) + + ifd = uint(4) + if ifd < 8 or ifd + 2 > len(data): + return None + count = uint(ifd, 2) + table_end = ifd + 2 + 12 * count + if table_end + 4 > len(data): + return None + tags = {} + for pos in range(ifd + 2, table_end, 12): + tag = uint(pos, 2) + if tag in tags: + return None + tags[tag] = (uint(pos + 2, 2), uint(pos + 4), uint(pos + 8)) + if tags.get(0xB001, ())[:2] != (4, 1): + return None + images = tags[0xB001][2] + entry = tags.get(0xB002) + if not entry or images < 2 or entry[:2] != (7, 16 * images): + return None + offset = entry[2] + if offset < table_end + 4 or offset + 16 * images > len(data): + return None + ranges = [] + for index in range(images): + pos = offset + index * 16 + attributes, size, relative = uint(pos), uint(pos + 4), uint(pos + 8) + if attributes & 0x07000000 or size < 4 or (index == 0 and relative != 0): + return None + start = 0 if index == 0 else base + relative + ranges.append((start, start + size)) + return ranges + + +def _is_complete_jpeg(raw: bytes, *, _allow_mpf: bool = True) -> bool: if len(raw) < 4 or raw[:2] != b"\xff\xd8": return False pos = 2 saw_sof = False saw_scan = False + mpf_ranges = None while pos < len(raw): marker_start = pos if raw[pos] != 0xFF: @@ -741,7 +788,20 @@ def _is_complete_jpeg(raw: bytes) -> bool: marker = raw[pos] pos += 1 if marker == 0xD9: - return saw_sof and saw_scan and pos == len(raw) + if not saw_sof or not saw_scan: + return False + if mpf_ranges is None: + return pos == len(raw) + if mpf_ranges[0] != (0, pos): + return False + # No gaps, overlaps, undeclared pictures or post-EOI payloads. + for start, end in mpf_ranges[1:]: + if start != pos or end > len(raw): + return False + if not _is_complete_jpeg(raw[start:end], _allow_mpf=False): + return False + pos = end + return pos == len(raw) if marker in {0x00, 0x01, 0xD8} or 0xD0 <= marker <= 0xD7: return False if pos + 2 > len(raw): @@ -752,6 +812,12 @@ def _is_complete_jpeg(raw: bytes) -> bool: segment_end = pos + segment_length if segment_end > len(raw): return False + if _allow_mpf and marker == 0xE2 and raw[pos + 2:pos + 6] == b"MPF\x00": + if mpf_ranges is not None: + return False + mpf_ranges = _mpf_image_ranges(raw[pos + 6:segment_end], pos + 6) + if mpf_ranges is None: + return False if marker in _JPEG_SOF_MARKERS: if segment_length < 8: return False diff --git a/tests/fixtures/multipicture.jpg b/tests/fixtures/multipicture.jpg new file mode 100644 index 0000000000000000000000000000000000000000..16b2e30aba86f3a9bac57e302ed67e723197a310 GIT binary patch literal 1372 zcmex=wh=DOELf4NWZ*Q!{f5ODks=S2uSLPp{yR(6I1`$f)F$)U@=B z%&g*)(z5c3%Btp;*0%PJ&aO$5r%atTea6gLixw|gx@`H1m8&*w-m-Pu_8mKS9XfpE z=&|D`PM*4S`O4L6*Kgds_3+W-Cr_U}fAR9w$4{TXeEs(Q$Io9Ne=#yJL%anfAwEO% zmmttzOe`$SEbJhEF*20{F|!~GtD+&BkYgZwVxh2-Q6qGeQ$1KQT&+zEQqOPBlzYG7r`5#*nA5D0p U36C}j@8cI', raw, BASE + 4, IFD - BASE) + struct.pack_into('>H', raw, IFD, len(TAGS)) + for tag, pos in TAGS.items(): + kind, count, value = struct.unpack_from('HHI', raw, pos, tag, kind, count) + if tag != 0xB000: # Version is four raw ASCII bytes, not an integer. + struct.pack_into('>I', raw, pos + 8, value) + for pos in range(ENTRIES, ENTRIES + 32, 16): + struct.pack_into('>IIIHH', raw, pos, *struct.unpack_from(' Date: Sat, 19 Sep 2026 12:55:40 +0800 Subject: [PATCH 127/446] 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 047dfa40a54ad5d3957ab76cdaf013edd2b4b50e Mon Sep 17 00:00:00 2001 From: James Drake Date: Sat, 19 Sep 2026 00:29:07 -0500 Subject: [PATCH 128/446] fix(routes): never mask the launch error during cleanup _cleanup_chat_start_launch_failure runs inside the launch-failure handler, but the session re-resolve only caught KeyError. An unreadable or undeserializable session store raises something else, and that escaped the helper, replacing the worker-launch error the caller is about to report and aborting the reset after the registries were already cleared. Contain the resolution-and-reset so the helper cannot raise. The KeyError branch keeps its meaning (deleted session, return without saving); anything else is logged at debug like the existing save() failure path. --- api/routes.py | 52 ++++++++++++------- .../test_issue6869_gateway_launch_failure.py | 34 +++++++++++- 2 files changed, 65 insertions(+), 21 deletions(-) diff --git a/api/routes.py b/api/routes.py index da9537b3..e8054150 100644 --- a/api/routes.py +++ b/api/routes.py @@ -22824,26 +22824,38 @@ def _cleanup_chat_start_launch_failure(session, stream_id: str) -> None: # could resurrect a session deleted while the launch was failing. Same # pattern as the #1533 race fix (routes.py:3077) and the anchor-scene # write guard (routes.py:5140). - with _get_session_agent_lock(session.session_id): - try: - canonical = get_session(session.session_id) - except KeyError: - return # session deleted while the thread launch was failing - if getattr(canonical, "active_stream_id", None) != stream_id: - return # a successor turn already owns the session - canonical.active_stream_id = None - canonical.pending_user_message = None - canonical.pending_attachments = [] - canonical.pending_started_at = None - canonical.pending_user_source = None - try: - canonical.save() - except Exception: - logger.debug( - "Failed to persist chat-start cleanup after worker launch failure for %s", - stream_id, - exc_info=True, - ) + # + # This runs while the original launch failure is being handled, so it must + # never raise. Lock acquisition and session resolution can fail on their own + # (I/O, deserialization), and an escaping error here would mask the launch + # failure the caller is about to report while leaving the reset half done. + try: + with _get_session_agent_lock(session.session_id): + try: + canonical = get_session(session.session_id) + except KeyError: + return # session deleted while the thread launch was failing + if getattr(canonical, "active_stream_id", None) != stream_id: + return # a successor turn already owns the session + canonical.active_stream_id = None + canonical.pending_user_message = None + canonical.pending_attachments = [] + canonical.pending_started_at = None + canonical.pending_user_source = None + try: + canonical.save() + except Exception: + logger.debug( + "Failed to persist chat-start cleanup after worker launch failure for %s", + stream_id, + exc_info=True, + ) + except Exception: + logger.debug( + "Failed to reset session state after worker launch failure for %s", + stream_id, + exc_info=True, + ) def _is_hidden_empty_session(s) -> bool: diff --git a/tests/test_issue6869_gateway_launch_failure.py b/tests/test_issue6869_gateway_launch_failure.py index d4ea0ea0..01563067 100644 --- a/tests/test_issue6869_gateway_launch_failure.py +++ b/tests/test_issue6869_gateway_launch_failure.py @@ -182,4 +182,36 @@ def test_gateway_launch_failure_cleanup_does_not_resurrect_deleted_session(monke assert "old-stream" not in config.STREAMS assert stale.active_stream_id == "old-stream" assert stale.pending_user_message == "pending prompt" - assert saved == [], "cleanup must not save a deleted session" \ No newline at end of file + assert saved == [], "cleanup must not save a deleted session" + + +def test_gateway_launch_failure_cleanup_never_masks_the_launch_error(monkeypatch): + """A resolution failure during cleanup must not replace the launch error. + + Cleanup runs inside the launch-failure handler, so an error it fails to + contain is reported instead of the real launch failure. Resolution can fail + with something other than KeyError when the session store is unreadable or + cannot be deserialized. + """ + saved = [] + + def failing_resolver(sid, metadata_only=False): + raise OSError("session store unreadable") + + monkeypatch.setattr(routes, "get_session", failing_resolver) + stale = _make_session( + "session-launch-resolution-error", + active_stream_id="old-stream", + pending_user_message="pending prompt", + save=lambda: saved.append("stale"), + ) + _register_failed_stream(stale.session_id, "old-stream") + + # Must not raise: the caller is already handling the launch failure. + routes._cleanup_chat_start_launch_failure(stale, "old-stream") + + # The registry half still completed, and nothing was saved. + assert config.session_writeback_owner(stale.session_id) is None + assert "old-stream" not in config.STREAM_SESSION_OWNERS + assert "old-stream" not in config.STREAMS + assert saved == [] \ No newline at end of file From f33ac82ca1c00c2f75c2628cadb7827d96b57d96 Mon Sep 17 00:00:00 2001 From: happy5318 Date: Sat, 19 Sep 2026 13:35:13 +0800 Subject: [PATCH 129/446] 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 1a7b82d41e02bcaac400196e3128572901123e6c Mon Sep 17 00:00:00 2001 From: danpark Date: Sat, 19 Sep 2026 07:26:12 -0400 Subject: [PATCH 130/446] fix: preserve access logs during agent stdout capture (#2684) Own a private stdout descriptor before agent imports and serialize request log writes. Keep logging failures isolated from HTTP responses. Add successful chat/start subprocess delivery regressions and document header-latency semantics and cron ownership. --- api/request_logging.py | 26 ++++++++ docs/troubleshooting.md | 20 +++++++ server.py | 6 +- tests/test_access_log_delivery.py | 92 +++++++++++++++++++++++++++++ tests/test_issue2775_log_request.py | 22 +++++-- 5 files changed, 156 insertions(+), 10 deletions(-) create mode 100644 api/request_logging.py create mode 100644 tests/test_access_log_delivery.py diff --git a/api/request_logging.py b/api/request_logging.py new file mode 100644 index 00000000..df9df817 --- /dev/null +++ b/api/request_logging.py @@ -0,0 +1,26 @@ +"""Request output independent of agent/tool stdout capture.""" + +import os +import threading + + +# Import before agent code. Own the descriptor so replacing or closing Python's +# sys.stdout cannot capture access/error records intended for the service log. +try: + _STREAM = os.fdopen( + os.dup(1), "w", encoding="utf-8", errors="backslashreplace", buffering=1, + ) +except OSError: + _STREAM = None +_LOCK = threading.Lock() + + +def emit_request_log(message: str) -> None: + """Flush one complete record without letting a broken sink break HTTP.""" + try: + if _STREAM is not None: + with _LOCK: + _STREAM.write(message + "\n") + _STREAM.flush() + except Exception: + pass diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f3335f98..35cba162 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -6,6 +6,26 @@ If your symptom isn't listed and the diagnostics don't narrow it down, file a bu --- +## Requests succeed but access records disappear during agent work + +The server emits `[webui]` JSON access records to its original process stdout, +using a private duplicate captured before agent imports. This keeps request and +HTTP error logs visible when an in-process tool redirects or closes `sys.stdout`. +Logging failures still must not interrupt HTTP responses. + +Check the service's captured stdout (for example, `docker logs ` or +the journal for your WebUI unit). Verify successful `POST /api/chat/start` and +`GET /api/chat/stream` records, not only health probes or rejected requests. +Records include `method`, `path`, `status`, and `ms`: elapsed time until response +headers, **not** the lifetime of an SSE stream or the agent turn. A missing record +before response headers does not distinguish a pending request from a logging +failure. Keep the launcher's output destination open for the process lifetime. + +Scheduled cron execution belongs to the Hermes Agent gateway scheduler, not the +WebUI HTTP server. Investigate cron outcomes in the gateway's logs and the +active Hermes home's `cron/executions.db`; WebUI access records show HTTP cron +management requests, not every scheduled execution. + ## "AIAgent not available -- check that hermes-agent is on sys.path" **Symptom.** WebUI starts, shows the chat interface, but every chat request fails immediately with this error in the response or the server log. As of v0.51.6 the error includes a diagnostic block with the running Python interpreter, the relevant `sys.path` entries, and the most-common fix; on older versions the message is bare. diff --git a/server.py b/server.py index 364d5988..040d80c1 100644 --- a/server.py +++ b/server.py @@ -99,6 +99,7 @@ from urllib.parse import urlparse logger = logging.getLogger(__name__) +from api.request_logging import emit_request_log from api.auth import check_auth, reset_trusted_auth_request_state from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE from api.helpers import ( @@ -338,10 +339,7 @@ class Handler(BaseHTTPRequestHandler): @staticmethod def _safe_webui_print(message: str) -> None: """Emit a request log line without letting logging break responses.""" - try: - print(message, flush=True) - except Exception: - pass + emit_request_log(message) def log_request(self, code: str='-', size: str='-') -> None: """Structured JSON logs for each request.""" diff --git a/tests/test_access_log_delivery.py b/tests/test_access_log_delivery.py new file mode 100644 index 00000000..9096d925 --- /dev/null +++ b/tests/test_access_log_delivery.py @@ -0,0 +1,92 @@ +"""Access records must reach the process output, even during agent capture.""" + +import json +import os +from pathlib import Path +import subprocess +import sys +import textwrap + +import pytest + + +@pytest.mark.parametrize("capture", ["redirected", "closed"]) +def test_successful_chat_start_delivers_access_record(tmp_path, capture): + # A subprocess tests the delivered fd text, not an internal counter or a + # patched print. Keep the real chat route and HTTP response/logging stack; + # replace only model discovery and execution with deterministic fixtures. + script = textwrap.dedent(''' + import io + import json + import sys + import threading + import time + from unittest.mock import patch + from server import Handler + from api import routes + from api.models import Session + + handler = Handler.__new__(Handler) + handler.command = "POST" + handler.path = "/api/chat/start" + handler.requestline = "POST /api/chat/start HTTP/1.1" + handler.request_version = "HTTP/1.1" + handler.client_address = ("127.0.0.1", 12345) + handler.headers = {} + handler.wfile = io.BytesIO() + handler._req_t0 = time.time() + session = Session(session_id="log-delivery", model="fixture") + original = sys.stdout + captured = io.StringIO() + if sys.argv[1] == "closed": + captured.close() + + def start_run(session, **kwargs): + # Agent tool capture can redirect stdout from another thread before + # chat/start writes its acceptance response. + def capture_output(): + sys.stdout = captured + worker = threading.Thread(target=capture_output) + worker.start() + worker.join() + return {"stream_id": "log-stream", "session_id": session.session_id} + + try: + with ( + patch.object(routes, "_agent_runtime_barrier_response", return_value=None), + patch.object(routes, "_get_or_materialize_session", return_value=session), + patch.object(routes, "_resolve_chat_workspace_with_recovery", return_value="/tmp"), + patch.object(routes, "_read_profile_model_config", return_value=(None, "fixture", {})), + patch.object(routes, "get_config", return_value={}), + patch.object(routes, "_resolve_compatible_session_model_state", return_value=("fixture", None, False)), + patch.object(routes, "_start_run", side_effect=start_run), + ): + routes._handle_chat_start(handler, {"session_id": session.session_id, "message": "wake"}) + finally: + sys.stdout = original + response = handler.wfile.getvalue() + assert b"HTTP/1.1 200" in response, response + assert json.loads(response.split(b"\\r\\n\\r\\n", 1)[1])["stream_id"] == "log-stream" + ''') + env = { + "PATH": os.environ["PATH"], + "HOME": str(tmp_path), + "HERMES_HOME": str(tmp_path / "home"), + "HERMES_WEBUI_STATE_DIR": str(tmp_path / "state"), + "HERMES_WEBUI_TEST_NETWORK_BLOCK": "1", + } + result = subprocess.run( + [sys.executable, "-c", script, capture], + cwd=Path(__file__).resolve().parents[1], env=env, + capture_output=True, text=True, timeout=30, + ) + assert result.returncode == 0, result.stderr + records = [ + json.loads(line.removeprefix("[webui] ")) + for line in result.stdout.splitlines() if line.startswith("[webui] ") + ] + assert len(records) == 1, f"Successful chat/start lost its access record: {result.stdout!r}" + assert records[0]["method"] == "POST" + assert records[0]["path"] == "/api/chat/start" + assert records[0]["status"] == 200 + assert records[0]["ms"] >= 0 diff --git a/tests/test_issue2775_log_request.py b/tests/test_issue2775_log_request.py index 687c1830..0bfbb0e2 100644 --- a/tests/test_issue2775_log_request.py +++ b/tests/test_issue2775_log_request.py @@ -1,16 +1,26 @@ import json +import io + +import pytest from server import Handler -def test_log_request_handles_malformed_request_without_path(capsys): +@pytest.fixture +def log_output(monkeypatch): + output = io.StringIO() + monkeypatch.setattr("api.request_logging._STREAM", output) + return output + + +def test_log_request_handles_malformed_request_without_path(log_output): """Malformed request lines can call log_request before path is assigned.""" handler = Handler.__new__(Handler) handler.command = None Handler.log_request(handler, "400") - line = capsys.readouterr().out.strip() + line = log_output.getvalue().strip() assert line.startswith("[webui] ") record = json.loads(line.removeprefix("[webui] ")) assert record["method"] == "-" @@ -19,7 +29,7 @@ def test_log_request_handles_malformed_request_without_path(capsys): assert record["remote"] == "-" -def test_log_request_includes_remote_address(capsys): +def test_log_request_includes_remote_address(log_output): handler = Handler.__new__(Handler) handler.command = "POST" handler.path = "/api/auth/login" @@ -28,13 +38,13 @@ def test_log_request_includes_remote_address(capsys): Handler.log_request(handler, "401") - line = capsys.readouterr().out.strip() + line = log_output.getvalue().strip() record = json.loads(line.removeprefix("[webui] ")) assert record["remote"] == "192.0.2.10" assert "forwarded_for" not in record -def test_log_request_includes_first_forwarded_for_address(capsys): +def test_log_request_includes_first_forwarded_for_address(log_output): class Headers: def get(self, key): assert key == "X-Forwarded-For" @@ -48,7 +58,7 @@ def test_log_request_includes_first_forwarded_for_address(capsys): Handler.log_request(handler, "401") - line = capsys.readouterr().out.strip() + line = log_output.getvalue().strip() record = json.loads(line.removeprefix("[webui] ")) assert record["remote"] == "192.0.2.10" assert record["forwarded_for"] == "203.0.113.7" From d9440e86545dde14af6fa639fb8fdbad7fc00a38 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Sat, 19 Sep 2026 15:27:17 -0300 Subject: [PATCH 131/446] 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 3748a2e260a22807fdf8a4141263416e3f8581c9 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 20 Sep 2026 00:02:35 +0000 Subject: [PATCH 132/446] docs(changelog): stamp #7394 TTS persisted engine + #7276 redaction caches Co-authored-by: mvanhorn Co-authored-by: ruizanthony --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b0e58f..ddd5a072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Fixed +- **Server-side speech now honors the TTS engine you configured.** `POST /api/tts` only ever consulted the `engine` field on the request body, so any caller that omitted it — an extension, a native client, a script — silently fell back to Edge regardless of the `tts_engine` saved in Settings. A request that omits `engine` (or sends a whitespace-only value) now inherits the persisted engine, resolved before the provider branches run. An explicit request engine still wins, and a persisted value the server cannot use — `browser`, an extension-only engine, an unknown string, a non-string — still collapses to the historical Edge fallback, because browser speech synthesis has no server-side counterpart. The first-party WebUI callers are unchanged: both now send `engine` explicitly, verified by driving the real production request builders and asserting the outgoing body. Thanks @mvanhorn. (#7394, #7391) + +- **Session responses stop redoing the same redaction work on every request.** Redacting a session payload rescanned every string from scratch on each `/api/session` call, including large unchanged transcript bodies that can never contain a secret. Two caches now sit in front of that work: a bounded LRU for large strings and a memoized prefilter that skips values with no candidate pattern, with clean values returned without a copy. Correctness is preserved across runtime policy changes — a plugin calling `register_redaction_patterns()` mid-process invalidates both caches, so a newly registered secret pattern cannot be bypassed by a cached pre-registration result, and the cache is bounded by object size as well as entry count so a single oversized value cannot pin memory. Thanks @ruizanthony. (#7276) + - **Markdown tables survive a `
` in a cell, and spaced asterisks stop turning italic.** Models routinely emit `
` inside a table cell to stack values on separate lines. `renderMd()` converted every `
` to a newline *before* the table parser ran, so the row split in half: the table rendered with a stub first cell and the remaining rows spilled out underneath as raw pipe text. Separately, the inline emphasis rule matched across spaces, so ordinary prose like `2 * 3 * 4 = 24` rendered as `2 3 4`. `
` is now converted only outside genuine table blocks — classified with the same grammar the table parser itself uses (a run of pipe-lines whose second line is a separator), so pipe-delimited prose such as `| note
# heading |` keeps its heading and list rendering — and emphasis no longer matches a leading or trailing space. Hardened during review: the first implementation used a fixed internal placeholder that user text could contain (corrupting prose and breaking link anchors), and its stricter emphasis rule silently dropped ` spaced `; both are fixed, with the sanitizer still stripping every attribute from a preserved `
`. Thanks @JayaMegelar. (#7618) - **The Extensions settings pane is localized.** Gallery, Installed and Diagnostics were the last large user-facing surface still rendering hardcoded English. Every visible string in the pane now routes through `t()`, with translations supplied across all 15 shipped locales; each referenced key is present in every locale block, so no string can fall back to a raw key name. Extension names and author-supplied descriptions stay as written — they are third-party metadata, not UI chrome. Thanks @Yularzhi. (#7619, #7581) - **`/skills pending` and the other write-approval subcommands reach the agent again.** The WebUI's local `/skills` handler swallowed every argument into its own skill-name search, so the agent-owned write-approval subcommands never reached their handler — `/skills pending`, `/skills approve 3`, `/skills diff` and friends silently ran a name search instead of doing anything. Those subcommands now fall through to the normal send path using the existing opt-out contract that `/reasoning` already uses. Covers every alias the agent handler accepts, including `apply`, `deny` and `drop`, which a first pass missed. Thanks @totalitarian. (#7623) From 9239ed2112356fb51f0608ff90d36b793b6e61e9 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 20 Sep 2026 00:40:01 +0000 Subject: [PATCH 133/446] docs(changelog): stamp #7636 profile-home resolve cache Co-authored-by: totalitarian --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddd5a072..f9ed1382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **The session list stops re-resolving the same profile home once per row.** Building the sidebar's session list called `_resolve_profile_home_param()` for every projected row, and each call did a real filesystem resolve of the same path — a production trace pinned 5-17 s of a slow `/api/session` build to that one call site, reached through `Session.__init__` on every sidecar-metadata cache miss. The resolve is now memoized for the duration of a single session-list build via a call-scoped `ContextVar` entered by `_load_cli_sessions_uncached`, so N rows sharing a profile pay one resolve instead of N. Outside that scope the memo is inert, so nothing else in the process can serve a cached path, and the scope is released on exit including on exception — a value resolved under one profile can never be served to another. The active workspace is hoisted the same way, since it returns the same value for every row in a scan. Thanks @totalitarian. (#7636) + - **Server-side speech now honors the TTS engine you configured.** `POST /api/tts` only ever consulted the `engine` field on the request body, so any caller that omitted it — an extension, a native client, a script — silently fell back to Edge regardless of the `tts_engine` saved in Settings. A request that omits `engine` (or sends a whitespace-only value) now inherits the persisted engine, resolved before the provider branches run. An explicit request engine still wins, and a persisted value the server cannot use — `browser`, an extension-only engine, an unknown string, a non-string — still collapses to the historical Edge fallback, because browser speech synthesis has no server-side counterpart. The first-party WebUI callers are unchanged: both now send `engine` explicitly, verified by driving the real production request builders and asserting the outgoing body. Thanks @mvanhorn. (#7394, #7391) - **Session responses stop redoing the same redaction work on every request.** Redacting a session payload rescanned every string from scratch on each `/api/session` call, including large unchanged transcript bodies that can never contain a secret. Two caches now sit in front of that work: a bounded LRU for large strings and a memoized prefilter that skips values with no candidate pattern, with clean values returned without a copy. Correctness is preserved across runtime policy changes — a plugin calling `register_redaction_patterns()` mid-process invalidates both caches, so a newly registered secret pattern cannot be bypassed by a cached pre-registration result, and the cache is bounded by object size as well as entry count so a single oversized value cannot pin memory. Thanks @ruizanthony. (#7276) From a6f15d869bcac89ce8b1941b0956a956937121b9 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 20 Sep 2026 00:46:00 +0000 Subject: [PATCH 134/446] docs(changelog): stamp #7643 access-log stdout independence Co-authored-by: DevNexsler --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9ed1382..8e63b34e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **HTTP access logs survive agent stdout capture.** Agent and tool code replaces or closes `sys.stdout` to capture subprocess output, and the server's per-request access records were being swallowed into that capture instead of reaching the service log — so requests made while a tool was running simply vanished from the log. Access-log records now go through a dedicated writer that owns its own duplicate of the process's original stdout descriptor, taken at import time before any agent code loads, so replacing or closing Python's `sys.stdout` can no longer intercept them. Delivery is proven end to end by a subprocess test that redirects and then closes `sys.stdout` and still observes the records on the real process stdout. Record format, destination and ordering are unchanged for a normal launch, the write is guarded so a broken sink can never break an HTTP response, and `SIGPIPE` is already ignored before serving starts. Thanks @DevNexsler. (#7643, #2684) + - **The session list stops re-resolving the same profile home once per row.** Building the sidebar's session list called `_resolve_profile_home_param()` for every projected row, and each call did a real filesystem resolve of the same path — a production trace pinned 5-17 s of a slow `/api/session` build to that one call site, reached through `Session.__init__` on every sidecar-metadata cache miss. The resolve is now memoized for the duration of a single session-list build via a call-scoped `ContextVar` entered by `_load_cli_sessions_uncached`, so N rows sharing a profile pay one resolve instead of N. Outside that scope the memo is inert, so nothing else in the process can serve a cached path, and the scope is released on exit including on exception — a value resolved under one profile can never be served to another. The active workspace is hoisted the same way, since it returns the same value for every row in a scan. Thanks @totalitarian. (#7636) - **Server-side speech now honors the TTS engine you configured.** `POST /api/tts` only ever consulted the `engine` field on the request body, so any caller that omitted it — an extension, a native client, a script — silently fell back to Edge regardless of the `tts_engine` saved in Settings. A request that omits `engine` (or sends a whitespace-only value) now inherits the persisted engine, resolved before the provider branches run. An explicit request engine still wins, and a persisted value the server cannot use — `browser`, an extension-only engine, an unknown string, a non-string — still collapses to the historical Edge fallback, because browser speech synthesis has no server-side counterpart. The first-party WebUI callers are unchanged: both now send `engine` explicitly, verified by driving the real production request builders and asserting the outgoing body. Thanks @mvanhorn. (#7394, #7391) From 254e6fe9f13c80e8f3d2730b4c4b301bad521837 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 20 Sep 2026 02:25:50 +0000 Subject: [PATCH 135/446] docs(changelog): stamp #7641 MPF JPEG validation Co-authored-by: GeorgeCodes19 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e63b34e..abadaa43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Multi-frame phone photos stop dragging down session responses.** Multi-Picture Format JPEGs — the multi-frame containers phone cameras routinely produce — were being rejected at the first EOI marker, so they missed the native-image exemption and were handed to the full text-secret scan instead. On a real 30-message payload carrying 12 such images that cost 8.4 s of redaction per response; it is now 0.63 s, with every image byte preserved and non-image output identical. The exemption is granted only after the container fully validates: declared extents must tile the file contiguously to EOF, every secondary JPEG must itself be valid, and malformed offsets, bad counts or sizes, either byte order, overlapping or nested extents, gaps, truncation and undeclared trailing bytes all fail closed back to the normal redaction path rather than being exempted. Parser work is bounded by the APP2 segment size and secondary parsing cannot recurse. Verified against 10 hand-built malformed headers and 20,000 deterministic mutations with no exception raised. Thanks @GeorgeCodes19. (#7641) + - **HTTP access logs survive agent stdout capture.** Agent and tool code replaces or closes `sys.stdout` to capture subprocess output, and the server's per-request access records were being swallowed into that capture instead of reaching the service log — so requests made while a tool was running simply vanished from the log. Access-log records now go through a dedicated writer that owns its own duplicate of the process's original stdout descriptor, taken at import time before any agent code loads, so replacing or closing Python's `sys.stdout` can no longer intercept them. Delivery is proven end to end by a subprocess test that redirects and then closes `sys.stdout` and still observes the records on the real process stdout. Record format, destination and ordering are unchanged for a normal launch, the write is guarded so a broken sink can never break an HTTP response, and `SIGPIPE` is already ignored before serving starts. Thanks @DevNexsler. (#7643, #2684) - **The session list stops re-resolving the same profile home once per row.** Building the sidebar's session list called `_resolve_profile_home_param()` for every projected row, and each call did a real filesystem resolve of the same path — a production trace pinned 5-17 s of a slow `/api/session` build to that one call site, reached through `Session.__init__` on every sidecar-metadata cache miss. The resolve is now memoized for the duration of a single session-list build via a call-scoped `ContextVar` entered by `_load_cli_sessions_uncached`, so N rows sharing a profile pay one resolve instead of N. Outside that scope the memo is inert, so nothing else in the process can serve a cached path, and the scope is released on exit including on exception — a value resolved under one profile can never be served to another. The active workspace is hoisted the same way, since it returns the same value for every row in a scan. Thanks @totalitarian. (#7636) From 3163a91d1a6d45927c4dbe6939a135ef3ce554b4 Mon Sep 17 00:00:00 2001 From: happy5318 Date: Sun, 20 Sep 2026 02:19:43 +0800 Subject: [PATCH 136/446] # Cron edit/duplicate must round-trip a parseable schedule; bad input returns 400 (#7352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path The WebUI populates the editable schedule field from `schedule_display`, which is the human-readable label `once at 2026-08-28 16:00` for one-shot jobs. `saveCronForm()` then submits that exact string back to `POST /api/crons/update`. The Agent's `cron.jobs.parse_schedule()` rejects the display form with `ValueError` (it only accepts the canonical ISO timestamp). The route's earlier `try/except ValueError` block covered input normalization but not the `update_job()` call, so the parser exception escaped as HTTP 500. There are two independent failures to fix: 1. **Frontend** ships the unsafe display label as editable input. Both `openCronEdit()` (`static/panels.js:1593`) and `duplicateCurrentCron()` (`static/panels.js:1532`) use the same `schedule_display || schedule.expression` fallback. The cleanest fix is a shared helper that returns the canonical Agent-parseable value by schedule kind, matching the Agent's own schema: - `once` → `schedule.run_at` - recurring cron → `schedule.expr` (current schema; `expression` accepted for legacy payloads) - interval / legacy → the stored value, with `schedule_display` kept as a final fallback only when it isn't the `once at …` label. 2. **Backend** converts `ValueError` from `update_job()` into HTTP 400 with the parser message, so the WebUI can show a real validation toast instead of an opaque Internal Server Error. The 404 path (job not found) is preserved. ## What Changed - **`static/panels.js`** — add `_cronScheduleForEdit(job)` helper and use it in both `openCronEdit()` and `duplicateCurrentCron()`. The helper returns `schedule.run_at` for one-shot jobs, `schedule.expr` (or legacy `schedule.expression`) for recurring cron, and falls back to `schedule_display` only when it is not the `once at …` presentation form. `saveCronForm()` is unchanged — it still submits `schEl.value`, which is now the canonical field instead of the display label. - **`api/routes.py::_handle_cron_update`** — wrap the `update_job()` call in `try/except ValueError` and return HTTP 400 with the parser message. The earlier normalization try block stays untouched; the 404 check (`if not job`) sits outside the new try block so a missing job still returns 404. - **`tests/test_cron_edit_round_trip.py`** — new regression module with seven tests covering the helper contract (helper exists, both call sites use it, `saveCronForm` still submits the field by name), the route's 400-on-`ValueError` behavior, the preserved 404 path, and the happy-path 200. ## Why - Editing a one-shot cron job's scheduled time has been returning HTTP 500 since the WebUI's initial public release (per #7352 historical note), because the editable field round-trips the `once at …` display label straight to the Agent parser. - The backend regression is independent: even with a correct frontend, any user-typed invalid schedule text (or a programmatic caller) used to produce a 500 instead of a validation response. Both halves are required to deliver a "no more 500" user experience. ## Verification ``` $ .venv/bin/pytest \ tests/test_cron_edit_round_trip.py \ tests/test_cron_no_agent_edit.py \ tests/test_cron_model_override.py \ tests/test_cron_model_provider_picker.py \ tests/test_issue617_cron_profile_selector.py \ -v --no-header … ============================== 27 passed in 4.84s ============================== ``` Seven new tests cover both layers; twenty surrounding cron tests (no-agent edit, model override, model/provider picker, profile selector) stay green. ## Contract | Scenario | Before | After | | --- | --- | --- | | Open one-shot job for edit, change time, save | 500 (display label round-trips to parser) | 200, persisted with new `run_at` | | Open recurring cron job for edit | 200 (legacy `schedule.expression` chain) | 200, unchanged canonical field (`schedule.expr` first, `expression` as legacy fallback) | | Duplicate a one-shot job | 500 on first save | 200, populated with `schedule.run_at` from the source job | | Submit a `once at …` schedule value (programmatic / pre-fix frontend) | 500 | 400 with the parser message | | Submit any other unparseable schedule | 500 | 400 with the parser message | | Update a job that does not exist | 404 | 404 (preserved) | | Update a job with a valid canonical schedule | 200 | 200 (preserved) | ## Risks - **Frontend helper change**: I switched the editable field source for both edit and duplicate in one commit. `saveCronForm()` is untouched, so anything already rendered with the legacy chain (rare in practice — the helper is only called from these two functions) keeps working. The regression `test_save_cron_form_still_submits_schedule_value` pins the submit field name. - **New `try/except ValueError` in `_handle_cron_update`**: the earlier normalization try block (lines 24986–24998) is left exactly as-is. The new try block sits directly around `update_job()` only, so a `ValueError` from input parsing still surfaces before any persistence side effect. The 404 check is outside the new try, so a missing-job path cannot be misreported as 400. Both `test_cron_update_preserves_404_when_job_missing` and `test_cron_update_valid_schedule_still_succeeds` pin those contracts. - **Legacy `schedule.expression` payloads**: accepted as a back-compat fallback in `_cronScheduleForEdit` and in the route (the Agent schema is `expr`, but the parser still handles `expression` per the existing contract). If you prefer to drop the legacy fallback, the helper can be trimmed to `sched.expr` only. ## Model Used - Generated with `MiniMax-M3` via `happy5318/hermes-webui` worktree `/tmp/webui-worktrees/pr7352-cron-edit`, branch `fix/cron-edit-round-trip` (off `origin/master`). ## Related - Closes #7352 - Surface code: - `static/panels.js:1510` `duplicateCurrentCron` (uses helper) - `static/panels.js:1585` `openCronEdit` (uses helper) - `static/panels.js:1905` `saveCronForm` (submit path, unchanged) - `api/routes.py:24979` `_handle_cron_update` (new try/except) - `cron/jobs.py:785` `parse_schedule` (rejects the display form; Agent-side, not modified in this PR) - `cron/jobs.py:2199` `update_job` (the call that raises) - Test surface: new `tests/test_cron_edit_round_trip.py` --- api/routes.py | 12 +- static/panels.js | 32 +++- tests/test_cron_edit_round_trip.py | 240 +++++++++++++++++++++++++++++ 3 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 tests/test_cron_edit_round_trip.py diff --git a/api/routes.py b/api/routes.py index b827c53f..599b02ee 100644 --- a/api/routes.py +++ b/api/routes.py @@ -25011,7 +25011,17 @@ def _handle_cron_update(handler, body): updates[k] = v except ValueError as e: return bad(handler, str(e)) - job = update_job(body["job_id"], updates) + # #7352: ``update_job`` re-parses the updated schedule through + # ``cron.jobs.parse_schedule``, which raises ``ValueError`` for + # display-form input like ``"once at 2026-08-28 16:05"`` (or any other + # user-typed garbage). That exception previously escaped to a 500 + # because the earlier normalization try block didn't cover it. Return + # 400 with the parser message so the WebUI can surface a real + # validation error instead of an opaque Internal Server Error. + try: + job = update_job(body["job_id"], updates) + except ValueError as e: + return bad(handler, str(e), 400) if not job: return bad(handler, "Job not found", 404) return j(handler, {"ok": True, "job": _cron_job_for_api(job)}) diff --git a/static/panels.js b/static/panels.js index 7d4e174a..5c87bc0d 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1507,6 +1507,34 @@ function editCurrentCron(){ if (!_currentCronDetail) return; openCronEdit(_currentCronDetail); } +function _cronScheduleForEdit(job){ + // #7352: when the user opens an existing job for edit or duplicate, the + // editable field must hold the canonical Agent-parseable schedule, not + // the human-readable ``schedule_display`` ("once at ..."). The Agent + // parser at cron/jobs.py rejects the display form with ValueError and + // the resulting round-trip has been failing with HTTP 500 since the + // initial WebUI release. + if(!job) return ''; + const sched = job.schedule; + if(sched && typeof sched === 'object'){ + // One-shot: schedule_display is purely presentation; run_at is what the + // parser accepts. + if(sched.kind === 'once' && sched.run_at) return sched.run_at; + // Recurring cron: the current Agent schema is ``expr``; some legacy + // payloads still expose ``expression`` — accept either. + if(sched.expr) return sched.expr; + if(sched.expression) return sched.expression; + if(sched.run_at) return sched.run_at; + } + // Final fallback: only use schedule_display if it isn't the "once at ..." + // presentation label (which the parser rejects). For any other text + // (e.g. interval/every-30m) the display form is also a valid input. + if(job.schedule_display && !/^\s*once at\s+/i.test(job.schedule_display)){ + return job.schedule_display; + } + return ''; +} + function duplicateCurrentCron(){ if (!_currentCronDetail) return; const job = _currentCronDetail; @@ -1529,7 +1557,7 @@ function duplicateCurrentCron(){ } _renderCronForm({ name: dupName, - schedule: job.schedule_display || (job.schedule && job.schedule.expression) || '', + schedule: _cronScheduleForEdit(job), prompt: job.prompt || '', deliver: job.deliver || 'local', profile: job.profile || '', @@ -1590,7 +1618,7 @@ function openCronEdit(job){ _cronSelectedSkills = Array.isArray(job.skills) ? [...job.skills] : []; _renderCronForm({ name: job.name || '', - schedule: job.schedule_display || (job.schedule && job.schedule.expression) || '', + schedule: _cronScheduleForEdit(job), prompt: job.prompt || '', deliver: job.deliver || 'local', profile: job.profile || '', diff --git a/tests/test_cron_edit_round_trip.py b/tests/test_cron_edit_round_trip.py new file mode 100644 index 00000000..39400fb6 --- /dev/null +++ b/tests/test_cron_edit_round_trip.py @@ -0,0 +1,240 @@ +"""Regression coverage for issue #7352: cron edit/duplicate uses +``schedule_display`` ("once at ...") as editable input, which the Agent +parser at ``cron.jobs.parse_schedule`` rejects with ``ValueError`` and +the resulting round-trip has been failing with HTTP 500 since the +WebUI's initial public release. + +Two layers are fixed in this regression: + +1. ``static/panels.js`` adds ``_cronScheduleForEdit`` and uses it in + both ``openCronEdit`` and ``duplicateCurrentCron`` so the editable + field holds the canonical Agent-parseable value (one-shot + ``schedule.run_at``; recurring ``schedule.expr``; interval keeps its + existing text). + +2. ``api/routes.py::_handle_cron_update`` now wraps the ``update_job`` + call in ``try/except ValueError`` and returns HTTP 400 with the + parser message instead of letting the exception escape as a 500. +""" +from __future__ import annotations + +import io +import json +import sys +import types +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8") + + +class _JSONHandler: + def __init__(self): + self.status = None + self.headers = {} + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + pass + + +def _payload(handler): + return json.loads(handler.wfile.getvalue().decode("utf-8")) + + +def _function_body(name: str) -> str: + marker = f"function {name}(" + start = PANELS_JS.find(marker) + assert start != -1, f"{name} not found" + paren = PANELS_JS.find("(", start) + assert paren != -1, f"{name} params not found" + depth = 0 + for idx in range(paren, len(PANELS_JS)): + ch = PANELS_JS[idx] + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + brace = PANELS_JS.find("{", idx) + break + else: + raise AssertionError(f"{name} params did not terminate") + assert brace != -1, f"{name} body not found" + depth = 0 + for idx in range(brace, len(PANELS_JS)): + ch = PANELS_JS[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return PANELS_JS[brace + 1 : idx] + raise AssertionError(f"{name} body did not terminate") + + +# --- Frontend: _cronScheduleForEdit helper exists and is referenced ---------- + + +def test_cron_schedule_for_edit_helper_exists(): + """A canonical-by-kind schedule picker is required so that opening + an existing one-shot job for edit/duplicate populates the editable + field with the Agent-parseable ``run_at`` rather than the + presentation-only ``schedule_display`` label.""" + body = _function_body("_cronScheduleForEdit") + assert body, "_cronScheduleForEdit must exist" + # One-shot: use run_at, never the display label. + assert "kind === 'once'" in body + assert "run_at" in body + # Recurring cron: prefer the current Agent schema field ``expr``; + # accept legacy ``expression`` for back-compat. + assert "sched.expr" in body + assert "sched.expression" in body + # Final fallback: only use schedule_display if it isn't the unsafe + # ``once at ...`` presentation form. + assert "schedule_display" in body + assert "once at" in body + assert "return ''" in body + + +def test_open_cron_edit_uses_helper(): + """openCronEdit must call _cronScheduleForEdit, not the legacy + ``schedule_display || schedule.expression`` chain that round-trips + the unsafe display label back to the Agent parser.""" + body = _function_body("openCronEdit") + assert "schedule: _cronScheduleForEdit(job)" in body + # Legacy chain must be gone from this function. + assert "job.schedule_display" not in body + assert "job.schedule.expression" not in body + + +def test_duplicate_current_cron_uses_helper(): + """duplicateCurrentCron has the same root cause as openCronEdit; the + fix must cover both call sites or duplicating a one-shot job + would also fail.""" + body = _function_body("duplicateCurrentCron") + assert "schedule: _cronScheduleForEdit(job)" in body + assert "job.schedule_display" not in body + + +def test_save_cron_form_still_submits_schedule_value(): + """Regression guard: the saved-field name stays ``schedule`` and + the helper must not have leaked into the submit path (which already + reads ``schEl.value`` and submits it unchanged).""" + body = _function_body("saveCronForm") + assert "const schedule=schEl.value.trim();" in body + assert "schedule," in body + + +# --- Backend: _handle_cron_update converts ValueError to HTTP 400 -------------- + + +def _install_cron_jobs_stub(monkeypatch, *, update_fn): + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_jobs = types.ModuleType("cron.jobs") + cron_jobs.update_job = update_fn + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.jobs", cron_jobs) + + +def test_cron_update_value_error_returns_400(monkeypatch): + """#7352: when the Agent parser rejects the submitted schedule with + ValueError (the "once at ..." case before the frontend fix, or any + user-typed garbage), the route must return 400 with the parser + message instead of letting the exception escape as 500.""" + import api.routes as routes + + def _raise(job_id, updates): + raise ValueError( + "Invalid schedule 'once at 2026-08-28 16:05'. Use:\n" + " - Timestamp: '2026-02-03T14:00:00' (one-shot at time)" + ) + + _install_cron_jobs_stub(monkeypatch, update_fn=_raise) + + handler = _JSONHandler() + routes._handle_cron_update( + handler, + { + "job_id": "test-job", + "schedule": "once at 2026-08-28 16:05", + }, + ) + + assert handler.status == 400, ( + "schedule validation failures must surface as 400, not 500 " + "(#7352)" + ) + payload = _payload(handler) + assert "error" in payload + assert "Invalid schedule" in payload["error"], ( + "parser message must propagate so the WebUI can show a useful " + "validation toast" + ) + assert "once at 2026-08-28 16:05" in payload["error"] + + +def test_cron_update_preserves_404_when_job_missing(monkeypatch): + """The new ValueError handler must not swallow the not-found path: + when ``update_job`` returns ``None`` (no such job), the route must + still respond 404.""" + import api.routes as routes + + def _missing(job_id, updates): + return None + + _install_cron_jobs_stub(monkeypatch, update_fn=_missing) + + handler = _JSONHandler() + routes._handle_cron_update( + handler, + {"job_id": "ghost", "schedule": "0 9 * * *"}, + ) + + assert handler.status == 404 + assert "not found" in _payload(handler).get("error", "").lower() + + +def test_cron_update_valid_schedule_still_succeeds(monkeypatch): + """Happy path: a parseable schedule (the post-fix frontend submits + ``run_at`` rather than ``schedule_display``) reaches ``update_job`` + and the route returns 200 with the job payload.""" + import api.routes as routes + + calls = [] + job = { + "id": "test-job", + "schedule": { + "kind": "once", + "run_at": "2026-08-28T16:05:00-05:00", + "display": "once at 2026-08-28 16:05", + }, + "schedule_display": "once at 2026-08-28 16:05", + } + + def _ok(job_id, updates): + calls.append((job_id, updates)) + return {**job, **updates} + + _install_cron_jobs_stub(monkeypatch, update_fn=_ok) + + handler = _JSONHandler() + routes._handle_cron_update( + handler, + { + "job_id": "test-job", + "schedule": "2026-08-28T16:05:00-05:00", # canonical ISO + }, + ) + + assert handler.status == 200 + assert calls and calls[0][0] == "test-job" + assert calls[0][1]["schedule"] == "2026-08-28T16:05:00-05:00" From aaae80292aa79fc6df9f7b49ec104d2b11b16ad9 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 20 Sep 2026 03:14:42 +0000 Subject: [PATCH 137/446] fix(cron): keep natural-language recurring schedules verbatim on edit/duplicate Gate follow-up to #7649. _cronScheduleForEdit() consulted sched.expr before schedule_display, so opening or duplicating a job scheduled as `every monday 9am` populated the field with the canonical `0 9 * * 1`. The Agent rebuilds schedule_display from whatever the WebUI submits, so the rewrite stuck and the user silently lost their phrasing. Only the `once at ...` label is unparseable; every other schedule_display form round-trips through parse_schedule(), so prefer it and fall back to expr/expression. The existing tests asserted on the SOURCE TEXT of the helper and stayed green through this defect. Adds four tests that EXECUTE the real helper out of panels.js via node; the natural-language case is red before this fix. Co-authored-by: happy5318 --- static/panels.js | 14 ++++- tests/test_cron_edit_round_trip.py | 94 ++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/static/panels.js b/static/panels.js index 5c87bc0d..dd5090c3 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1516,10 +1516,20 @@ function _cronScheduleForEdit(job){ // initial WebUI release. if(!job) return ''; const sched = job.schedule; + // Only the "once at ..." label is unparseable; every other schedule_display + // form the Agent emits (`every monday 9am`, `every 30m`, ...) round-trips + // through parse_schedule() AND is what the user actually typed. Prefer it + // over the canonical `expr`, otherwise editing or duplicating a + // natural-language recurring job silently rewrites it to raw cron + // (`every monday 9am` -> `0 9 * * 1`) — the Agent rebuilds schedule_display + // from whatever we submit, so the rewrite sticks. + const display = job.schedule_display; + const displayIsParseable = Boolean(display) && !/^\s*once at\s+/i.test(display); if(sched && typeof sched === 'object'){ // One-shot: schedule_display is purely presentation; run_at is what the // parser accepts. if(sched.kind === 'once' && sched.run_at) return sched.run_at; + if(displayIsParseable) return display; // Recurring cron: the current Agent schema is ``expr``; some legacy // payloads still expose ``expression`` — accept either. if(sched.expr) return sched.expr; @@ -1529,8 +1539,8 @@ function _cronScheduleForEdit(job){ // Final fallback: only use schedule_display if it isn't the "once at ..." // presentation label (which the parser rejects). For any other text // (e.g. interval/every-30m) the display form is also a valid input. - if(job.schedule_display && !/^\s*once at\s+/i.test(job.schedule_display)){ - return job.schedule_display; + if(displayIsParseable){ + return display; } return ''; } diff --git a/tests/test_cron_edit_round_trip.py b/tests/test_cron_edit_round_trip.py index 39400fb6..4c839f27 100644 --- a/tests/test_cron_edit_round_trip.py +++ b/tests/test_cron_edit_round_trip.py @@ -238,3 +238,97 @@ def test_cron_update_valid_schedule_still_succeeds(monkeypatch): assert handler.status == 200 assert calls and calls[0][0] == "test-job" assert calls[0][1]["schedule"] == "2026-08-28T16:05:00-05:00" + + +# --------------------------------------------------------------------------- +# Behavioral round-trip coverage (gate follow-up). +# +# The assertions above pin the SOURCE TEXT of `_cronScheduleForEdit`. That is +# not enough: the first implementation satisfied every one of them while still +# rewriting a natural-language recurring schedule. `every monday 9am` has a +# canonical `sched.expr` of `0 9 * * 1`, and because `expr` was consulted +# before `schedule_display`, opening such a job for edit (or duplicating it) +# replaced the user's wording with raw cron. The Agent rebuilds +# `schedule_display` from whatever the WebUI submits, so the rewrite sticks and +# the user silently loses the phrasing they typed. +# +# These tests EXECUTE the real helper out of panels.js instead of reading it. +# --------------------------------------------------------------------------- + +import subprocess + + +def _cron_schedule_for_edit_source() -> str: + """Slice the complete `_cronScheduleForEdit` declaration out of panels.js.""" + marker = "function _cronScheduleForEdit(" + start = PANELS_JS.find(marker) + assert start != -1, "_cronScheduleForEdit not found in panels.js" + paren = PANELS_JS.find("(", start) + depth = 0 + brace = -1 + for idx in range(paren, len(PANELS_JS)): + ch = PANELS_JS[idx] + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + brace = PANELS_JS.find("{", idx) + break + assert brace != -1, "_cronScheduleForEdit body not found" + depth = 0 + for idx in range(brace, len(PANELS_JS)): + ch = PANELS_JS[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return PANELS_JS[start:idx + 1] + raise AssertionError("_cronScheduleForEdit body did not terminate") + + +def _run_cron_schedule_for_edit(job: dict) -> str: + """Execute the real `_cronScheduleForEdit` from panels.js against `job`.""" + script = ( + f"{_cron_schedule_for_edit_source()}\n" + f"const job = {json.dumps(job)};\n" + "process.stdout.write(String(_cronScheduleForEdit(job)));\n" + ) + result = subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ) + return result.stdout + + +def test_natural_language_recurring_schedule_survives_edit_unchanged(): + """`every monday 9am` must round-trip as typed, not as `0 9 * * 1`. + + Red before the fix: `sched.expr` was preferred over `schedule_display`. + """ + job = { + "schedule_display": "every monday 9am", + "schedule": {"kind": "cron", "expr": "0 9 * * 1"}, + } + assert _run_cron_schedule_for_edit(job) == "every monday 9am" + + +def test_one_shot_still_uses_canonical_run_at_not_the_display_label(): + """The original #7352 fix must not regress: `once at ...` is unparseable.""" + job = { + "schedule_display": "once at 2026-08-28 16:00", + "schedule": {"kind": "once", "run_at": "2026-08-28T16:00:00"}, + } + assert _run_cron_schedule_for_edit(job) == "2026-08-28T16:00:00" + + +def test_raw_cron_expression_without_display_falls_back_to_expr(): + """A job carrying only `expr` still yields the canonical expression.""" + job = {"schedule": {"kind": "cron", "expr": "*/15 * * * *"}} + assert _run_cron_schedule_for_edit(job) == "*/15 * * * *" + + +def test_legacy_expression_field_still_accepted(): + """Back-compat: older payloads expose `expression` rather than `expr`.""" + job = {"schedule": {"kind": "cron", "expression": "0 0 * * *"}} + assert _run_cron_schedule_for_edit(job) == "0 0 * * *" From c12cb38295a50c387a51842bc49457f6c67020a6 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 20 Sep 2026 03:15:11 +0000 Subject: [PATCH 138/446] docs(changelog): stamp #7649 cron schedule round-trip + #7630 system-project profile tagging Co-authored-by: happy5318 Co-authored-by: carlotestor --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index abadaa43..5ec86927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Fixed +- **Editing or duplicating a one-shot cron job no longer returns HTTP 500.** The editable schedule field was populated from `schedule_display`, the human-readable label (`once at 2026-08-28 16:00`), and saving submitted that label straight back. The Agent's schedule parser only accepts the canonical timestamp, so it raised — and the route's existing `ValueError` guard did not cover the `update_job()` call, so the failure escaped as a 500. The field now carries the canonical `run_at` for one-shot jobs, and genuinely unparseable input returns a 400 with the parser's message instead of a server error. Natural-language recurring schedules are preserved verbatim: hardening during review found that preferring the canonical cron expression rewrote a job scheduled as `every monday 9am` into `0 9 * * 1` on every edit or duplicate — and because the Agent rebuilds the display string from whatever is submitted, the rewrite stuck and the user silently lost their phrasing. Only the `once at …` label is unparseable, so every other display form is now kept as typed. Thanks @happy5318. (#7649, #7352) + +- **"Webhooks" and "Cron Jobs" projects stop appearing on profiles that have none.** `get_cli_sessions(all_profiles=True)` walks every profile's `state.db` in one request, and when the walk reached a profile holding webhook or cron rows it lazily minted the system project — stamping it with the **UI-selected** profile rather than the profile whose rows triggered the mint. So a webhook project from one profile materialized on whichever profile happened to be open, deleting it from `projects.json` did not help because the next sidebar poll re-minted it, and switching profiles produced another copy. Both mint paths now tag the scanned profile. Single-profile calls keep their previous behavior, and no existing project data is overwritten — a pre-existing mis-tagged entry stays until removed, alongside the correctly tagged one. Thanks @carlotestor. (#7630) + - **Multi-frame phone photos stop dragging down session responses.** Multi-Picture Format JPEGs — the multi-frame containers phone cameras routinely produce — were being rejected at the first EOI marker, so they missed the native-image exemption and were handed to the full text-secret scan instead. On a real 30-message payload carrying 12 such images that cost 8.4 s of redaction per response; it is now 0.63 s, with every image byte preserved and non-image output identical. The exemption is granted only after the container fully validates: declared extents must tile the file contiguously to EOF, every secondary JPEG must itself be valid, and malformed offsets, bad counts or sizes, either byte order, overlapping or nested extents, gaps, truncation and undeclared trailing bytes all fail closed back to the normal redaction path rather than being exempted. Parser work is bounded by the APP2 segment size and secondary parsing cannot recurse. Verified against 10 hand-built malformed headers and 20,000 deterministic mutations with no exception raised. Thanks @GeorgeCodes19. (#7641) - **HTTP access logs survive agent stdout capture.** Agent and tool code replaces or closes `sys.stdout` to capture subprocess output, and the server's per-request access records were being swallowed into that capture instead of reaching the service log — so requests made while a tool was running simply vanished from the log. Access-log records now go through a dedicated writer that owns its own duplicate of the process's original stdout descriptor, taken at import time before any agent code loads, so replacing or closing Python's `sys.stdout` can no longer intercept them. Delivery is proven end to end by a subprocess test that redirects and then closes `sys.stdout` and still observes the records on the real process stdout. Record format, destination and ordering are unchanged for a normal launch, the write is guarded so a broken sink can never break an HTTP response, and `SIGPIPE` is already ignored before serving starts. Thanks @DevNexsler. (#7643, #2684) From ac83c94637df6e0858cd8e433ab0063842e74cfa Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Tue, 18 Aug 2026 12:59:42 +0000 Subject: [PATCH 139/446] fix(ui): keep context compactions visible in long transcripts --- static/ui.js | 101 +++++++++++++++++++-- tests/test_auto_compression_card.py | 9 +- tests/test_compaction_card_display.py | 125 ++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 tests/test_compaction_card_display.py diff --git a/static/ui.js b/static/ui.js index ae387706..961522f6 100644 --- a/static/ui.js +++ b/static/ui.js @@ -15245,6 +15245,22 @@ function _isContextCompactionMessage(m){ function _isContextCompactionText(text){ return /^\s*\[context compaction/i.test(String(text||'')) || /^\s*context compaction/i.test(String(text||'')); } +function _compactionSummarySegment(text){ + // Mirror of api/compression_anchor.py compaction_summary_segment(). The + // replay patch carries this helper so it stays self-contained on upstream. + const s=String(text||'').replace(/^\s+/,''); + const low=s.toLowerCase(); + if(low.startsWith('[context compaction')||low.startsWith('context compaction')) return s; + if(!low.startsWith('[prior context')) return null; + const delim=low.indexOf('[end of prior context'); + if(delim===-1) return null; + const after=s.slice(delim); + const close=after.indexOf(']'); + if(close===-1) return null; + const segment=after.slice(close+1).replace(/^\s+/,''); + if(segment.toLowerCase().startsWith('[context compaction')) return segment; + return null; +} function _isPreservedCompressionTaskListMarkerText(text){ return /^\s*\[your active task list was preserved across context compression\]/i.test(String(text||'')); } @@ -15324,9 +15340,47 @@ function _latestCompressionReferenceMessage(messages, summaryText=''){ function _shouldShowSettledCompressionReference(referenceText){ return !!String(referenceText||'').trim() && !_isContextCompactionText(referenceText); } +// Ultra-compact display (2026-08-18): the compaction marker text starts with a +// long fixed envelope of handling instructions. The conversation card must +// preview the DIGEST (goal/state the user cares about), never the envelope. +function _compactionDigestText(text){ + const s=String(text||''); + // The envelope prose ends right before the first markdown heading on its + // own line. Quoted headings inside the envelope (e.g. "from '## Historical + // Task Snapshot' or any other section") never sit at line start. + const m=s.match(/(^|\n)#{1,3} [^\n]/); + if(!m) return s.trim(); + const start=m.index+(m[1]?1:0); + return s.slice(start).trim(); +} +function _compactionCardPreview(text){ + const digest=_compactionDigestText(text) + .replace(/^#{1,3} /gm,'') + .replace(/^Historical Task Snapshot\s*/i,''); + return digest.split(/\n+/).map(l=>l.trim()).filter(Boolean).slice(0,2).join(' ').slice(0,220); +} +// All compaction markers present in the LOADED transcript, oldest first. Each +// one renders as its own collapsed card at its real position so the user can +// see when the context was compacted and reopen the digest inline. +function _loadedCompactionMarkerRawIdxs(messages){ + const out=[]; + if(!Array.isArray(messages)) return out; + for(let i=0;i
@@ -16967,13 +17021,38 @@ function renderMessages(options){ S.messages, sessionCompressionSummary ); - const referenceText=referenceMessage - ? msgContent(referenceMessage)||String(referenceMessage.content||'') - : sessionCompressionSummary; - const referenceNode=(!compressionState && _shouldShowSettledCompressionReference(referenceText) && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary)) + const referenceText=(()=>{ + if(!referenceMessage) return sessionCompressionSummary; + const raw=msgContent(referenceMessage)||String(referenceMessage.content||''); + const segment=_compactionSummarySegment(raw); + return segment!==null?segment:raw; + })(); + // Ultra-compact display (2026-08-18): every compaction marker loaded in the + // transcript renders as its own collapsed card at its real position, so the + // user SEES each compaction and can reopen its digest inline. The single + // anchored reference card remains only for the settled-summary case where + // no marker message is loaded (server-truncated tail). + const loadedCompactionRawIdxs=(!compressionState)?_loadedCompactionMarkerRawIdxs(S.messages):[]; + const compactionCardNodes=loadedCompactionRawIdxs.map((markerRawIdx,pos)=>{ + const markerMsg=S.messages[markerRawIdx]; + let raw=''; + try{ + raw=String(msgContent(markerMsg)||''); + }catch(_){ + raw=String((markerMsg&&markerMsg.content)||''); + } + const segment=_compactionSummarySegment(raw); + const text=segment!==null?segment:raw; + const isLatest=pos===loadedCompactionRawIdxs.length-1; + const row=document.createElement('div'); + row.innerHTML=`
${_compressionReferenceCardHtml(text,false)}${isLatest?_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages):''}
`; + return {node:row.firstElementChild, rawIdx:markerRawIdx}; + }); + const referenceNode=(!compressionState && !compactionCardNodes.length && _shouldShowSettledCompressionReference(referenceText) && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary)) ? (()=>{const row=document.createElement('div');row.innerHTML=`
${_compressionReferenceCardHtml(referenceText,false)}${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}
`;return row.firstElementChild;})() : null; - let preservedCompressionTaskCardsAttached=!!referenceNode; + let referenceNodePinnedAtTop=false; + let preservedCompressionTaskCardsAttached=!!referenceNode||compactionCardNodes.length>0; const preservedCompressionRawIdxs=[]; let rawIdx=0; for(const m of S.messages){ @@ -17011,6 +17090,9 @@ function renderMessages(options){ : (typeof t==='function'?t('load_older_messages'):'Load earlier messages'); inner.appendChild(indicator); _wireMessageWindowLoadEarlierButton(); + // Keep the settled compacted-context card immediately visible in a long, + // tail-loaded conversation. Put it in flow (not inside an old tool turn). + referenceNodePinnedAtTop=_pinSettledCompressionReferenceAtTop(inner,referenceNode,referenceMessageRawIdx); } let lastUserRawIdx=-1; for(let i=visWithIdx.length-1;i>=0;i--){ @@ -17628,8 +17710,11 @@ function renderMessages(options){ const handoffSummaryStates=_collectHandoffSummaryStates(S.messages); _insertCompressionLikeNode(compressionNode); - if(referenceNode&&referenceMessageRawIdx>=0) _insertCompressionLikeNodeByRawIdx(referenceNode, referenceMessageRawIdx); - else _insertCompressionLikeNode(referenceNode); + for(const entry of compactionCardNodes) _insertCompressionLikeNodeByRawIdx(entry.node, entry.rawIdx); + if(!referenceNodePinnedAtTop){ + if(referenceNode&&referenceMessageRawIdx>=0) _insertCompressionLikeNodeByRawIdx(referenceNode, referenceMessageRawIdx); + else _insertCompressionLikeNode(referenceNode); + } _insertCompressionLikeNode(preservedOnlyNode, preservedOnlyAnchor); _insertCompressionLikeNode(handoffState?_handoffCardsNode(handoffState):null, renderVisWithIdx.length?renderVisWithIdx.length-1:null); for(const entry of handoffSummaryStates){ diff --git a/tests/test_auto_compression_card.py b/tests/test_auto_compression_card.py index 837ff474..dec169c7 100644 --- a/tests/test_auto_compression_card.py +++ b/tests/test_auto_compression_card.py @@ -1076,9 +1076,10 @@ def test_context_anchor_reference_uses_session_summary_fallback(): assert "sessionCompressionSummary" in src assert "const sessionCompressionSummary" in src - assert "referenceText=referenceMessage" in src - assert ": sessionCompressionSummary" in src - assert "_shouldShowSettledCompressionReference(referenceText)" in src + assert "const referenceText=(()=>{" in src + assert "if(!referenceMessage) return sessionCompressionSummary;" in src + assert "return segment!==null?segment:raw;" in src + assert "!compactionCardNodes.length && _shouldShowSettledCompressionReference(referenceText)" in src assert "!_isContextCompactionText(referenceText)" in src @@ -1234,7 +1235,7 @@ def test_preserved_task_list_attaches_once_per_render(): assert ".reverse().find(m=>_isPreservedCompressionTaskListMessage(m))" in src assert "const preservedCompressionTaskMessages=_latestPreservedCompressionTaskListMessages(S.messages);" in src assert "S.messages.filter(m=>_isPreservedCompressionTaskListMessage(m))" not in src - assert "let preservedCompressionTaskCardsAttached=!!referenceNode;" in src + assert "let preservedCompressionTaskCardsAttached=!!referenceNode||compactionCardNodes.length>0;" in src assert "const preservedOnlyNode=" in src assert "(!preservedCompressionTaskCardsAttached&&(!referenceNode||compressionState)&&preservedCompressionTaskMessages.length)" in src diff --git a/tests/test_compaction_card_display.py b/tests/test_compaction_card_display.py new file mode 100644 index 00000000..a3c181c9 --- /dev/null +++ b/tests/test_compaction_card_display.py @@ -0,0 +1,125 @@ +"""Regression tests for visible ultra-compact compaction cards.""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = ROOT / "static" / "ui.js" + + +def _extract_function(source: str, name: str) -> str: + marker = f"function {name}(" + start = source.find(marker) + if start < 0: + raise AssertionError(f"function {name} not found") + brace = source.find("{", start) + depth = 1 + pos = brace + 1 + while depth and pos < len(source): + if source[pos] == "{": + depth += 1 + elif source[pos] == "}": + depth -= 1 + pos += 1 + return source[start:pos] + + +def _run_node(script: str) -> None: + completed = subprocess.run( + ["node", "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + timeout=30, + check=False, + ) + assert completed.returncode == 0, completed.stderr or completed.stdout + + +def test_compaction_digest_and_preview_drop_instruction_envelope() -> None: + source = UI_JS.read_text(encoding="utf-8") + functions = "\n".join( + _extract_function(source, name) + for name in ("_compactionDigestText", "_compactionCardPreview") + ) + envelope = ( + "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted " + "into the summary below. This is a handoff from a previous context window." + ) + digest = ( + '## Historical Task Snapshot\nUser asked: "fix the planning bug"\n' + "## Goal\nFix the MES planning defect.\n" + "## Active State\n- worktree ready\n" + "## Détail complet\nRésumé intégral : lire la note " + "/opt/obsidian/vault/MES/Hermes/Compactions/compaction-test.md" + ) + marker = envelope + "\n" + digest + "\n--- END OF CONTEXT SUMMARY ---" + script = f""" +const assert = require('assert'); +{functions} +const marker = {json.dumps(marker)}; +const digest = _compactionDigestText(marker); +assert.ok(digest.startsWith('## Historical Task Snapshot')); +assert.ok(!digest.includes('REFERENCE ONLY')); +assert.ok(digest.includes('compaction-test.md')); +const preview = _compactionCardPreview(marker); +assert.ok(preview.includes('fix the planning bug')); +assert.ok(!/REFERENCE ONLY|compacted into the summary/i.test(preview)); +assert.ok(preview.length <= 220); +""" + _run_node(script) + + +def test_loaded_compaction_marker_scan_finds_every_non_tool_marker() -> None: + source = UI_JS.read_text(encoding="utf-8") + functions = "\n".join( + _extract_function(source, name) + for name in ( + "_compactionSummarySegment", + "_isContextCompactionText", + "_isContextCompactionMessage", + "_loadedCompactionMarkerRawIdxs", + ) + ) + marker = "[CONTEXT COMPACTION — REFERENCE ONLY]\n## Goal\nKeep the digest visible." + messages = [ + {"role": "user", "content": "first request"}, + {"role": "assistant", "content": marker}, + {"role": "tool", "content": marker}, + {"role": "assistant", "content": "normal answer"}, + {"role": "assistant", "content": marker}, + ] + script = f""" +const assert = require('assert'); +const msgContent = (message) => message && typeof message.content === 'string' ? message.content : ''; +{functions} +assert.deepStrictEqual(_loadedCompactionMarkerRawIdxs({json.dumps(messages)}), [1, 4]); +""" + _run_node(script) + + +def test_settled_reference_is_pinned_at_top_when_marker_is_outside_tail() -> None: + source = UI_JS.read_text(encoding="utf-8") + helper = _extract_function(source, "_pinSettledCompressionReferenceAtTop") + script = f""" +const assert = require('assert'); +{helper} +const children = []; +const inner = {{ appendChild(node) {{ children.push(node); return node; }} }}; +const card = {{ id: 'compaction-card' }}; +assert.strictEqual(_pinSettledCompressionReferenceAtTop(inner, card, -1), true); +assert.deepStrictEqual(children, [card]); +assert.strictEqual(_pinSettledCompressionReferenceAtTop(inner, {{id:'inline'}}, 12), false); +assert.deepStrictEqual(children, [card]); +""" + _run_node(script) + assert re.search( + r"referenceNodePinnedAtTop\s*=\s*_pinSettledCompressionReferenceAtTop\(", + source, + ), "render path must pin the settled summary before transcript rows" + assert "if(!referenceNodePinnedAtTop)" in source From c13819b6b02d5bd96fa7c80a26c47d3d491abeb8 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Tue, 18 Aug 2026 13:23:39 +0000 Subject: [PATCH 140/446] test: stub _loadedCompactionMarkerRawIdxs in anchor-ownership node harness The renderMessages node harness added in origin/master (#7125 lineage) evals renderMessages in isolation; the new loaded-compaction gate calls _loadedCompactionMarkerRawIdxs, which must be stubbed like the other render helpers (_postProcessWithAnchorSuppression, _usedModelTurnChipLabel). --- tests/test_anchor_fallback_ownership.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_anchor_fallback_ownership.py b/tests/test_anchor_fallback_ownership.py index 6cc6d677..c333355e 100644 --- a/tests/test_anchor_fallback_ownership.py +++ b/tests/test_anchor_fallback_ownership.py @@ -682,6 +682,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds function _gatewayModelWarningText() {{ return ''; }} function _usedModelTurnChipLabel() {{ return ''; }} function _formatTurnDuration() {{ return ''; }} + function _loadedCompactionMarkerRawIdxs() {{ return []; }} function _renderSettledAnchorSceneForMessage(message, segment, rawIdx) {{ const group = new FakeElement('div'); group.className = 'tool-worklog-group agent-activity-group'; From 3eb06c192c5f19f8c41b388a8ab69ba7f7381336 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Fri, 21 Aug 2026 00:01:44 +0000 Subject: [PATCH 141/446] fix(ui): preserve compaction cards across virtual windows --- static/ui.js | 135 +++++++++++---- tests/test_anchor_fallback_ownership.py | 8 + tests/test_auto_compression_card.py | 15 +- tests/test_compaction_card_display.py | 46 ++++- ...st_issue500_message_list_virtualization.py | 159 ++++++++++++++++++ 5 files changed, 319 insertions(+), 44 deletions(-) diff --git a/static/ui.js b/static/ui.js index 961522f6..4948164f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -15373,11 +15373,49 @@ function _loadedCompactionMarkerRawIdxs(messages){ // A settled compaction whose marker sits before the server-loaded tail must // remain visible at the top of that tail. Anchoring it to an old assistant/tool // turn can bury it inside a hidden worklog, which makes compaction look absent. -function _pinSettledCompressionReferenceAtTop(inner,node,referenceMessageRawIdx){ - if(!inner||!node||referenceMessageRawIdx>=0) return false; +function _selectCompactionCardPlacements(markerRawIdxs,firstRenderedRawIdx){ + const inlineMarkers=[]; + let latestPreWindowMarker=null; + const boundary=Number.isFinite(firstRenderedRawIdx)?firstRenderedRawIdx:-1; + for(const value of Array.isArray(markerRawIdxs)?markerRawIdxs:[]){ + const rawIdx=Number(value); + if(!Number.isInteger(rawIdx)||rawIdx<0) continue; + if(rawIdx=0) return false; + return _pinCompactionCardAtTop(inner,node); +} function _compressionReferenceCardHtml(text, open=false){ const copy=_engineAwareCompressionCopy(); const preview=_compactionCardPreview(text)||text.split(/\n+/).filter(Boolean).slice(0,2).join(' '); @@ -17029,11 +17067,13 @@ function renderMessages(options){ })(); // Ultra-compact display (2026-08-18): every compaction marker loaded in the // transcript renders as its own collapsed card at its real position, so the - // user SEES each compaction and can reopen its digest inline. The single - // anchored reference card remains only for the settled-summary case where - // no marker message is loaded (server-truncated tail). + // user SEES each compaction and can reopen its digest inline. If a marker is + // older than the virtual window, only the latest such marker is pinned above + // the rendered rows; markers inside the window stay inline. + const firstRenderedRawIdx=renderVisWithIdx.length?renderVisWithIdx[0].rawIdx:Infinity; const loadedCompactionRawIdxs=(!compressionState)?_loadedCompactionMarkerRawIdxs(S.messages):[]; - const compactionCardNodes=loadedCompactionRawIdxs.map((markerRawIdx,pos)=>{ + const compactionPlacements=_selectCompactionCardPlacements(loadedCompactionRawIdxs,firstRenderedRawIdx); + const _compactionCardEntry=(markerRawIdx,kind)=>{ const markerMsg=S.messages[markerRawIdx]; let raw=''; try{ @@ -17043,16 +17083,28 @@ function renderMessages(options){ } const segment=_compactionSummarySegment(raw); const text=segment!==null?segment:raw; - const isLatest=pos===loadedCompactionRawIdxs.length-1; + const ownsTasks=!!(compactionPlacements.taskOwner + && compactionPlacements.taskOwner.kind===kind + && compactionPlacements.taskOwner.rawIdx===markerRawIdx); const row=document.createElement('div'); - row.innerHTML=`
${_compressionReferenceCardHtml(text,false)}${isLatest?_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages):''}
`; - return {node:row.firstElementChild, rawIdx:markerRawIdx}; - }); - const referenceNode=(!compressionState && !compactionCardNodes.length && _shouldShowSettledCompressionReference(referenceText) && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary)) - ? (()=>{const row=document.createElement('div');row.innerHTML=`
${_compressionReferenceCardHtml(referenceText,false)}${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}
`;return row.firstElementChild;})() + row.innerHTML=`
${_compressionReferenceCardHtml(text,false)}${ownsTasks?_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages):''}
`; + const node=row.firstElementChild; + if(node){ + node.setAttribute('data-compaction-placement',kind); + node.setAttribute('data-compaction-raw-idx',String(markerRawIdx)); + if(ownsTasks&&preservedCompressionTaskMessages.length) node.setAttribute('data-compaction-task-owner','1'); + } + return {node,rawIdx:markerRawIdx,kind}; + }; + const preWindowCompactionCard=compactionPlacements.latestPreWindowMarker===null + ?null + :_compactionCardEntry(compactionPlacements.latestPreWindowMarker,'pre-window'); + const compactionCardNodes=compactionPlacements.inlineMarkers.map(markerRawIdx=>_compactionCardEntry(markerRawIdx,'inline')); + const referenceNode=(!compressionState && loadedCompactionRawIdxs.length===0 && _shouldShowSettledCompressionReference(referenceText) && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary)) + ? (()=>{const row=document.createElement('div');row.innerHTML=`
${_compressionReferenceCardHtml(referenceText,false)}${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}
`;const node=row.firstElementChild;if(node&&preservedCompressionTaskMessages.length) node.setAttribute('data-compaction-task-owner','1');return node;})() : null; let referenceNodePinnedAtTop=false; - let preservedCompressionTaskCardsAttached=!!referenceNode||compactionCardNodes.length>0; + let preservedCompressionTaskOwnerNode=null; const preservedCompressionRawIdxs=[]; let rawIdx=0; for(const m of S.messages){ @@ -17060,7 +17112,6 @@ function renderMessages(options){ if(_isPreservedCompressionTaskListMessage(m)){preservedCompressionRawIdxs.push(rawIdx);rawIdx++;continue;} rawIdx++; } - const firstRenderedRawIdx=renderVisWithIdx.length?renderVisWithIdx[0].rawIdx:Infinity; // #6999: the turn-content maps MUST see the FULL visWithIdx, not the // virtual render window. _assistantTurnFinalVisibleContentMap / // _assistantTurnVisibleContentMap derive the echo-strip context for a @@ -17093,7 +17144,16 @@ function renderMessages(options){ // Keep the settled compacted-context card immediately visible in a long, // tail-loaded conversation. Put it in flow (not inside an old tool turn). referenceNodePinnedAtTop=_pinSettledCompressionReferenceAtTop(inner,referenceNode,referenceMessageRawIdx); + if(referenceNodePinnedAtTop&&referenceNode?.parentElement&&preservedCompressionTaskMessages.length){ + preservedCompressionTaskOwnerNode=referenceNode; + } } + const preWindowInsertion=_insertCompactionCardNodes( + preWindowCompactionCard?[preWindowCompactionCard]:[], + compactionPlacements.taskOwner, + node=>_pinCompactionCardAtTop(inner,node) + ); + if(preWindowInsertion.taskOwnerNode) preservedCompressionTaskOwnerNode=preWindowInsertion.taskOwnerNode; let lastUserRawIdx=-1; for(let i=visWithIdx.length-1;i>=0;i--){ if(visWithIdx[i].m&&visWithIdx[i].m.role==='user'){ @@ -17640,7 +17700,7 @@ function renderMessages(options){ } function _insertCompressionLikeNode(node, anchorIndex){ - if(!node) return; + if(!node) return false; const anchorIdx=anchorIndex===undefined?insertionAnchor:anchorIndex; if(anchorIdx!==null && renderVisWithIdx[anchorIdx]){ const anchorRawIdx=renderVisWithIdx[anchorIdx].rawIdx; @@ -17650,23 +17710,24 @@ function renderMessages(options){ const blocks=_assistantTurnBlocks(turn); if(blocks){ blocks.appendChild(node); - return; + return !!node.parentElement; } } const userRow=userRows.get(anchorRawIdx); if(userRow && userRow.parentElement){ userRow.parentElement.insertBefore(node, userRow.nextSibling); - return; + return !!node.parentElement; } } inner.appendChild(node); + return !!node.parentElement; } function _insertCompressionLikeNodeByRawIdx(node, rawIdx){ - if(!node) return; - if(rawIdx{const row=document.createElement('div');row.innerHTML=`
${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}
`;return row.firstElementChild;})() + const preservedOnlyNode=preservedCompressionTaskMessages.length + ? (()=>{const row=document.createElement('div');row.innerHTML=`
${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}
`;return row.firstElementChild;})() : null; const preservedOnlyAnchor=preservedCompressionRawIdxs.length ? (()=>{let idx=null;for(let i=0;i_insertCompressionLikeNodeByRawIdx(node,markerRawIdx) + ); + if(inlineCompactionInsertion.taskOwnerNode) preservedCompressionTaskOwnerNode=inlineCompactionInsertion.taskOwnerNode; if(!referenceNodePinnedAtTop){ - if(referenceNode&&referenceMessageRawIdx>=0) _insertCompressionLikeNodeByRawIdx(referenceNode, referenceMessageRawIdx); - else _insertCompressionLikeNode(referenceNode); + const referenceInserted=referenceNode&&referenceMessageRawIdx>=0 + ?_insertCompressionLikeNodeByRawIdx(referenceNode,referenceMessageRawIdx) + :_insertCompressionLikeNode(referenceNode); + if(referenceInserted&&referenceNode?.parentElement&&preservedCompressionTaskMessages.length){ + preservedCompressionTaskOwnerNode=referenceNode; + } } - _insertCompressionLikeNode(preservedOnlyNode, preservedOnlyAnchor); + _insertPreservedCompressionTaskFallback( + preservedCompressionTaskOwnerNode, + preservedOnlyNode, + node=>_insertCompressionLikeNode(node,preservedOnlyAnchor) + ); _insertCompressionLikeNode(handoffState?_handoffCardsNode(handoffState):null, renderVisWithIdx.length?renderVisWithIdx.length-1:null); for(const entry of handoffSummaryStates){ if(!entry||!entry.state) continue; diff --git a/tests/test_anchor_fallback_ownership.py b/tests/test_anchor_fallback_ownership.py index c333355e..ff0a1c45 100644 --- a/tests/test_anchor_fallback_ownership.py +++ b/tests/test_anchor_fallback_ownership.py @@ -683,6 +683,14 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds function _usedModelTurnChipLabel() {{ return ''; }} function _formatTurnDuration() {{ return ''; }} function _loadedCompactionMarkerRawIdxs() {{ return []; }} + function _selectCompactionCardPlacements() {{ + return {{ inlineMarkers: [], latestPreWindowMarker: null, taskOwner: null }}; + }} + function _insertCompactionCardNodes() {{ + return {{ insertedNodes: [], taskOwnerNode: null }}; + }} + function _insertPreservedCompressionTaskFallback() {{ return false; }} + function _pinCompactionCardAtTop() {{ return false; }} function _renderSettledAnchorSceneForMessage(message, segment, rawIdx) {{ const group = new FakeElement('div'); group.className = 'tool-worklog-group agent-activity-group'; diff --git a/tests/test_auto_compression_card.py b/tests/test_auto_compression_card.py index dec169c7..bbe16607 100644 --- a/tests/test_auto_compression_card.py +++ b/tests/test_auto_compression_card.py @@ -1079,7 +1079,7 @@ def test_context_anchor_reference_uses_session_summary_fallback(): assert "const referenceText=(()=>{" in src assert "if(!referenceMessage) return sessionCompressionSummary;" in src assert "return segment!==null?segment:raw;" in src - assert "!compactionCardNodes.length && _shouldShowSettledCompressionReference(referenceText)" in src + assert "loadedCompactionRawIdxs.length===0 && _shouldShowSettledCompressionReference(referenceText)" in src assert "!_isContextCompactionText(referenceText)" in src @@ -1116,8 +1116,9 @@ def test_reference_message_uses_raw_transcript_position_before_anchor_fallback() src = _read("static/ui.js") assert "const {message:referenceMessage, rawIdx:referenceMessageRawIdx}=_latestCompressionReferenceMessage(" in src - assert "if(referenceNode&&referenceMessageRawIdx>=0) _insertCompressionLikeNodeByRawIdx(referenceNode, referenceMessageRawIdx);" in src - assert "else _insertCompressionLikeNode(referenceNode);" in src + assert "referenceNode&&referenceMessageRawIdx>=0" in src + assert "?_insertCompressionLikeNodeByRawIdx(referenceNode,referenceMessageRawIdx)" in src + assert ":_insertCompressionLikeNode(referenceNode);" in src def test_reference_message_inserted_before_future_assistant_anchor(): @@ -1200,7 +1201,7 @@ def test_frontend_reference_insertion_skips_when_reference_is_before_render_wind assert end != -1, "raw-index insertion helper end not found" helper = src[start:end] - assert "if(rawIdx_isPreservedCompressionTaskListMessage(m))" in src assert "const preservedCompressionTaskMessages=_latestPreservedCompressionTaskListMessages(S.messages);" in src assert "S.messages.filter(m=>_isPreservedCompressionTaskListMessage(m))" not in src - assert "let preservedCompressionTaskCardsAttached=!!referenceNode||compactionCardNodes.length>0;" in src - assert "const preservedOnlyNode=" in src - assert "(!preservedCompressionTaskCardsAttached&&(!referenceNode||compressionState)&&preservedCompressionTaskMessages.length)" in src + def test_preserved_task_list_is_suppressed_when_latest_todo_state_has_no_active_items(): diff --git a/tests/test_compaction_card_display.py b/tests/test_compaction_card_display.py index a3c181c9..c0687b2f 100644 --- a/tests/test_compaction_card_display.py +++ b/tests/test_compaction_card_display.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import re import subprocess from pathlib import Path @@ -103,11 +102,51 @@ assert.deepStrictEqual(_loadedCompactionMarkerRawIdxs({json.dumps(messages)}), [ _run_node(script) +def test_compaction_placement_selection_covers_window_boundaries() -> None: + source = UI_JS.read_text(encoding="utf-8") + helper = _extract_function(source, "_selectCompactionCardPlacements") + script = f""" +const assert = require('assert'); +{helper} +assert.deepStrictEqual( + _selectCompactionCardPlacements([3, 11, 49, 50, 88], 50), + {{ + inlineMarkers: [50, 88], + latestPreWindowMarker: 49, + taskOwner: {{kind: 'inline', rawIdx: 88}}, + }}, +); +assert.deepStrictEqual( + _selectCompactionCardPlacements([3, 11, 49], 50), + {{ + inlineMarkers: [], + latestPreWindowMarker: 49, + taskOwner: {{kind: 'pre-window', rawIdx: 49}}, + }}, +); +assert.deepStrictEqual( + _selectCompactionCardPlacements([50, 88], 50), + {{ + inlineMarkers: [50, 88], + latestPreWindowMarker: null, + taskOwner: {{kind: 'inline', rawIdx: 88}}, + }}, +); +assert.deepStrictEqual( + _selectCompactionCardPlacements([], 50), + {{inlineMarkers: [], latestPreWindowMarker: null, taskOwner: null}}, +); +""" + _run_node(script) + + def test_settled_reference_is_pinned_at_top_when_marker_is_outside_tail() -> None: source = UI_JS.read_text(encoding="utf-8") + pin_helper = _extract_function(source, "_pinCompactionCardAtTop") helper = _extract_function(source, "_pinSettledCompressionReferenceAtTop") script = f""" const assert = require('assert'); +{pin_helper} {helper} const children = []; const inner = {{ appendChild(node) {{ children.push(node); return node; }} }}; @@ -118,8 +157,3 @@ assert.strictEqual(_pinSettledCompressionReferenceAtTop(inner, {{id:'inline'}}, assert.deepStrictEqual(children, [card]); """ _run_node(script) - assert re.search( - r"referenceNodePinnedAtTop\s*=\s*_pinSettledCompressionReferenceAtTop\(", - source, - ), "render path must pin the settled summary before transcript rows" - assert "if(!referenceNodePinnedAtTop)" in source diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 70a8e851..3d5ff232 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -101,6 +101,165 @@ console.log(JSON.stringify(metrics)); assert metrics["bottomPad"] == 0 +def test_compaction_cards_cross_virtualization_threshold_with_one_task_owner(): + """Execute the real window and compaction DOM helpers with a tiny DOM. + + This deliberately reuses issue #500's proven virtualization harness instead + of evaluating all of renderMessages() against a hand-built browser clone. + """ + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + r""" +const assert = require('assert'); +const MESSAGE_VIRTUAL_DEFAULT_ROW_HEIGHTS = { + user: 120, + assistant: 160, + tool_call: 400, + default: 140, +}; +const MESSAGE_VIRTUAL_THRESHOLD_ROWS = 80; +const MESSAGE_VIRTUAL_BUFFER_PX = 0; +eval(extractFunc('_messageVirtualDefaultHeightForRole')); +eval(extractFunc('_messageVirtualWindow')); +eval(extractFunc('_selectCompactionCardPlacements')); +eval(extractFunc('_pinCompactionCardAtTop')); +eval(extractFunc('_insertCompactionCardNodes')); +eval(extractFunc('_insertPreservedCompressionTaskFallback')); +eval(extractFunc('_insertCompressionLikeNodeByRawIdx')); + +class MiniNode { + constructor(label, rawIdx = null) { + this.label = label; + this.rawIdx = rawIdx; + this.children = []; + this.parentElement = null; + } + appendChild(node) { + if (node.parentElement) node.remove(); + node.parentElement = this; + this.children.push(node); + return node; + } + insertBefore(node, reference) { + if (node.parentElement) node.remove(); + node.parentElement = this; + const index = this.children.indexOf(reference); + if (index < 0) this.children.push(node); + else this.children.splice(index, 0, node); + return node; + } + remove() { + if (!this.parentElement) return; + const index = this.parentElement.children.indexOf(this); + if (index >= 0) this.parentElement.children.splice(index, 1); + this.parentElement = null; + } +} +function _assistantTurnBlocks() { return null; } +function taskCount(node) { + return (node.label === 'task' ? 1 : 0) + + node.children.reduce((total, child) => total + taskCount(child), 0); +} +function card(kind, rawIdx, taskOwner) { + const node = new MiniNode(`${kind}:${rawIdx}`, rawIdx); + if (taskOwner && taskOwner.kind === kind && taskOwner.rawIdx === rawIdx) { + node.appendChild(new MiniNode('task')); + } + return {kind, rawIdx, node}; +} + +const visible = Array.from({length: 100}, (_, index) => ({ + rawIdx: index * 2, + m: {role: 'user'}, +})); +const virtualWindow = _messageVirtualWindow({ + total: visible.length, + scrollTop: 120 * 200, + viewportHeight: 720, + heights: Array.from({length: visible.length}, () => 120), + defaultHeight: 120, + bufferPx: 0, + threshold: MESSAGE_VIRTUAL_THRESHOLD_ROWS, + keepTailCount: 20, +}); +assert.strictEqual(virtualWindow.virtualized, true); +const renderVisWithIdx = visible.slice(virtualWindow.start); +const firstRenderedRawIdx = renderVisWithIdx[0].rawIdx; +assert.strictEqual(firstRenderedRawIdx, 160); + +let inner; +let assistantSegments; +let userRows; +function renderScenario(markers) { + const placements = _selectCompactionCardPlacements(markers, firstRenderedRawIdx); + inner = new MiniNode('inner'); + assistantSegments = new Map(); + userRows = new Map(); + let taskOwnerNode = null; + + if (placements.latestPreWindowMarker !== null) { + const entry = card('pre-window', placements.latestPreWindowMarker, placements.taskOwner); + const mounted = _insertCompactionCardNodes( + [entry], + placements.taskOwner, + node => _pinCompactionCardAtTop(inner, node), + ); + taskOwnerNode = mounted.taskOwnerNode; + } + + for (const entry of renderVisWithIdx) { + const row = new MiniNode(`row:${entry.rawIdx}`, entry.rawIdx); + inner.appendChild(row); + userRows.set(entry.rawIdx, row); + } + + const inlineEntries = placements.inlineMarkers.map(rawIdx => + card('inline', rawIdx, placements.taskOwner) + ); + const mounted = _insertCompactionCardNodes( + inlineEntries, + placements.taskOwner, + (node, rawIdx) => _insertCompressionLikeNodeByRawIdx(node, rawIdx), + ); + taskOwnerNode = mounted.taskOwnerNode || taskOwnerNode; + + const fallback = new MiniNode('standalone-tasks'); + fallback.appendChild(new MiniNode('task')); + const fallbackInserted = _insertPreservedCompressionTaskFallback( + taskOwnerNode, + fallback, + node => { inner.appendChild(node); return true; }, + ); + return {placements, inner, taskOwnerNode, fallbackInserted}; +} + +const mixed = renderScenario([1, 79, 159, 170]); +assert.deepStrictEqual(mixed.placements, { + inlineMarkers: [170], + latestPreWindowMarker: 159, + taskOwner: {kind: 'inline', rawIdx: 170}, +}); +assert.strictEqual(mixed.inner.children[0].label, 'pre-window:159'); +assert.ok( + mixed.inner.children.indexOf(mixed.taskOwnerNode) + < mixed.inner.children.findIndex(node => node.rawIdx > 170), +); +assert.strictEqual(taskCount(mixed.inner), 1); +assert.strictEqual(mixed.fallbackInserted, false); + +const preWindowOnly = renderScenario([1, 79, 159]); +assert.strictEqual(preWindowOnly.taskOwnerNode.label, 'pre-window:159'); +assert.strictEqual(taskCount(preWindowOnly.inner), 1); +assert.strictEqual(preWindowOnly.fallbackInserted, false); + +const noMarkers = renderScenario([]); +assert.strictEqual(noMarkers.taskOwnerNode, null); +assert.strictEqual(noMarkers.fallbackInserted, true); +assert.strictEqual(taskCount(noMarkers.inner), 1); +console.log('ok'); +""" + assert _run_node(source) == "ok" + + def test_render_messages_uses_virtual_window_and_spacer_measurement_path(): js = UI_JS_PATH.read_text(encoding="utf-8") render_start = js.index("function renderMessages(options)") From fab4968b170edeac992de4c7cc83eb00e0f5cf73 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Fri, 21 Aug 2026 00:18:50 +0000 Subject: [PATCH 142/446] test: remove private compaction fixture path --- tests/test_compaction_card_display.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_compaction_card_display.py b/tests/test_compaction_card_display.py index c0687b2f..b57abfaa 100644 --- a/tests/test_compaction_card_display.py +++ b/tests/test_compaction_card_display.py @@ -52,10 +52,10 @@ def test_compaction_digest_and_preview_drop_instruction_envelope() -> None: ) digest = ( '## Historical Task Snapshot\nUser asked: "fix the planning bug"\n' - "## Goal\nFix the MES planning defect.\n" + "## Goal\nFix the planning defect.\n" "## Active State\n- worktree ready\n" "## Détail complet\nRésumé intégral : lire la note " - "/opt/obsidian/vault/MES/Hermes/Compactions/compaction-test.md" + "docs/compaction-test.md" ) marker = envelope + "\n" + digest + "\n--- END OF CONTEXT SUMMARY ---" script = f""" From 44b2a5710922f50df93ed6e3ab78bad0e3c3e70e Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Fri, 21 Aug 2026 00:34:23 +0000 Subject: [PATCH 143/446] fix(ui): preserve all pre-window compaction cards --- static/ui.js | 22 +++-- tests/test_anchor_fallback_ownership.py | 2 +- tests/test_compaction_card_display.py | 8 +- ...st_issue500_message_list_virtualization.py | 85 +++++++++++++++++-- 4 files changed, 95 insertions(+), 22 deletions(-) diff --git a/static/ui.js b/static/ui.js index 4948164f..8d09cccd 100644 --- a/static/ui.js +++ b/static/ui.js @@ -15374,21 +15374,21 @@ function _loadedCompactionMarkerRawIdxs(messages){ // remain visible at the top of that tail. Anchoring it to an old assistant/tool // turn can bury it inside a hidden worklog, which makes compaction look absent. function _selectCompactionCardPlacements(markerRawIdxs,firstRenderedRawIdx){ + const preWindowMarkers=[]; const inlineMarkers=[]; - let latestPreWindowMarker=null; const boundary=Number.isFinite(firstRenderedRawIdx)?firstRenderedRawIdx:-1; for(const value of Array.isArray(markerRawIdxs)?markerRawIdxs:[]){ const rawIdx=Number(value); if(!Number.isInteger(rawIdx)||rawIdx<0) continue; - if(rawIdx_compactionCardEntry(markerRawIdx,'pre-window')); const compactionCardNodes=compactionPlacements.inlineMarkers.map(markerRawIdx=>_compactionCardEntry(markerRawIdx,'inline')); const referenceNode=(!compressionState && loadedCompactionRawIdxs.length===0 && _shouldShowSettledCompressionReference(referenceText) && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary)) ? (()=>{const row=document.createElement('div');row.innerHTML=`
${_compressionReferenceCardHtml(referenceText,false)}${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}
`;const node=row.firstElementChild;if(node&&preservedCompressionTaskMessages.length) node.setAttribute('data-compaction-task-owner','1');return node;})() @@ -17149,7 +17147,7 @@ function renderMessages(options){ } } const preWindowInsertion=_insertCompactionCardNodes( - preWindowCompactionCard?[preWindowCompactionCard]:[], + preWindowCompactionCards, compactionPlacements.taskOwner, node=>_pinCompactionCardAtTop(inner,node) ); diff --git a/tests/test_anchor_fallback_ownership.py b/tests/test_anchor_fallback_ownership.py index ff0a1c45..50c041e7 100644 --- a/tests/test_anchor_fallback_ownership.py +++ b/tests/test_anchor_fallback_ownership.py @@ -684,7 +684,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds function _formatTurnDuration() {{ return ''; }} function _loadedCompactionMarkerRawIdxs() {{ return []; }} function _selectCompactionCardPlacements() {{ - return {{ inlineMarkers: [], latestPreWindowMarker: null, taskOwner: null }}; + return {{ preWindowMarkers: [], inlineMarkers: [], taskOwner: null }}; }} function _insertCompactionCardNodes() {{ return {{ insertedNodes: [], taskOwnerNode: null }}; diff --git a/tests/test_compaction_card_display.py b/tests/test_compaction_card_display.py index b57abfaa..f2595e25 100644 --- a/tests/test_compaction_card_display.py +++ b/tests/test_compaction_card_display.py @@ -111,30 +111,30 @@ const assert = require('assert'); assert.deepStrictEqual( _selectCompactionCardPlacements([3, 11, 49, 50, 88], 50), {{ + preWindowMarkers: [3, 11, 49], inlineMarkers: [50, 88], - latestPreWindowMarker: 49, taskOwner: {{kind: 'inline', rawIdx: 88}}, }}, ); assert.deepStrictEqual( _selectCompactionCardPlacements([3, 11, 49], 50), {{ + preWindowMarkers: [3, 11, 49], inlineMarkers: [], - latestPreWindowMarker: 49, taskOwner: {{kind: 'pre-window', rawIdx: 49}}, }}, ); assert.deepStrictEqual( _selectCompactionCardPlacements([50, 88], 50), {{ + preWindowMarkers: [], inlineMarkers: [50, 88], - latestPreWindowMarker: null, taskOwner: {{kind: 'inline', rawIdx: 88}}, }}, ); assert.deepStrictEqual( _selectCompactionCardPlacements([], 50), - {{inlineMarkers: [], latestPreWindowMarker: null, taskOwner: null}}, + {{preWindowMarkers: [], inlineMarkers: [], taskOwner: null}}, ); """ _run_node(script) diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 3d5ff232..6994c36b 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -120,18 +120,35 @@ const MESSAGE_VIRTUAL_THRESHOLD_ROWS = 80; const MESSAGE_VIRTUAL_BUFFER_PX = 0; eval(extractFunc('_messageVirtualDefaultHeightForRole')); eval(extractFunc('_messageVirtualWindow')); +eval(extractFunc('_compactionDigestText')); +eval(extractFunc('_compactionCardPreview')); +eval(extractFunc('_compressionReferenceCardHtml')); eval(extractFunc('_selectCompactionCardPlacements')); eval(extractFunc('_pinCompactionCardAtTop')); eval(extractFunc('_insertCompactionCardNodes')); eval(extractFunc('_insertPreservedCompressionTaskFallback')); eval(extractFunc('_insertCompressionLikeNodeByRawIdx')); +class MiniClassList { + constructor(...names) { this.names = new Set(names); } + add(...names) { names.forEach(name => this.names.add(name)); } + contains(name) { return this.names.has(name); } + toggle(name) { + if (this.names.has(name)) { + this.names.delete(name); + return false; + } + this.names.add(name); + return true; + } +} class MiniNode { constructor(label, rawIdx = null) { this.label = label; this.rawIdx = rawIdx; this.children = []; this.parentElement = null; + this.classList = new MiniClassList(); } appendChild(node) { if (node.parentElement) node.remove(); @@ -153,19 +170,60 @@ class MiniNode { if (index >= 0) this.parentElement.children.splice(index, 1); this.parentElement = null; } + closest(selector) { + let node = this; + while (node) { + if (selector === '.tool-card' && node.classList.contains('tool-card')) return node; + node = node.parentElement; + } + return null; + } } function _assistantTurnBlocks() { return null; } +function _engineAwareCompressionCopy() { + return {label: 'Context compacted', preview: 'Reference only'}; +} +function esc(value) { + return String(value).replace(/&/g, '&').replace(/"/g, '"').replace(/
`; } +function t(key) { return key; } function taskCount(node) { return (node.label === 'task' ? 1 : 0) + node.children.reduce((total, child) => total + taskCount(child), 0); } function card(kind, rawIdx, taskOwner) { const node = new MiniNode(`${kind}:${rawIdx}`, rawIdx); + const marker = `[CONTEXT COMPACTION — REFERENCE ONLY]\n## Goal\nDigest ${rawIdx}`; + const html = _compressionReferenceCardHtml(marker, false); + const handler = html.match(/class="tool-card-header" onclick="([^"]+)"/); + assert.ok(handler, `card ${rawIdx} must expose its disclosure handler`); + const disclosure = new MiniNode(`disclosure:${rawIdx}`); + disclosure.classList.add('tool-card'); + const header = new MiniNode(`header:${rawIdx}`); + header.click = () => Function(handler[1]).call(header); + disclosure.appendChild(header); + node.appendChild(disclosure); + node.disclosure = disclosure; + node.markerHtml = html; if (taskOwner && taskOwner.kind === kind && taskOwner.rawIdx === rawIdx) { node.appendChild(new MiniNode('task')); } return {kind, rawIdx, node}; } +function compactionCards(node) { + return node.children.filter(child => /^pre-window:|^inline:/.test(child.label)); +} +function reopen(cardNode) { + assert.strictEqual(cardNode.disclosure.classList.contains('open'), false); + cardNode.children[0].children[0].click(); + assert.strictEqual(cardNode.disclosure.classList.contains('open'), true); + cardNode.children[0].children[0].click(); + assert.strictEqual(cardNode.disclosure.classList.contains('open'), false); + cardNode.children[0].children[0].click(); + assert.strictEqual(cardNode.disclosure.classList.contains('open'), true); + assert.ok(cardNode.markerHtml.includes(`Digest ${cardNode.rawIdx}`)); +} const visible = Array.from({length: 100}, (_, index) => ({ rawIdx: index * 2, @@ -196,10 +254,12 @@ function renderScenario(markers) { userRows = new Map(); let taskOwnerNode = null; - if (placements.latestPreWindowMarker !== null) { - const entry = card('pre-window', placements.latestPreWindowMarker, placements.taskOwner); + if (placements.preWindowMarkers.length) { + const entries = placements.preWindowMarkers.map(rawIdx => + card('pre-window', rawIdx, placements.taskOwner) + ); const mounted = _insertCompactionCardNodes( - [entry], + entries, placements.taskOwner, node => _pinCompactionCardAtTop(inner, node), ); @@ -234,20 +294,35 @@ function renderScenario(markers) { const mixed = renderScenario([1, 79, 159, 170]); assert.deepStrictEqual(mixed.placements, { + preWindowMarkers: [1, 79, 159], inlineMarkers: [170], - latestPreWindowMarker: 159, taskOwner: {kind: 'inline', rawIdx: 170}, }); -assert.strictEqual(mixed.inner.children[0].label, 'pre-window:159'); +const mixedCards = compactionCards(mixed.inner); +assert.deepStrictEqual(mixedCards.map(node => node.rawIdx), [1, 79, 159, 170]); +reopen(mixedCards[0]); +assert.strictEqual(mixedCards[1].disclosure.classList.contains('open'), false); +reopen(mixedCards[1]); assert.ok( mixed.inner.children.indexOf(mixed.taskOwnerNode) < mixed.inner.children.findIndex(node => node.rawIdx > 170), ); +assert.deepStrictEqual( + mixedCards.filter(node => node.children.some(child => child.label === 'task')).map(node => node.rawIdx), + [170], +); assert.strictEqual(taskCount(mixed.inner), 1); assert.strictEqual(mixed.fallbackInserted, false); const preWindowOnly = renderScenario([1, 79, 159]); +assert.deepStrictEqual(compactionCards(preWindowOnly.inner).map(node => node.rawIdx), [1, 79, 159]); assert.strictEqual(preWindowOnly.taskOwnerNode.label, 'pre-window:159'); +assert.deepStrictEqual( + compactionCards(preWindowOnly.inner) + .filter(node => node.children.some(child => child.label === 'task')) + .map(node => node.rawIdx), + [159], +); assert.strictEqual(taskCount(preWindowOnly.inner), 1); assert.strictEqual(preWindowOnly.fallbackInserted, false); From 8fe3325e12eac3feacb0b8f9f8055c38adea00e5 Mon Sep 17 00:00:00 2001 From: Anthony Ruiz Date: Thu, 17 Sep 2026 22:49:46 +0000 Subject: [PATCH 144/446] fix(ui): keep current summary visible next to stale compaction markers The settled current-summary card was only created when no compaction marker was loaded at all. A transcript with older (stale) markers plus a newer `compression_anchor_summary` that no loaded marker references therefore lost the current summary, which master still rendered. Decide the fallback from `_latestCompressionReferenceMessage()` (`referenceMessageRawIdx < 0`) instead of marker presence, and feed that decision into `_selectCompactionCardPlacements()` before any card node is built so the fallback is the single preserved-task owner and marker cards never attach the task list a second time. Add a render-level regression that executes the real `renderMessages()` over 220 messages, three stale markers, one preserved task-list message and a newer summary: the current summary is visible and exactly one task owner is rendered, in full, virtualized and server-truncated windows. --- static/ui.js | 38 +- tests/test_auto_compression_card.py | 2 +- tests/test_compaction_card_display.py | 14 + ...est_compaction_current_summary_fallback.py | 528 ++++++++++++++++++ 4 files changed, 570 insertions(+), 12 deletions(-) create mode 100644 tests/test_compaction_current_summary_fallback.py diff --git a/static/ui.js b/static/ui.js index 8d09cccd..e2c7b690 100644 --- a/static/ui.js +++ b/static/ui.js @@ -15373,7 +15373,11 @@ function _loadedCompactionMarkerRawIdxs(messages){ // A settled compaction whose marker sits before the server-loaded tail must // remain visible at the top of that tail. Anchoring it to an old assistant/tool // turn can bury it inside a hidden worklog, which makes compaction look absent. -function _selectCompactionCardPlacements(markerRawIdxs,firstRenderedRawIdx){ +// `currentSummaryFallback` is true when the session's current summary matches +// no loaded marker and therefore renders as its own settled card. That card is +// the newest compaction the user can see, so it owns the preserved task cards +// and no marker card may attach them again. +function _selectCompactionCardPlacements(markerRawIdxs,firstRenderedRawIdx,currentSummaryFallback=false){ const preWindowMarkers=[]; const inlineMarkers=[]; const boundary=Number.isFinite(firstRenderedRawIdx)?firstRenderedRawIdx:-1; @@ -15383,11 +15387,13 @@ function _selectCompactionCardPlacements(markerRawIdxs,firstRenderedRawIdx){ if(rawIdx{ const markerMsg=S.messages[markerRawIdx]; let raw=''; @@ -17098,8 +17114,8 @@ function renderMessages(options){ }; const preWindowCompactionCards=compactionPlacements.preWindowMarkers.map(markerRawIdx=>_compactionCardEntry(markerRawIdx,'pre-window')); const compactionCardNodes=compactionPlacements.inlineMarkers.map(markerRawIdx=>_compactionCardEntry(markerRawIdx,'inline')); - const referenceNode=(!compressionState && loadedCompactionRawIdxs.length===0 && _shouldShowSettledCompressionReference(referenceText) && (sessionCompressionAnchor!==null || sessionCompressionAnchorKey || sessionCompressionSummary)) - ? (()=>{const row=document.createElement('div');row.innerHTML=`
${_compressionReferenceCardHtml(referenceText,false)}${_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages)}
`;const node=row.firstElementChild;if(node&&preservedCompressionTaskMessages.length) node.setAttribute('data-compaction-task-owner','1');return node;})() + const referenceNode=showCurrentSummaryFallback + ? (()=>{const row=document.createElement('div');row.innerHTML=`
${_compressionReferenceCardHtml(referenceText,false)}${referenceNodeOwnsTasks?_preservedCompressionTaskListCardsHtml(preservedCompressionTaskMessages):''}
`;const node=row.firstElementChild;if(node&&referenceNodeOwnsTasks) node.setAttribute('data-compaction-task-owner','1');return node;})() : null; let referenceNodePinnedAtTop=false; let preservedCompressionTaskOwnerNode=null; @@ -17142,7 +17158,7 @@ function renderMessages(options){ // Keep the settled compacted-context card immediately visible in a long, // tail-loaded conversation. Put it in flow (not inside an old tool turn). referenceNodePinnedAtTop=_pinSettledCompressionReferenceAtTop(inner,referenceNode,referenceMessageRawIdx); - if(referenceNodePinnedAtTop&&referenceNode?.parentElement&&preservedCompressionTaskMessages.length){ + if(referenceNodePinnedAtTop&&referenceNode?.parentElement&&referenceNodeOwnsTasks){ preservedCompressionTaskOwnerNode=referenceNode; } } @@ -17780,7 +17796,7 @@ function renderMessages(options){ const referenceInserted=referenceNode&&referenceMessageRawIdx>=0 ?_insertCompressionLikeNodeByRawIdx(referenceNode,referenceMessageRawIdx) :_insertCompressionLikeNode(referenceNode); - if(referenceInserted&&referenceNode?.parentElement&&preservedCompressionTaskMessages.length){ + if(referenceInserted&&referenceNode?.parentElement&&referenceNodeOwnsTasks){ preservedCompressionTaskOwnerNode=referenceNode; } } diff --git a/tests/test_auto_compression_card.py b/tests/test_auto_compression_card.py index bbe16607..3e336343 100644 --- a/tests/test_auto_compression_card.py +++ b/tests/test_auto_compression_card.py @@ -1079,7 +1079,7 @@ def test_context_anchor_reference_uses_session_summary_fallback(): assert "const referenceText=(()=>{" in src assert "if(!referenceMessage) return sessionCompressionSummary;" in src assert "return segment!==null?segment:raw;" in src - assert "loadedCompactionRawIdxs.length===0 && _shouldShowSettledCompressionReference(referenceText)" in src + assert "referenceMessageRawIdx<0 && _shouldShowSettledCompressionReference(referenceText)" in src assert "!_isContextCompactionText(referenceText)" in src diff --git a/tests/test_compaction_card_display.py b/tests/test_compaction_card_display.py index f2595e25..5e5a8ff7 100644 --- a/tests/test_compaction_card_display.py +++ b/tests/test_compaction_card_display.py @@ -136,6 +136,20 @@ assert.deepStrictEqual( _selectCompactionCardPlacements([], 50), {{preWindowMarkers: [], inlineMarkers: [], taskOwner: null}}, ); +// A current-summary fallback (no loaded marker matches the session summary) +// owns the preserved tasks even when stale markers are still loaded. +assert.deepStrictEqual( + _selectCompactionCardPlacements([3, 11, 49, 88], 50, true), + {{ + preWindowMarkers: [3, 11, 49], + inlineMarkers: [88], + taskOwner: {{kind: 'current-summary', rawIdx: -1}}, + }}, +); +assert.deepStrictEqual( + _selectCompactionCardPlacements([], 50, true), + {{preWindowMarkers: [], inlineMarkers: [], taskOwner: {{kind: 'current-summary', rawIdx: -1}}}}, +); """ _run_node(script) diff --git a/tests/test_compaction_current_summary_fallback.py b/tests/test_compaction_current_summary_fallback.py new file mode 100644 index 00000000..d0ec6aaf --- /dev/null +++ b/tests/test_compaction_current_summary_fallback.py @@ -0,0 +1,528 @@ +"""Render-level regression: stale compaction markers must not hide the current summary. + +A session can carry historical ``[CONTEXT COMPACTION]`` markers in its loaded +transcript while ``session.compression_anchor_summary`` already describes a +newer compaction that no loaded marker references. ``renderMessages()`` must +still render that current summary as its own settled card (as ``master`` did) +and the preserved task cards must be attached to exactly one owner. + +The scenario drives the real ``renderMessages()`` through the same Node +harness used by ``tests/test_anchor_fallback_ownership.py``; only the DOM +shim gains a minimal ``innerHTML`` → ``firstElementChild`` parser so the +compaction card nodes built by ``renderMessages()`` are real elements. +""" + +from __future__ import annotations + +import json +import textwrap + +from tests.test_anchor_fallback_ownership import ( + _function_source, + _run_node_script, + _ui_js, +) + + +_REAL_FUNCTIONS = ( + "_transparentStreamOrderedParts", + "_legacySettledFallbackHasToolMetadata", + "_isContextCompactionText", + "_isContextCompactionMessage", + "_compactionSummarySegment", + "_isPreservedCompressionTaskListMarkerText", + "_isPreservedCompressionTaskListMessage", + "_preservedCompressionTaskListPreview", + "_latestTodoToolItems", + "_hasActiveTodoItems", + "_latestPreservedCompressionTaskListMessages", + "_compressionStatusCardHtml", + "_preservedCompressionTaskListCardHtml", + "_preservedCompressionTaskListCardsHtml", + "_latestCompressionReferenceMessage", + "_shouldShowSettledCompressionReference", + "_compactionDigestText", + "_compactionCardPreview", + "_compressionReferenceCardHtml", + "_loadedCompactionMarkerRawIdxs", + "_selectCompactionCardPlacements", + "_insertCompactionCardNodes", + "_insertPreservedCompressionTaskFallback", + "_pinCompactionCardAtTop", + "_pinSettledCompressionReferenceAtTop", + "renderMessages", +) + +_DOM_SHIM = r""" +class FakeClassList { + constructor(el) { this.el = el; } + _set() { return new Set(String(this.el.className || '').split(/\s+/).filter(Boolean)); } + contains(name) { return this._set().has(name); } + add(...names) { + const set = this._set(); + names.forEach((name) => set.add(name)); + this.el.className = Array.from(set).join(' '); + } + remove(...names) { + const set = this._set(); + names.forEach((name) => set.delete(name)); + this.el.className = Array.from(set).join(' '); + } + toggle(name) { + const set = this._set(); + if (set.has(name)) set.delete(name); else set.add(name); + this.el.className = Array.from(set).join(' '); + return set.has(name); + } +} +function unescapeHtml(value) { + return String(value) + .replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<') + .replace(/>/g, '>').replace(/&/g, '&'); +} +// Minimal HTML → FakeElement tree parser. Only used when renderMessages() +// reads `firstElementChild` after assigning innerHTML (the compaction / +// reference / preserved-task card nodes). Text nodes are dropped; the +// assertions below rely on data-* attributes only. +function parseHtmlInto(parent, html) { + const tagRe = /<(\/)?([A-Za-z][\w-]*)((?:\s+[\w:-]+(?:="[^"]*")?)*)\s*(\/)?>/g; + const stack = [parent]; + let match; + while ((match = tagRe.exec(html))) { + if (match[1]) { + if (stack.length > 1) stack.pop(); + continue; + } + const el = new FakeElement(match[2]); + const attrRe = /([\w:-]+)(?:="([^"]*)")?/g; + let attr; + while ((attr = attrRe.exec(match[3] || ''))) { + el.setAttribute(attr[1], attr[2] === undefined ? '' : unescapeHtml(attr[2])); + } + stack[stack.length - 1].appendChild(el); + if (!match[4]) stack.push(el); + } +} +class FakeElement { + constructor(tag = 'div') { + this.tagName = tag.toUpperCase(); + this.children = []; + this.parentElement = null; + this.dataset = {}; + this.attributes = {}; + this.className = ''; + this.id = ''; + this.hidden = false; + this._innerHTML = ''; + this._pendingHtml = null; + this.style = {}; + this.classList = new FakeClassList(this); + } + set innerHTML(value) { + this._innerHTML = String(value); + for (const child of this.children) child.parentElement = null; + this.children = []; + this._pendingHtml = this._innerHTML ? this._innerHTML : null; + } + get innerHTML() { return this._innerHTML; } + get firstElementChild() { + if (this._pendingHtml !== null) { + const html = this._pendingHtml; + this._pendingHtml = null; + parseHtmlInto(this, html); + } + return this.children[0] || null; + } + appendChild(child) { + if (child.parentElement) child.remove(); + child.parentElement = this; + this.children.push(child); + return child; + } + insertBefore(child, ref) { + if (child.parentElement) child.remove(); + child.parentElement = this; + const idx = this.children.indexOf(ref); + if (idx < 0) this.children.push(child); + else this.children.splice(idx, 0, child); + return child; + } + remove() { + if (!this.parentElement) return; + const idx = this.parentElement.children.indexOf(this); + if (idx >= 0) this.parentElement.children.splice(idx, 1); + this.parentElement = null; + } + setAttribute(name, value) { + this.attributes[name] = String(value); + if (name === 'id') this.id = String(value); + if (name === 'class') this.className = String(value); + if (name.startsWith('data-')) this.dataset[dataKey(name)] = String(value); + } + getAttribute(name) { + if (name === 'id') return this.id || null; + if (name === 'class') return this.className || null; + if (name.startsWith('data-')) { + const value = this.dataset[dataKey(name)]; + return value === undefined ? null : String(value); + } + return this.attributes[name] === undefined ? null : this.attributes[name]; + } + removeAttribute(name) { + delete this.attributes[name]; + if (name.startsWith('data-')) delete this.dataset[dataKey(name)]; + } + matches(selector) { return matchesSelector(this, selector); } + closest(selector) { + let node = this; + while (node) { + if (matchesSelector(node, selector)) return node; + node = node.parentElement; + } + return null; + } + querySelectorAll(selector) { + const found = []; + const visit = (node) => { + for (const child of node.children) { + if (matchesSelector(child, selector)) found.push(child); + visit(child); + } + }; + visit(this); + return found; + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] || null; + } + insertAdjacentHTML() {} +} +function dataKey(name) { + return String(name).slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); +} +function matchesSelector(el, selector) { + return String(selector || '').split(',').some((part) => matchesSimple(el, part.trim())); +} +function matchesSimple(el, selector) { + if (!selector) return false; + const negated = []; + const baseSelector = selector.replace(/:not\(([^()]*)\)/g, (_, inner) => { + negated.push(String(inner || '').trim()); + return ''; + }).trim(); + if (negated.some((inner) => inner && matchesSimple(el, inner))) return false; + if (!baseSelector) return true; + const classMatches = [...baseSelector.matchAll(/\.([A-Za-z0-9_-]+)/g)].map((m) => m[1]); + if (classMatches.some((name) => !el.classList.contains(name))) return false; + const attrMatches = [...baseSelector.matchAll(/\[([^=\]]+)(?:=["']?([^"'\]]+)["']?)?\]/g)]; + for (const match of attrMatches) { + const value = el.getAttribute(match[1]); + if (value === null) return false; + if (match[2] !== undefined && String(value) !== String(match[2])) return false; + } + const idMatch = baseSelector.match(/#([A-Za-z0-9_-]+)/); + if (idMatch && el.id !== idMatch[1]) return false; + const tagMatch = baseSelector.match(/^[A-Za-z][A-Za-z0-9_-]*/); + if (tagMatch && el.tagName.toLowerCase() !== tagMatch[0].toLowerCase()) return false; + return true; +} + +const elements = { + msgInner: new FakeElement('div'), + emptyState: new FakeElement('div'), +}; +global.window = {}; +global.document = { + createElement: (tag) => new FakeElement(tag), + getElementById: (id) => elements[id] || null, +}; +global.performance = { now: () => 1 }; +global.requestAnimationFrame = (fn) => fn(); +global.setTimeout = (fn) => fn(); +function $(id) { return elements[id] || null; } +function isTransparentStream() { return false; } +function isCompactWorklogMode() { return true; } +function isSimplifiedToolCalling() { return true; } +function t(key) { return key; } +function li() { return ''; } +function esc(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} +function msgContent(message) { + if (Array.isArray(message.content)) { + return message.content.filter((p) => p && p.type === 'text').map((p) => p.text || p.content || '').join('\n'); + } + return String(message.content || ''); +} +let S; +const INFLIGHT = {}; +let _loadingSessionId = null; +let _messageRenderWindowSid = null; +let _messageUserUnpinned = false; +let _programmaticScroll = false; +let _programmaticScrollSetAt = 0; +let _sessionHtmlCacheSid = null; +let _messagesTruncated = false; +let _oldestIdx = 0; +let virtualStart = 0; +const _sessionHtmlCache = new Map(); +const _recycleStash = new Map(); +const _msgNodeRecycleEnabled = false; +const _recycleResetAttrs = []; +const _ERR_MSG_RE = /__never__/; + +function _captureMessageScrollSnapshot() { return null; } +function _resetMessageRenderWindow(sid) { _messageRenderWindowSid = sid; } +function _getVisibleMessagesWithIdx() { + return S.messages.map((m, rawIdx) => ( + m && m.role !== 'tool' && !_isContextCompactionMessage(m) && !_isPreservedCompressionTaskListMessage(m) + ) ? { m, rawIdx } : null).filter(Boolean); +} +function _messageVirtualKeepTailCount() { return 20; } +function _currentMessageVirtualWindow(vis) { + const start = Math.min(virtualStart, vis.length); + return { + virtualized: start > 0, + start, + end: vis.length, + topPad: start > 0 ? 120 * start : 0, + bottomPad: 0, + total: vis.length, + tailStart: vis.length, + }; +} +function _messageVirtualWindowKeyFor() { return 'all'; } +function _messageRenderCacheSignature() { return 'sig'; } +function _compressionStateForCurrentSession() { return null; } +function clearCompressionUi() {} +function _handoffStateForCurrentSession() { return null; } +function _captureWorklogDetailDisclosureState() { return null; } +function _applySessionNavigationPrefs() {} +function _messageVirtualSpacer() { return new FakeElement('div'); } +function _wireMessageWindowLoadEarlierButton() {} +function _compressionAnchorIndex() { return null; } +function _assistantTurnFinalVisibleContentMap() { return new Map(); } +function _assistantTurnVisibleContentMap() { return new Map(); } +function _engineAwareCompressionCopy() { + return { label: 'context_compaction_label', preview: 'reference_only_label' }; +} +function _createAssistantTurn() { + const turn = new FakeElement('div'); + turn.className = 'assistant-turn'; + const blocks = new FakeElement('div'); + blocks.className = 'assistant-turn-blocks'; + turn.appendChild(blocks); + return turn; +} +function _assistantTurnBlocks(turn) { return turn ? turn.querySelector('.assistant-turn-blocks') : null; } +function _setLatestAssistantTurnLandmark() {} +function _assistantRoleHtml() { return ''; } +function _userMessageDomId(rawIdx) { return `user-${rawIdx}`; } +function _messageSessionIndexForRawIdx(rawIdx) { return rawIdx; } +function _messageViewportAnchorKeyForMessage() { return 'k'; } +function _stripAttachedFilesMarkerForDisplay(value) { return String(value || ''); } +function _stripWorkspaceDisplayPrefix(value) { return String(value || ''); } +function _stripLeadingAssistantThinkingMarkup(value) { return String(value || ''); } +function _getCachedRender(value) { return String(value || ''); } +function _formatInServerTz() { return ''; } +function _formatMessageFooterTimestamp() { return ''; } +function _questionJumpButtonHtml() { return ''; } +function _formatTurnTps() { return ''; } +function isTpsDisplayEnabled() { return false; } +function _renderAttachmentHtml() { return ''; } +function _isMarkerOnlyAssistantCompressionMessage() { return false; } +function _isAssistantEmptyPlaceholderContent() { return false; } +function _assistantTurnAnchorSettledFinalAnswer() { return null; } +function _worklogReasoningTextFromMessage() { return ''; } +function _assistantMessageBelongsInWorklog() { return false; } +function _assistantThinkingBelongsInWorklog() { return false; } +function _assistantReasoningPayloadText() { return ''; } +function _statusCardHtml() { return ''; } +function _collectHandoffSummaryStates() { return []; } +function _handoffCardsNode() { return null; } +function renderCompressionUi() {} +function _assistantToolAnchorIdxForMessage(messages, rawIdx) { return rawIdx; } +function _cliToolResultSnippet(value) { return String(value || ''); } +function _cliPatchSnippetFromArgs() { return ''; } +function _cliToolCardSnippet(value) { return String(value || ''); } +function _cliToolCardHasDiffSnippet() { return false; } +function _toolArgsSnapshot(args) { return args || {}; } +function _worklogReasonHtmlFromAnchor() { return ''; } +function _normalizeThinkingEchoCompare(value) { return String(value || ''); } +function _toolWorklogListEl(group) { return group; } +function ensureActivityGroup(parent) { + const group = new FakeElement('div'); + if (parent) parent.appendChild(group); + return group; +} +function _appendWorklogStep() {} +function _syncToolCallGroupSummary() {} +function _restoreWorklogDetailDisclosureState() {} +function _scrollAfterMessageRender() {} +function _maybeRecoverVirtualizedBlankViewport() { return false; } +function _updateMessageVirtualMeasurements() {} +function postProcessRenderedMessages() {} +function _postProcessWithAnchorSuppression() {} +function _formatGatewayModelLabel() { return ''; } +function _gatewayRoutingFailoverText() { return ''; } +function _gatewayModelWarningText() { return ''; } +function _usedModelTurnChipLabel() { return ''; } +function _formatTurnDuration() { return ''; } +function _renderSettledAnchorSceneForMessage() { return false; } +""" + +_SCENARIO = r""" +const TOTAL = 220; +const STALE_MARKER_IDXS = [40, 100, 160]; +const TASK_LIST_IDX = 161; +const CURRENT_TOKEN = 'CURRENT-SUMMARY-TOKEN-7124'; +const TASK_MARKER = '[Your active task list was preserved across context compression]'; + +function staleMarker(rawIdx) { + return '[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted into the summary below.\n' + + '## Historical Task Snapshot\nUser asked: "stale request ' + rawIdx + '"\n' + + '## Goal\nSTALE-MARKER-' + rawIdx + ' historical state\n--- END OF CONTEXT SUMMARY ---'; +} +function buildMessages() { + const messages = []; + for (let i = 0; i < TOTAL; i++) { + if (STALE_MARKER_IDXS.includes(i)) { + messages.push({ role: 'assistant', content: staleMarker(i) }); + } else if (i === TASK_LIST_IDX) { + messages.push({ role: 'user', content: TASK_MARKER + '\n- [ ] finish the compaction fix' }); + } else if (i % 2 === 0) { + messages.push({ role: 'user', content: 'question ' + i }); + } else { + messages.push({ role: 'assistant', content: 'answer ' + i }); + } + } + return messages; +} +function render(opts) { + elements.msgInner = new FakeElement('div'); + _sessionHtmlCache.clear(); + _sessionHtmlCacheSid = null; + virtualStart = opts.virtualStart || 0; + _messagesTruncated = !!opts.truncated; + _oldestIdx = opts.truncated ? 50 : 0; + S = { + session: { + session_id: opts.sid, + compression_anchor_summary: opts.summary, + }, + messages: buildMessages(), + toolCalls: [], + busy: false, + }; + renderMessages(); + const inner = elements.msgInner; + const cards = inner.querySelectorAll('[data-compression-card]'); + const rawText = (node) => String(node.getAttribute('data-raw-text') || ''); + const summaryCards = cards.filter((node) => rawText(node).includes(CURRENT_TOKEN)); + const taskCards = cards.filter((node) => rawText(node).startsWith(TASK_MARKER)); + const owners = inner.querySelectorAll('[data-compaction-task-owner]'); + const ownerHasSummary = owners.length === 1 + && owners[0].querySelectorAll('[data-compression-card]').some((node) => rawText(node).includes(CURRENT_TOKEN)); + const ownerHasTask = owners.length === 1 + && owners[0].querySelectorAll('[data-compression-card]').some((node) => rawText(node).startsWith(TASK_MARKER)); + const placements = inner.querySelectorAll('[data-compaction-placement]'); + const summaryTopIdx = summaryCards.length + ? inner.children.findIndex((child) => child.querySelectorAll('[data-compression-card]').some((node) => rawText(node).includes(CURRENT_TOKEN))) + : -1; + const loadOlderIdx = inner.children.findIndex((child) => child.id === 'loadOlderIndicator'); + return { + renderedRows: inner.querySelectorAll('[data-msg-idx]').length, + currentSummaryVisible: summaryCards.length > 0, + currentSummaryCards: summaryCards.length, + staleCards: placements.map((node) => node.getAttribute('data-compaction-placement')), + staleCardTexts: placements.map((node) => { + const card = node.querySelector('[data-compression-card]'); + return card ? (/STALE-MARKER-(\d+)/.exec(rawText(card))?.[1] || null) : null; + }), + taskCards: taskCards.length, + taskOwners: owners.length, + ownerHasSummary, + ownerHasTask, + standaloneFallbacks: inner.querySelectorAll('[data-compaction-task-fallback]').length, + summaryTopIdx, + loadOlderIdx, + }; +} + +const NEWER_SUMMARY = 'Compacted context: ' + CURRENT_TOKEN + ' — newest state after the last compaction'; +const fullWindow = render({ sid: 'full', summary: NEWER_SUMMARY }); +// Visible index 170 maps to raw idx >= 174 (three markers and the task-list +// message are not renderable rows), so every stale marker is pre-window. +const virtualWindow = render({ sid: 'virtual', summary: NEWER_SUMMARY, virtualStart: 170 }); +const truncatedTail = render({ sid: 'truncated', summary: NEWER_SUMMARY, truncated: true }); +// Control: the current summary IS referenced by the latest loaded marker, so +// the marker card renders it and no second (fallback) card may appear. +const matchingSummary = render({ sid: 'matching', summary: 'STALE-MARKER-160 historical state' }); +const matchingCards = elements.msgInner.querySelectorAll('[data-compression-card]') + .filter((node) => String(node.getAttribute('data-raw-text') || '').includes('STALE-MARKER-160')); +console.log(JSON.stringify({ + fullWindow, + virtualWindow, + truncatedTail, + matchingSummary: { + ...matchingSummary, + latestMarkerCards: matchingCards.length, + }, +})); +""" + + +def _scenario_script() -> str: + js = _ui_js() + evals = "\n".join( + f"eval({json.dumps(_function_source(js, name))});" for name in _REAL_FUNCTIONS + ) + return textwrap.dedent(_DOM_SHIM) + "\n" + evals + "\n" + textwrap.dedent(_SCENARIO) + + +def test_stale_markers_do_not_suppress_current_summary_and_task_owner_is_unique(): + result = json.loads(_run_node_script(_scenario_script())) + + full = result["fullWindow"] + assert full["renderedRows"] > 200 + assert full["currentSummaryVisible"] is True + assert full["currentSummaryCards"] == 1 + assert full["staleCards"] == ["inline", "inline", "inline"] + assert full["staleCardTexts"] == ["40", "100", "160"] + assert full["taskCards"] == 1 + assert full["taskOwners"] == 1 + assert full["ownerHasSummary"] is True + assert full["ownerHasTask"] is True + assert full["standaloneFallbacks"] == 0 + + virtual = result["virtualWindow"] + assert virtual["currentSummaryVisible"] is True + assert virtual["currentSummaryCards"] == 1 + assert virtual["staleCards"] == ["pre-window", "pre-window", "pre-window"] + assert virtual["staleCardTexts"] == ["40", "100", "160"] + assert virtual["taskCards"] == 1 + assert virtual["taskOwners"] == 1 + assert virtual["ownerHasSummary"] is True + assert virtual["standaloneFallbacks"] == 0 + + truncated = result["truncatedTail"] + assert truncated["currentSummaryVisible"] is True + assert truncated["currentSummaryCards"] == 1 + assert truncated["loadOlderIdx"] >= 0 + # The settled current-summary card is pinned directly under the + # load-earlier affordance, before the stale marker cards and rows. + assert truncated["summaryTopIdx"] == truncated["loadOlderIdx"] + 1 + assert truncated["taskCards"] == 1 + assert truncated["taskOwners"] == 1 + assert truncated["ownerHasSummary"] is True + assert truncated["standaloneFallbacks"] == 0 + + matching = result["matchingSummary"] + assert matching["currentSummaryVisible"] is False + assert matching["latestMarkerCards"] == 1 + assert matching["staleCards"] == ["inline", "inline", "inline"] + assert matching["taskCards"] == 1 + assert matching["taskOwners"] == 1 + assert matching["ownerHasTask"] is True + assert matching["standaloneFallbacks"] == 0 From 48520d6473b256dcc2ce2cd9c218464a0d55a2f0 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 20 Sep 2026 04:10:20 +0000 Subject: [PATCH 145/446] docs(changelog): stamp #7124 compaction card visibility Co-authored-by: ruizanthony --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec86927..209337fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Context-compaction cards stay visible in long transcripts.** When a transcript grew past the virtualization window, the compaction markers explaining where context was compressed scrolled out of the rendered range and vanished, leaving no indication that compaction had occurred. Pre-window markers are now preserved and placed deterministically, and card placement is stable across repeated renders and window changes — verified to neither lose nor duplicate a card as the virtual window moves. Hardened during review: the settled current-summary card was created only when *no* compaction marker was loaded at all, so a transcript carrying older markers plus a newer summary that none of them referenced silently dropped the summary the session was actually operating under — marker presence is not proof that any marker matches. The fallback now keys on whether a loaded marker actually references the current summary, and participates in the single preserved-task-owner selection so the repair cannot duplicate the task card. Mid-compression, empty-summary, unmatched-anchor-key, first-compaction and matched-marker cases were each checked, and reverting the guard reproduces the original suppression. Thanks @ruizanthony. (#7124) + - **Editing or duplicating a one-shot cron job no longer returns HTTP 500.** The editable schedule field was populated from `schedule_display`, the human-readable label (`once at 2026-08-28 16:00`), and saving submitted that label straight back. The Agent's schedule parser only accepts the canonical timestamp, so it raised — and the route's existing `ValueError` guard did not cover the `update_job()` call, so the failure escaped as a 500. The field now carries the canonical `run_at` for one-shot jobs, and genuinely unparseable input returns a 400 with the parser's message instead of a server error. Natural-language recurring schedules are preserved verbatim: hardening during review found that preferring the canonical cron expression rewrote a job scheduled as `every monday 9am` into `0 9 * * 1` on every edit or duplicate — and because the Agent rebuilds the display string from whatever is submitted, the rewrite stuck and the user silently lost their phrasing. Only the `once at …` label is unparseable, so every other display form is now kept as typed. Thanks @happy5318. (#7649, #7352) - **"Webhooks" and "Cron Jobs" projects stop appearing on profiles that have none.** `get_cli_sessions(all_profiles=True)` walks every profile's `state.db` in one request, and when the walk reached a profile holding webhook or cron rows it lazily minted the system project — stamping it with the **UI-selected** profile rather than the profile whose rows triggered the mint. So a webhook project from one profile materialized on whichever profile happened to be open, deleting it from `projects.json` did not help because the next sidebar poll re-minted it, and switching profiles produced another copy. Both mint paths now tag the scanned profile. Single-profile calls keep their previous behavior, and no existing project data is overwritten — a pre-existing mis-tagged entry stays until removed, alongside the correctly tagged one. Thanks @carlotestor. (#7630) From e71c3e1f9717135d03f712790d328078e90592ad Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 2 Sep 2026 10:44:47 -0300 Subject: [PATCH 146/446] fix(picker): stop truncating model names with colons --- static/ui.js | 32 ++++-- ...test_issue7240_custom_model_colon_label.py | 102 ++++++++++++++++++ 2 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 tests/test_issue7240_custom_model_colon_label.py diff --git a/static/ui.js b/static/ui.js index e2c7b690..ea5678ee 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7386,15 +7386,35 @@ function _stripDottedModelPrefix(bare){ function getModelLabel(modelId){ if(!modelId) return 'Unknown'; const rawId=String(modelId||''); - // Preserve custom gateway model IDs exactly as configured. + // Preserve custom gateway model IDs exactly as configured. A custom id is + // `@custom:` in the plain custom lane or `@custom::` for + // a named custom provider; the provider slug may itself be an endpoint + // authority (e.g. `custom:10.8.71.41:8080`). The model portion may contain + // colons (tag/variant suffixes like `:free`, `:31b`, `:397b`) and vendor + // slashes, so only the leading provider segment is peeled (#7240). // Examples: - // @custom:ai_gateway:Qwen3.6-35B-A3B -> Qwen3.6-35B-A3B - // @custom:qwen397b-64k -> qwen397b-64k + // @custom:ai_gateway:Qwen3.6-35B-A3B -> Qwen3.6-35B-A3B + // @custom:omni:kg/stepfun/step-3.7-flash:free -> kg/stepfun/step-3.7-flash:free + // @custom:qwen397b-64k -> qwen397b-64k if(rawId.startsWith('@custom:')){ const rest=rawId.slice('@custom:'.length); - if(rest.includes(':')) return rest.slice(rest.lastIndexOf(':')+1)||rawId; - if(rest.includes('/')) return rest.slice(rest.indexOf('/')+1)||rawId; - return rest||rawId; + const sep=rest.indexOf(':'); + if(sep<0) return rest||rawId; + let model=rest.slice(sep+1); + // Endpoint-style slug (`custom:10.8.71.41:8080:model`): the `:port` belongs + // to the provider segment, mirroring the host:port slug check in + // api/config.py, so it is consumed before the model label starts. + const portMatch=/^(\d{1,5}):/.exec(model); + if(portMatch){ + const port=Number(portMatch[1]); + if(port>=1&&port<=65535){ + const host=rest.slice(0,sep).toLowerCase(); + if(host==='localhost'||host.includes('.')||/^\d{1,3}(\.\d{1,3}){3}$/.test(host)){ + model=model.slice(portMatch[0].length); + } + } + } + return model||rawId; } // Check dynamic labels first, then fall back to splitting the ID if(_dynamicModelLabels[modelId]) return _dynamicModelLabels[modelId]; diff --git a/tests/test_issue7240_custom_model_colon_label.py b/tests/test_issue7240_custom_model_colon_label.py new file mode 100644 index 00000000..e501864c --- /dev/null +++ b/tests/test_issue7240_custom_model_colon_label.py @@ -0,0 +1,102 @@ +"""Regression test for #7240 — model dropdown truncates custom model names +containing colons. + +getModelLabel() derived the label of a ``@custom:`` id from the substring +following the LAST ``:``, so a tag/variant suffix like ``:free``/``:31b``/ +``:397b`` inside the model's own name became the whole label +(``@custom:omni:kg/stepfun/step-3.7-flash:free`` rendered as ``free``). + +The fix peels only the leading provider segment (``@custom::`` — the +```` is itself optional in the plain custom lane, and may be an endpoint +authority ``host:port``), leaving the full configured model id as the label. + +Runs the live getModelLabel() via Node so drift between the test and the real +code is caught immediately. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent.resolve() +UI_JS_PATH = REPO_ROOT / "static" / "ui.js" +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + +_DRIVER = r""" +const fs = require('fs'); +const ui = fs.readFileSync(process.argv[1], 'utf8'); +// Slice getModelLabel() by function boundaries (regex literals inside it defeat +// a naive brace counter, so bound it by the next top-level function instead). +const start = ui.indexOf('function getModelLabel('); +if (start < 0) throw new Error('getModelLabel not found'); +const after = ui.indexOf('\nfunction _gatewayProviderName(', start); +if (after < 0) throw new Error('getModelLabel end boundary not found'); +const fnSrc = ui.slice(start, after); +const _dynamicModelLabels = {}; +function _fmtOllamaLabel(s){ return s; } +// getModelLabel() calls the dotted Bedrock/Vertex prefix normalizer, which lives +// just above it with two Sets it closes over. Pull all three in, or the eval'd +// function ReferenceErrors on the first dotted id. +const _stripStart = ui.indexOf('const _BEDROCK_REGION_PREFIXES'); +const _stripEnd = ui.indexOf('function getModelLabel('); +if (_stripStart < 0 || _stripStart > _stripEnd) throw new Error('_stripDottedModelPrefix block not found'); +eval(ui.slice(_stripStart, _stripEnd)); +eval(fnSrc); +const out = {}; +for (const m of JSON.parse(process.argv[2])) out[m] = getModelLabel(m); +process.stdout.write(JSON.stringify(out)); +""" + + +def _labels(model_ids): + proc = subprocess.run( + [NODE, "-e", _DRIVER, str(UI_JS_PATH), json.dumps(model_ids)], + capture_output=True, text=True, timeout=30, + ) + assert proc.returncode == 0, f"node driver failed: {proc.stderr}" + return json.loads(proc.stdout) + + +def test_custom_model_label_keeps_colon_tag_suffixes(): + """#7240: the full configured model id is the label — a trailing ``:tag`` + inside the model name must never become the whole label.""" + out = _labels([ + # Repro ids from the issue: tag/variant suffix after the model name. + "@custom:omni:kg/stepfun/step-3.7-flash:free", + "@custom:omni:kg/tencent/hy3:free", + "@custom:omni:ollamacloud/gemma4:31b", + "@custom:omni:ollamacloud/qwen3.5:397b", + # Non-colon model names must keep working. + "@custom:omni:gemini/gemini-3.1-flash-lite", + "@custom:ai_gateway:Qwen3.6-35B-A3B", + ]) + assert out["@custom:omni:kg/stepfun/step-3.7-flash:free"] == "kg/stepfun/step-3.7-flash:free" + assert out["@custom:omni:kg/tencent/hy3:free"] == "kg/tencent/hy3:free" + assert out["@custom:omni:ollamacloud/gemma4:31b"] == "ollamacloud/gemma4:31b" + assert out["@custom:omni:ollamacloud/qwen3.5:397b"] == "ollamacloud/qwen3.5:397b" + assert out["@custom:omni:gemini/gemini-3.1-flash-lite"] == "gemini/gemini-3.1-flash-lite" + assert out["@custom:ai_gateway:Qwen3.6-35B-A3B"] == "Qwen3.6-35B-A3B" + + +def test_custom_model_label_plain_lane_and_host_port_slugs(): + """#7240 regression guards: the plain custom lane (no provider slug) keeps + the whole remainder, and an endpoint-authority slug (``host:port``) is + consumed as provider plumbing rather than leaking into the label.""" + out = _labels([ + # Plain custom lane: '@custom:' with no slug separator. + "@custom:qwen397b-64k", + # Endpoint-style slug: ':port' belongs to the provider segment. + "@custom:10.8.71.41:8080:Qwen3-235B", + "@custom:localhost:11434:llama3", + # A digit-leading model after a NAMED slug is not a port — must not be + # peeled as if the slug were host:port. + "@custom:omni:11434:Qwen3", + ]) + assert out["@custom:qwen397b-64k"] == "qwen397b-64k" + assert out["@custom:10.8.71.41:8080:Qwen3-235B"] == "Qwen3-235B" + assert out["@custom:localhost:11434:llama3"] == "llama3" + assert out["@custom:omni:11434:Qwen3"] == "11434:Qwen3" From da4e4ef3168d5413cc72170dfa040b80ea0043ba Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 2 Sep 2026 13:35:38 -0300 Subject: [PATCH 147/446] fix(picker): catalog is authority for @custom labels; keep plain-lane colon models whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #7240 fix peeled the leading @custom: segment on first-colon split, but a plain-lane custom id whose model carries a tag/variant colon (@custom:ollamacloud/qwen3.5:397b) was still misread as slug=ollamacloud/qwen3.5, rendering only "397b". Two changes: - _dynamicModelLabels (the backend-populated catalog) is now consulted BEFORE the string peel: the backend knows the true provider/model split and ships an exact m.label per routing id, so catalogued ids — including plain-lane colon models and collision cases — render verbatim. - The legacy string fallback now treats a slash-bearing first segment as the model itself (a provider slug is a config key or host:port authority and never contains '/'), mirroring the '/' means routable rule api/config.py applies when building ids. Tests: add plain-lane @custom:ollamacloud/qwen3.5:397b controls, a post-catalog dynamic-label control, and a collision case proving labels stay distinct by full routing id. Driver now accepts an optional pre-hydrated catalog map. --- static/ui.js | 17 ++++- ...test_issue7240_custom_model_colon_label.py | 64 +++++++++++++++++-- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/static/ui.js b/static/ui.js index ea5678ee..37742c5a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7386,6 +7386,15 @@ function _stripDottedModelPrefix(bare){ function getModelLabel(modelId){ if(!modelId) return 'Unknown'; const rawId=String(modelId||''); + // The catalog is the authority on model identity: the backend knows the real + // provider/model split and ships an exact `m.label` per routing id, so a + // catalogued id — including a plain-lane `@custom:` id whose model contains + // colons (`@custom:ollamacloud/qwen3.5:397b`) — renders verbatim. The + // string parsing below is only a legacy fallback for ids the catalog has + // never seen (pre-hydration, stale sessions, removed providers), where a + // first-colon split alone cannot tell a `@custom::` from a + // plain-lane `@custom:` (#7240). + if(_dynamicModelLabels[modelId]) return _dynamicModelLabels[modelId]; // Preserve custom gateway model IDs exactly as configured. A custom id is // `@custom:` in the plain custom lane or `@custom::` for // a named custom provider; the provider slug may itself be an endpoint @@ -7400,6 +7409,12 @@ function getModelLabel(modelId){ const rest=rawId.slice('@custom:'.length); const sep=rest.indexOf(':'); if(sep<0) return rest||rawId; + // A provider slug is a config key or a host:port authority — it never + // contains a `/`. A slash-bearing first segment is therefore the model + // itself in the plain custom lane (`@custom:ollamacloud/qwen3.5:397b` + // must render the whole remainder, not just `397b`), mirroring the + // `/`-means-routable rule api/config.py applies when building ids. + if(rest.slice(0,sep).includes('/')) return rest||rawId; let model=rest.slice(sep+1); // Endpoint-style slug (`custom:10.8.71.41:8080:model`): the `:port` belongs // to the provider segment, mirroring the host:port slug check in @@ -7416,8 +7431,6 @@ function getModelLabel(modelId){ } return model||rawId; } - // Check dynamic labels first, then fall back to splitting the ID - if(_dynamicModelLabels[modelId]) return _dynamicModelLabels[modelId]; // Static fallback for common models const STATIC_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','anthropic/claude-haiku-3-5':'Haiku 3.5','google/gemini-3.1-pro-preview':'Gemini 3.1 Pro','google/gemini-3-flash-preview':'Gemini 3 Flash','google/gemini-3.1-flash-lite-preview':'Gemini 3.1 Flash Lite','google/gemini-2.5-pro':'Gemini 2.5 Pro','google/gemini-2.5-flash':'Gemini 2.5 Flash','deepseek/deepseek-v4-flash':'DeepSeek V4 Flash','deepseek/deepseek-v4-pro':'DeepSeek V4 Pro','deepseek/deepseek-chat-v3-0324':'DeepSeek V3 (legacy)','meta-llama/llama-4-scout':'Llama 4 Scout'}; if(STATIC_LABELS[modelId]) return STATIC_LABELS[modelId]; diff --git a/tests/test_issue7240_custom_model_colon_label.py b/tests/test_issue7240_custom_model_colon_label.py index e501864c..342ef70e 100644 --- a/tests/test_issue7240_custom_model_colon_label.py +++ b/tests/test_issue7240_custom_model_colon_label.py @@ -6,9 +6,11 @@ following the LAST ``:``, so a tag/variant suffix like ``:free``/``:31b``/ ``:397b`` inside the model's own name became the whole label (``@custom:omni:kg/stepfun/step-3.7-flash:free`` rendered as ``free``). -The fix peels only the leading provider segment (``@custom::`` — the -```` is itself optional in the plain custom lane, and may be an endpoint -authority ``host:port``), leaving the full configured model id as the label. +The fix makes the catalog the authority — a catalogued routing id renders its +exact `m.label` (the backend knows the real provider/model split). The string +peel is only a legacy fallback for ids the catalog has never seen, where a +provider slug (a config key or host:port authority) is distinguished from a +plain-lane model by its lack of a `/`. Runs the live getModelLabel() via Node so drift between the test and the real code is caught immediately. @@ -36,7 +38,9 @@ if (start < 0) throw new Error('getModelLabel not found'); const after = ui.indexOf('\nfunction _gatewayProviderName(', start); if (after < 0) throw new Error('getModelLabel end boundary not found'); const fnSrc = ui.slice(start, after); -const _dynamicModelLabels = {}; +// Optional pre-hydrated catalog: {routingId: label}. Absent -> empty, which +// exercises the string-only legacy fallback. +const _dynamicModelLabels = process.argv[3] ? JSON.parse(process.argv[3]) : {}; function _fmtOllamaLabel(s){ return s; } // getModelLabel() calls the dotted Bedrock/Vertex prefix normalizer, which lives // just above it with two Sets it closes over. Pull all three in, or the eval'd @@ -52,9 +56,12 @@ process.stdout.write(JSON.stringify(out)); """ -def _labels(model_ids): +def _labels(model_ids, catalog=None): + argv = [NODE, "-e", _DRIVER, str(UI_JS_PATH), json.dumps(model_ids)] + if catalog is not None: + argv.append(json.dumps(catalog)) proc = subprocess.run( - [NODE, "-e", _DRIVER, str(UI_JS_PATH), json.dumps(model_ids)], + argv, capture_output=True, text=True, timeout=30, ) assert proc.returncode == 0, f"node driver failed: {proc.stderr}" @@ -100,3 +107,48 @@ def test_custom_model_label_plain_lane_and_host_port_slugs(): assert out["@custom:10.8.71.41:8080:Qwen3-235B"] == "Qwen3-235B" assert out["@custom:localhost:11434:llama3"] == "llama3" assert out["@custom:omni:11434:Qwen3"] == "11434:Qwen3" + + +def test_plain_custom_lane_colon_bearing_model_slash_segment(): + """#7240: a plain-lane ``@custom:`` whose first segment + is slash-bearing (`ollamacloud/qwen3.5:397b`) is the model itself — a + provider slug never contains a `/`, so it must not be peeled as one.""" + out = _labels([ + "@custom:ollamacloud/qwen3.5:397b", + "@custom:ollamacloud/gemma4:31b", + "@custom:some-vendor/step-3.7-flash:free", + ]) + assert out["@custom:ollamacloud/qwen3.5:397b"] == "ollamacloud/qwen3.5:397b" + assert out["@custom:ollamacloud/gemma4:31b"] == "ollamacloud/gemma4:31b" + assert out["@custom:some-vendor/step-3.7-flash:free"] == "some-vendor/step-3.7-flash:free" + + +def test_catalog_label_wins_over_legacy_peel(): + """Post-hydration: the catalog ships the exact backend label per routing + id, so a catalogued id renders verbatim even where the string peel alone + would be ambiguous or lossy.""" + catalog = { + # Plain lane, colon-bearing model: the backend knows this is ONE model. + "@custom:ollamacloud/qwen3.5:397b": "ollamacloud/qwen3.5:397b", + # Named lane, same native model: distinct routing id, distinct entry. + "@custom:omni:ollamacloud/qwen3.5:397b": "ollamacloud/qwen3.5:397b", + # A custom display label is preserved exactly. + "@custom:ai_gateway:Qwen3.6-35B-A3B": "My Qwen Gateway", + } + out = _labels(list(catalog), catalog=catalog) + assert out["@custom:ollamacloud/qwen3.5:397b"] == "ollamacloud/qwen3.5:397b" + assert out["@custom:omni:ollamacloud/qwen3.5:397b"] == "ollamacloud/qwen3.5:397b" + assert out["@custom:ai_gateway:Qwen3.6-35B-A3B"] == "My Qwen Gateway" + + +def test_collision_labels_stay_distinct_by_full_routing_id(): + """Two providers exposing the same native model stay distinguishable: the + catalog keys labels by the FULL routing id, never by the bare model.""" + catalog = { + "@custom:omni:ollamacloud/qwen3.5:397b": "ollamacloud/qwen3.5:397b (omni)", + "@custom:ai_gateway:ollamacloud/qwen3.5:397b": "ollamacloud/qwen3.5:397b (gateway)", + } + out = _labels(list(catalog), catalog=catalog) + assert out["@custom:omni:ollamacloud/qwen3.5:397b"] == "ollamacloud/qwen3.5:397b (omni)" + assert out["@custom:ai_gateway:ollamacloud/qwen3.5:397b"] == "ollamacloud/qwen3.5:397b (gateway)" + assert out["@custom:omni:ollamacloud/qwen3.5:397b"] != out["@custom:ai_gateway:ollamacloud/qwen3.5:397b"] From 47cbba48a43091f32d64f7300d19114d9cc2e4f9 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 20 Sep 2026 06:43:08 +0000 Subject: [PATCH 148/446] docs(changelog): stamp #7401 colon-bearing custom model labels Co-authored-by: webtecnica --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 209337fd..d0b0c2d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Custom model names containing colons are no longer truncated in the picker.** A model id like `ollamacloud/qwen3.5:397b` was cut at its internal colon, so the picker showed a mangled label and two models that differed only after the colon could render identically. The colon is overloaded here — it separates a `@provider:model` routing lane, and it also appears inside perfectly ordinary model names — so the label builder now takes the catalog as the authority for `@custom` entries and leaves a plain-lane model id whole. Labels are display-only: the original option value survives selection and is dispatched unchanged, so no routing behaviour moves. Catalogued, cold, empty, stale, host:port, leading- and trailing-colon and multi-colon names were each checked to stay non-blank and exception-free. Thanks @webtecnica. (#7401, #7240) + - **Context-compaction cards stay visible in long transcripts.** When a transcript grew past the virtualization window, the compaction markers explaining where context was compressed scrolled out of the rendered range and vanished, leaving no indication that compaction had occurred. Pre-window markers are now preserved and placed deterministically, and card placement is stable across repeated renders and window changes — verified to neither lose nor duplicate a card as the virtual window moves. Hardened during review: the settled current-summary card was created only when *no* compaction marker was loaded at all, so a transcript carrying older markers plus a newer summary that none of them referenced silently dropped the summary the session was actually operating under — marker presence is not proof that any marker matches. The fallback now keys on whether a loaded marker actually references the current summary, and participates in the single preserved-task-owner selection so the repair cannot duplicate the task card. Mid-compression, empty-summary, unmatched-anchor-key, first-compaction and matched-marker cases were each checked, and reverting the guard reproduces the original suppression. Thanks @ruizanthony. (#7124) - **Editing or duplicating a one-shot cron job no longer returns HTTP 500.** The editable schedule field was populated from `schedule_display`, the human-readable label (`once at 2026-08-28 16:00`), and saving submitted that label straight back. The Agent's schedule parser only accepts the canonical timestamp, so it raised — and the route's existing `ValueError` guard did not cover the `update_job()` call, so the failure escaped as a 500. The field now carries the canonical `run_at` for one-shot jobs, and genuinely unparseable input returns a 400 with the parser's message instead of a server error. Natural-language recurring schedules are preserved verbatim: hardening during review found that preferring the canonical cron expression rewrote a job scheduled as `every monday 9am` into `0 9 * * 1` on every edit or duplicate — and because the Agent rebuilds the display string from whatever is submitted, the rewrite stuck and the user silently lost their phrasing. Only the `once at …` label is unparseable, so every other display form is now kept as typed. Thanks @happy5318. (#7649, #7352) From 8e31ae9f93ddf3e898fb95151a75157549fe8e39 Mon Sep 17 00:00:00 2001 From: happy5318 Date: Sun, 20 Sep 2026 05:36:50 +0800 Subject: [PATCH 149/446] # Session MEDIA auth and snapshot capture inspect Codex commentary sidecars (#7565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path The Agent's OpenAI Codex Responses adapter persists user-visible assistant progress in `codex_message_items` with `phase: "commentary"` while the outer `content` field is empty. The Agent already stamps these items with `role: assistant` and a normalized phase (`agent/codex_responses_adapter.py::_message_item` and `_OutputScan._message`), and the rendering lane is owned by #6828. The WebUI's two consumers of those persisted rows, however, only read the outer `content` field: - `api/routes.py::_session_media_token_allows_path` flattens `message.get("content")` through `_message_content_text()` and scans it for `MEDIA:` tokens. - `api/media_snapshots.py::annotate_media_snapshots` does the same when annotating the settled transcript so previews can survive an in-place overwrite of the source file. For a safe image emitted only inside commentary, the browser request hits the real `_handle_media` route, owns the correct session_id and exact path, but is denied 403 by the auth predicate because it cannot see the assistant's persisted `MEDIA:` token. Symmetrically, the snapshot annotator never captures commentary artifacts, so a subsequent in-place overwrite rewrites history without a recoverable snapshot. The fix is the maintainer's narrow recommendation: one shared fail-closed public-assistant-text iterator in `api/media_snapshots.py` (`codex_commentary_text`), consumed by both the auth predicate and the snapshot annotator. Keeping the extraction in one neutral helper means the auth and capture paths cannot drift apart on a future change. The fail-closed predicate is a strict chain — outer role is `assistant`; item type is `message`; item role is `assistant`; item phase normalizes to `commentary`; content is a list; the part is textual `output_text` with a string `text` value. Every step that fails returns the safe empty string, so the helper is safe to concatenate with the existing top-level extraction. User rows, user sidecars, reasoning items, analysis/final phases, malformed parts, non-string `text` values, and arbitrary nested fields are all skipped. ## What Changed - **`api/media_snapshots.py::codex_commentary_text`** — new module-level helper that returns the concatenation of all public-text `output_text` parts inside assistant-owned `codex_message_items` with `phase: "commentary"`. The outer row role, item type, item role, item phase, content shape, and part type are all gated, so the helper is fail-closed and safe to concatenate with the existing top-level text extraction. - **`api/routes.py::_session_media_token_allows_path`** — inside the existing per-message loop, the per-row `text` is now the concatenation of `_message_content_text(message.get("content"))` and the new `codex_commentary_text(message)`. The exact-path, owning-session, safe-MIME, URL-rejection, hard-denied, and symlink guards are unchanged. The commentary helper is imported lazily inside the loop so this file does not pick up a new top-level dependency. - **`api/media_snapshots.py::annotate_media_snapshots`** — the per-row `text` is now the concatenation of the existing top-level string `content` and the new commentary helper output. The existing `media_re.findall()` and resolve/allow paths are unchanged, so otherwise-capture-eligible commentary previews are now snapshotted at settle time. The string pre-filter on `"MEDIA:" in text` is kept so the helper short-circuits before regex extraction when neither source contains a token. - **`tests/test_issue7565_codex_commentary_media.py`** — new regression module with eleven tests: a positive valid-shape test, six fail-closed tests (user outer role, wrong item type, wrong item role, wrong phase, non-output_text parts, non-string output_text text), a case-insensitive commentary-phase test, a malformed-input test, and two integration static tests pinning that the auth predicate and the snapshot annotator both consume the new helper. ## Why - A user-visible artifact that the agent emitted only inside commentary was returning 403 at the real `_handle_media` route boundary even with the correct owning session_id, and the artifact was never snapshotted at settle time. The fix restores parity with top-level public assistant content for the commentary phase specifically. - Symmetry between auth and capture matters: a token that auth admits must be one capture can stamp, otherwise the served bytes can desynchronize from the snapshot pointer and the in-place-overwrite survivor feature (#6922) silently breaks. Sharing the helper makes drift impossible. ## Verification ``` $ .venv/bin/pytest \ tests/test_issue7565_codex_commentary_media.py \ tests/test_media_inline.py \ tests/test_media_message_snapshots.py \ -q --no-header … ============================= 115 passed in 6.16s ============================== ``` Eleven new tests cover the helper contract and the integration points. The 104 surrounding media_inline / media_message_snapshots tests stay green. ## Contract | Outer role | Item shape | Helper result | Auth result | Snapshot result | | --- | --- | --- | --- | --- | | `assistant` | `{type:"message", role:"assistant", phase:"commentary", content:[{type:"output_text", text:"…MEDIA:…"}]}` | text surfaced | `MEDIA:` token found; existing exact-path / safe-MIME / owning-session guards apply | `MEDIA:` token snapshotted at settle time | | `assistant` | `{type:"message", role:"assistant", phase:"analysis", content:[{type:"output_text", text:"…"}]}` | empty | unchanged from existing top-level-only behavior | unchanged | | `assistant` | `{type:"message", role:"assistant", phase:"final", content:[{type:"output_text", text:"…"}]}` | empty | unchanged | unchanged | | `assistant` | `{type:"reasoning", …}` | empty | unchanged | unchanged | | `assistant` | item role `user` | empty | unchanged | unchanged | | `assistant` | item content not a list | empty | unchanged | unchanged | | `assistant` | part type `tool_call` / `image` / `input_text` (even with text containing `MEDIA:…`) | empty | unchanged | unchanged | | `assistant` | `output_text` with non-string `text` (dict / list / number) | empty | unchanged | unchanged | | `user` (outer role) | any sidecar | empty | unchanged (user rows already skipped) | unchanged | | `assistant` | no `codex_message_items` | empty | unchanged | unchanged | | malformed (`None`, `""`, list, string, missing role) | n/a | empty | unchanged | unchanged | The fix only widens the inspection surface for the one public-text sidecar shape; everything else stays exactly as it was, so existing safe-MIME, hard-denied, exact-path-equality, owning-session, URL-rejection, and symlink guards remain intact. ## Risks - **Commentary widening the inspection surface**: yes, by design. The widening is narrowly scoped to the outer-assistant / item-message / item-role-assistant / phase-commentary / part-output_text / string-text chain. The new tests pin every step in that chain and every out-of-chain shape that must remain rejected. - **Existing safe-MIME / hard-denied / exact-path / symlink guards weakened?** No. The `media_capture_allowed` predicate, the `MIME_MAP` lookup, the `Path(ref).expanduser().resolve() == target_resolved` equality check, the `if "://" in ref` URL rejection, the hard-denied state/secret paths, and the symlink protections are all unchanged. The only change is what text the regex sees. - **Agent producer change**: the helper reads the same shape the agent already stamps (`role: assistant`, `phase: "commentary"`, `content: [{type: "output_text", text: "..."}]`). A future producer change that adds a new phase or part type requires an explicit follow-up PR; the helper's comments document the precise chain. - **Helper duplication drift risk avoided**: by placing the helper in `api/media_snapshots.py` (the storage half) and having `api/routes.py` import it lazily inside the loop, both consumers go through one definition. A follow-up change to the helper's contract automatically applies to both call sites. ## Model Used - Generated with `MiniMax-M3` via `happy5318/hermes-webui` worktree `/tmp/webui-worktrees/pr7565-codex-media`, branch `fix/codex-commentary-media` (off `origin/master` via `ghfast` mirror — `origin` was still TLS recv-failing at PR creation time; the mirror was used for `git fetch` and `git worktree add` only; push goes through the `fork` remote as usual). ## Related - Closes #7565 - Surface code: - `api/media_snapshots.py` (new) `codex_commentary_text` helper - `api/routes.py::_session_media_token_allows_path` (modified to concatenate the helper output) - `api/media_snapshots.py::annotate_media_snapshots` (modified to concatenate the helper output) - Adjacent lanes (intentionally not folded in): - #7294 / #7297 (safe-MIME, session_id, snapshot query parameters) — independent contract - #6828 (persisted-commentary rendering / recovery) — frontend UI; the WebUI auth predicate does not assume or expand its rendering scope - Open PRs #7367 and #6923 — overlap the token-extraction sites but neither implements this commentary ownership rule, per the maintainer's open-PR search - Test surface: new `tests/test_issue7565_codex_commentary_media.py` --- api/media_snapshots.py | 82 ++++++- api/routes.py | 17 +- .../test_issue7565_codex_commentary_media.py | 218 ++++++++++++++++++ 3 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 tests/test_issue7565_codex_commentary_media.py diff --git a/api/media_snapshots.py b/api/media_snapshots.py index b50c32a6..929c1431 100644 --- a/api/media_snapshots.py +++ b/api/media_snapshots.py @@ -228,6 +228,66 @@ def snapshot_path_for_digest(digest: str) -> Path | None: return candidate if candidate.is_file() else None +def codex_commentary_text(message) -> str: + """#7565: typed public assistant commentary carried in + ``codex_message_items`` is eligible for the same exact + session-scoped MEDIA: grant as public top-level assistant content. + + The Agent producer (``agent/codex_responses_adapter.py``) stamps + message items with ``role: assistant`` and a normalized phase. + Only the public ``commentary`` phase, only the textual + ``output_text`` part, and only when the outer row role is + ``assistant`` is honored. Reasoning, analysis, final phases, + user-owned sidecars, and arbitrary nested fields are not + inspected — keeping the extraction in one neutral helper so + the authorization predicate (``api/routes.py:: + _session_media_token_allows_path``) and the snapshot scan + (``annotate_media_snapshots`` below) cannot drift apart. + + Fail-closed predicate, in order: outer role is assistant; + ``codex_message_items`` is a list; item type is ``message``; + item role is ``assistant``; item phase normalizes to + ``commentary``; content is a list; the part is textual + ``output_text`` with a string ``text`` value. Any step that + fails returns an empty string, so this helper is safe to + concatenate with the existing top-level text extraction. + """ + if not isinstance(message, dict): + return "" + # Outer role must be assistant. User rows and their sidecars + # cannot mint grants even when the sidecar contains a + # commentary-shaped item, preserving the implicit threat model + # that user-authored content cannot mint allow-list entries. + role = str(message.get("role") or "").strip().lower() + if role != "assistant": + return "" + items = message.get("codex_message_items") + if not isinstance(items, list): + return "" + parts = [] + for item in items: + if not isinstance(item, dict): + continue + if str(item.get("type") or "").strip().lower() != "message": + continue + if str(item.get("role") or "").strip().lower() != "assistant": + continue + if str(item.get("phase") or "").strip().lower() != "commentary": + continue + content = item.get("content") + if not isinstance(content, list): + continue + for part in content: + if not isinstance(part, dict): + continue + if str(part.get("type") or "").strip().lower() != "output_text": + continue + text = part.get("text") + if isinstance(text, str): + parts.append(text) + return "\n".join(parts) + + def _binding_path_for_digest(digest: str) -> Path: """Sidecar holding the source-path set for a digest (``.src.json``).""" return get_snapshot_dir() / f"{digest}.src.json" @@ -449,10 +509,28 @@ def annotate_media_snapshots( for msg in messages or []: if not isinstance(msg, dict) or msg.get("role") != "assistant": continue + # #7565: also inspect typed public assistant commentary + # carried in ``codex_message_items`` (Agent phase: + # "commentary"). The concatenated text below is the union of + # the existing top-level string extraction and the new + # commentary-only helper, so otherwise-capture-eligible + # commentary previews are snapshotted at settle time. The + # commentary helper is fail-closed (outer role must be + # assistant; item type/role/phase all constrained; only + # textual output_text parts are read) and matches the auth + # predicate's extraction so capture and serve cannot drift + # apart. content = msg.get("content") - if not isinstance(content, str) or "MEDIA:" not in content: + commentary = codex_commentary_text(msg) + text_parts = [] + if isinstance(content, str) and "MEDIA:" in content: + text_parts.append(content) + if commentary and "MEDIA:" in commentary: + text_parts.append(commentary) + if not text_parts: continue - refs = media_re.findall(content) + text = "\n".join(text_parts) + refs = media_re.findall(text) if not refs: continue existing = msg.get("_media_snapshots") diff --git a/api/routes.py b/api/routes.py index 599b02ee..0e159784 100644 --- a/api/routes.py +++ b/api/routes.py @@ -20482,7 +20482,22 @@ def _session_media_token_allows_path(sid: str, target: Path, allowed_mimes: set[ role = str(message.get("role") or "").strip().lower() if role == "user": continue - text = _message_content_text(message.get("content")) + # #7565: also inspect typed public assistant commentary carried in + # ``codex_message_items`` (Agent phase: "commentary"). The + # concatenated text below is the union of the existing + # top-level extraction and the new commentary-only helper, so + # the existing exact-path, owning-session, safe-MIME, URL, + # hard-denied, and symlink guards are unchanged. The + # commentary helper is fail-closed (outer role must be + # assistant; item type/role/phase all constrained; only + # textual output_text parts are read). + from api.media_snapshots import codex_commentary_text + text = "\n".join( + fragment for fragment in ( + _message_content_text(message.get("content")), + codex_commentary_text(message), + ) if fragment + ) if "MEDIA:" not in text: continue for ref in _MEDIA_TOKEN_RE.findall(text): diff --git a/tests/test_issue7565_codex_commentary_media.py b/tests/test_issue7565_codex_commentary_media.py new file mode 100644 index 00000000..7a483109 --- /dev/null +++ b/tests/test_issue7565_codex_commentary_media.py @@ -0,0 +1,218 @@ +"""Regression coverage for issue #7565: session MEDIA authorization +and snapshot capture must also inspect typed public assistant +commentary carried in ``codex_message_items`` (Agent phase: +"commentary"). The Agent producer (``agent/codex_responses_adapter.py``) +persists such items with ``role: assistant`` and a normalized phase; +the WebUI's auth predicate (``api/routes.py:: +_session_media_token_allows_path``) and snapshot annotator +(``api/media_snapshots.py::annotate_media_snapshots``) previously +only scanned top-level assistant ``content``, so a safe image +emitted only inside commentary returned 403 and was never +snapshotted at settle time. +""" +from __future__ import annotations + +import sys +import types + +from api.media_snapshots import codex_commentary_text + + +# ── unit tests for the shared helper ───────────────────────────────────────── + + +def _commentary_row(text="Synthetic preview is ready.\n\nMEDIA:/opt/artifacts/preview.png", + phase="commentary", + item_type="message", + item_role="assistant", + outer_role="assistant"): + return { + "role": outer_role, + "content": "", + "codex_message_items": [ + { + "type": item_type, + "role": item_role, + "phase": phase, + "content": [ + {"type": "output_text", "text": text}, + ], + } + ], + } + + +def test_commentary_text_returns_text_for_valid_shape(): + """The valid-shape case from the issue body — outer role + assistant, item type message, item role assistant, phase + commentary, list of output_text parts with a string text — + must surface the embedded text so the auth predicate and + snapshot scan can both find the MEDIA: token.""" + row = _commentary_row(text="MEDIA:/opt/artifacts/preview.png") + text = codex_commentary_text(row) + assert "MEDIA:/opt/artifacts/preview.png" in text, ( + "valid commentary shape must surface the textual MEDIA: " + "token so the auth predicate can resolve it" + ) + + +def test_commentary_text_rejects_user_outer_role(): + """A user-row sidecar must not mint grants even when the + sidecar contains a commentary-shaped item, preserving the + implicit threat model that user-authored content cannot + mint allow-list entries.""" + row = _commentary_row(outer_role="user", text="MEDIA:/etc/passwd") + assert codex_commentary_text(row) == "", ( + "user outer role must short-circuit before the sidecar is read" + ) + + +def test_commentary_text_rejects_wrong_item_type(): + """Item type must be exactly ``message``. Reasoning, function + call, and other non-message items must not be inspected even + when their content is shaped like commentary.""" + row = _commentary_row(item_type="reasoning", text="MEDIA:/opt/x.png") + assert codex_commentary_text(row) == "" + + +def test_commentary_text_rejects_wrong_item_role(): + """Item role must be exactly ``assistant``. User-sidecar + assistant commentary (an item with role: user nested inside + an outer assistant row) must not mint grants.""" + row = _commentary_row(item_role="user", text="MEDIA:/opt/x.png") + assert codex_commentary_text(row) == "" + + +def test_commentary_text_rejects_wrong_phase(): + """Phase must normalize to exactly ``commentary``. Analysis, + final, and arbitrary phases must not be inspected, even when + the row carries a textual output_text part with a MEDIA: + token. ``"Commentary"`` with a capital C is accepted because + the helper lowercases the phase; the others are rejected.""" + for wrong_phase in ("analysis", "final", "tool_call", ""): + row = _commentary_row(phase=wrong_phase, text="MEDIA:/opt/x.png") + assert codex_commentary_text(row) == "", ( + f"phase={wrong_phase!r} must not be inspected" + ) + + +def test_commentary_text_accepts_case_insensitive_commentary(): + """Phase matching is case-insensitive; the Agent producer + stamps a normalized phase, but the helper must still accept + variants so a future producer change cannot silently break + the auth predicate.""" + row = _commentary_row(phase="Commentary", text="MEDIA:/opt/x.png") + assert codex_commentary_text(row) != "" + + +def test_commentary_text_rejects_non_output_text_parts(): + """Only textual ``output_text`` parts are read. Tool-call + parts, image parts, file parts, and arbitrary typed parts + must not be inspected even if they happen to carry a + MEDIA-shaped string.""" + row = { + "role": "assistant", + "content": "", + "codex_message_items": [ + { + "type": "message", + "role": "assistant", + "phase": "commentary", + "content": [ + {"type": "tool_call", "name": "x", "text": "MEDIA:/opt/x.png"}, + {"type": "image", "text": "MEDIA:/opt/x.png"}, + {"type": "input_text", "text": "MEDIA:/opt/x.png"}, + ], + } + ], + } + assert codex_commentary_text(row) == "", ( + "non-output_text parts must not be inspected even when they " + "carry a MEDIA-shaped string" + ) + + +def test_commentary_text_rejects_non_text_output_text_part(): + """``output_text`` parts with a non-string ``text`` value must + be skipped, not coerced. This pins the fail-closed contract: + the helper will not stringify a dict, list, or number just + because the part type is correct.""" + row = { + "role": "assistant", + "content": "", + "codex_message_items": [ + { + "type": "message", + "role": "assistant", + "phase": "commentary", + "content": [ + {"type": "output_text", "text": {"nested": "MEDIA:/opt/x.png"}}, + {"type": "output_text", "text": ["MEDIA:/opt/x.png"]}, + {"type": "output_text", "text": 42}, + ], + } + ], + } + assert codex_commentary_text(row) == "" + + +def test_commentary_text_handles_malformed_messages(): + """The helper must never raise on malformed input. Empty, + None, list, string, and arbitrary objects must all return + the safe empty default.""" + assert codex_commentary_text(None) == "" + assert codex_commentary_text("") == "" + assert codex_commentary_text([]) == "" + assert codex_commentary_text({}) == "" + assert codex_commentary_text({"role": "assistant"}) == "" # no codex_message_items + assert codex_commentary_text({"role": "assistant", "codex_message_items": "not a list"}) == "" + # Malformed item shape: not a dict. + assert codex_commentary_text({ + "role": "assistant", + "codex_message_items": [None, "string", 42, ["list"]], + }) == "" + + +# ── auth-predicate integration via the public surface ─────────────────────── + + +def test_session_media_token_allows_path_imports_commentary_helper(): + """The authorization predicate must import and use the + commentary helper so a safe image emitted only inside + commentary gets the same exact-path grant as a top-level + one. Without the import, the auth surface silently regresses + to the top-level-only shape that #7565 reports.""" + src = open("api/routes.py", encoding="utf-8").read() + assert "from api.media_snapshots import codex_commentary_text" in src + # The helper is actually consumed (not just imported) inside + # the predicate body. + auth_idx = src.find("def _session_media_token_allows_path") + assert auth_idx != -1 + end_idx = src.find("\n\ndef ", auth_idx + 1) + if end_idx == -1: + end_idx = len(src) + body = src[auth_idx:end_idx] + assert "codex_commentary_text(message)" in body, ( + "the auth predicate must consult the commentary helper so " + "tokens emitted only inside commentary are honored" + ) + + +def test_annotate_media_snapshots_uses_commentary_helper(): + """The snapshot annotator must also use the commentary helper + so commentary-emitted artifacts are snapshotted at settle time + rather than only the first time the live file is read.""" + src = open("api/media_snapshots.py", encoding="utf-8").read() + # The helper is defined at module scope. + assert "def codex_commentary_text(" in src + # The annotator consumes it. + annotate_idx = src.find("def annotate_media_snapshots(") + assert annotate_idx != -1 + end_idx = src.find("\n\ndef ", annotate_idx + 1) + if end_idx == -1: + end_idx = len(src) + body = src[annotate_idx:end_idx] + assert "codex_commentary_text(msg)" in body, ( + "the snapshot annotator must consult the commentary helper " + "so commentary-emitted artifacts are captured at settle time" + ) From bc37675bc21380aef3bb91181a83de7118b55c04 Mon Sep 17 00:00:00 2001 From: happy5318 Date: Sun, 20 Sep 2026 06:56:41 +0800 Subject: [PATCH 150/446] fix(tests): drop unused sys/types imports in #7565 codex commentary test The test file imports `sys` and `types` but never references either. Ruff F401 flags them. No test or runtime behavior change. - Remove the 2 unused import lines - 11 tests still pass - No new ruff violations --- tests/test_issue7565_codex_commentary_media.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_issue7565_codex_commentary_media.py b/tests/test_issue7565_codex_commentary_media.py index 7a483109..9970f46f 100644 --- a/tests/test_issue7565_codex_commentary_media.py +++ b/tests/test_issue7565_codex_commentary_media.py @@ -12,9 +12,6 @@ snapshotted at settle time. """ from __future__ import annotations -import sys -import types - from api.media_snapshots import codex_commentary_text From 7d2d40833b7eeca61340d0c4df8f7feac20b5345 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 20 Sep 2026 09:01:29 +0000 Subject: [PATCH 151/446] docs(changelog): stamp #7654 Codex commentary MEDIA auth + snapshots Co-authored-by: happy5318 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b0c2d4..784e2101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Images and files produced during Codex commentary are viewable again.** The Agent's OpenAI Codex Responses adapter persists user-visible assistant progress in `codex_message_items` with `phase: "commentary"` while the outer `content` field stays empty. WebUI's session MEDIA authorization and its snapshot capture both read only that outer field, so a `MEDIA:` token emitted during commentary was invisible to both — the artifact was never authorized for the session token and never snapshotted, leaving the user unable to open a file the assistant had just produced. Both consumers now inspect the commentary sidecars as well. The widening is confined to genuinely assistant-authored, correctly-phased items: role, type, phase and content checks all fail closed, so a user-authored message, a tool result or a malformed item cannot grant a path token. Every existing guard is unchanged — exact-path matching, MIME allow-listing, session ownership, profile visibility and the state/secret hard-deny all still run on anything discovered through the new route, and snapshot capture keeps its confinement without double-capturing an artifact already taken from outer content. Verified against the Agent's real producer shape plus an adversarial probe covering user/wrong-role/wrong-phase, denied-state, wrong-path, duplicate, malformed and 300k-item inputs. Thanks @happy5318. (#7654, #7565) + - **Custom model names containing colons are no longer truncated in the picker.** A model id like `ollamacloud/qwen3.5:397b` was cut at its internal colon, so the picker showed a mangled label and two models that differed only after the colon could render identically. The colon is overloaded here — it separates a `@provider:model` routing lane, and it also appears inside perfectly ordinary model names — so the label builder now takes the catalog as the authority for `@custom` entries and leaves a plain-lane model id whole. Labels are display-only: the original option value survives selection and is dispatched unchanged, so no routing behaviour moves. Catalogued, cold, empty, stale, host:port, leading- and trailing-colon and multi-colon names were each checked to stay non-blank and exception-free. Thanks @webtecnica. (#7401, #7240) - **Context-compaction cards stay visible in long transcripts.** When a transcript grew past the virtualization window, the compaction markers explaining where context was compressed scrolled out of the rendered range and vanished, leaving no indication that compaction had occurred. Pre-window markers are now preserved and placed deterministically, and card placement is stable across repeated renders and window changes — verified to neither lose nor duplicate a card as the virtual window moves. Hardened during review: the settled current-summary card was created only when *no* compaction marker was loaded at all, so a transcript carrying older markers plus a newer summary that none of them referenced silently dropped the summary the session was actually operating under — marker presence is not proof that any marker matches. The fallback now keys on whether a loaded marker actually references the current summary, and participates in the single preserved-task-owner selection so the repair cannot duplicate the task card. Mid-compression, empty-summary, unmatched-anchor-key, first-compaction and matched-marker cases were each checked, and reverting the guard reproduces the original suppression. Thanks @ruizanthony. (#7124) From b7f536b006cb8365d559ed71032383ff6eeecac2 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Thu, 13 Aug 2026 13:13:29 -0300 Subject: [PATCH 152/446] fix(async): atomic per-origin wakeup admission prevents delivery budget exhaustion (#6959) --- api/background_process.py | 61 ++++++ tests/test_async_delegation_webui_bridge.py | 212 ++++++++++++++++++++ 2 files changed, 273 insertions(+) diff --git a/api/background_process.py b/api/background_process.py index c8dbb1d6..a692fe7d 100644 --- a/api/background_process.py +++ b/api/background_process.py @@ -1086,6 +1086,13 @@ def _start_async_delegation_wakeup_turn( session_id, exc_info=True, ) + finally: + # The wakeup turn has resolved (accepted, rejected, or raised): the + # per-origin admission slot is free for the next backlogged + # completion. Never release earlier — the slot must stay reserved + # for the whole claim→turn window so siblings defer without + # consuming the finite delivery budget (#6959). + _release_async_delegation_wakeup_admission(session_id) threading.Thread( target=_runner, @@ -1094,6 +1101,42 @@ def _start_async_delegation_wakeup_turn( ).start() +# ── Per-origin wakeup admission reservation (#6959) ──────────────────────── +# A durable claim has a finite delivery-attempt budget, and the pre-claim busy +# check is NOT atomic with turn publication: several completed delegations for +# one idle origin can all pass it, each obtain a durable claim, then race for a +# single ``start_session_turn`` admission slot. The losers' transient 409s +# release already-counted claims, and repeated rounds can drive completed rows +# to the terminal ``dropped`` state. Reserve the per-origin admission slot +# ATOMICALLY before claiming so at most one async-delegation wakeup per session +# is in flight at a time; siblings that lose the reservation defer WITHOUT +# claiming (no delivery attempt consumed). The reservation is in-process only: +# the wakeup thread releases it on every exit path and it vanishes on restart, +# so it can never strand a durable claim. +_ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK = threading.Lock() +_ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT: set[str] = set() + + +def _try_reserve_async_delegation_wakeup_admission(session_id: str) -> bool: + """Atomically reserve the single in-flight wakeup slot for *session_id*. + + Returns False when another async-delegation wakeup for the same origin is + already in flight (claimed but not yet resolved), so the caller can defer + without touching the finite delivery budget. + """ + with _ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK: + if session_id in _ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT: + return False + _ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT.add(session_id) + return True + + +def _release_async_delegation_wakeup_admission(session_id: str) -> None: + """Release the in-flight wakeup slot for *session_id* (idempotent).""" + with _ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK: + _ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT.discard(session_id) + + def _process_async_delegation_event( evt: dict, *, @@ -1114,14 +1157,30 @@ def _process_async_delegation_event( ) return + # Atomic per-origin wakeup admission (#6959): the busy check above is a + # pre-check, not a reservation — several idle completions for one origin + # can all pass it together. Only one may own the in-flight wakeup slot; a + # sibling that loses this reservation must NOT claim, because a claim + # released by a transient 409 would count against the finite delivery + # budget. The admitted wakeup releases the slot when its turn resolves. + if not _try_reserve_async_delegation_wakeup_admission(session_id): + _retry_unclaimed_async_delegation_event( + process_registry, + evt, + keep_legacy_retrying=True, + ) + return + try: claim = claim_async_delegation_delivery(evt, "webui-background") except Exception: + _release_async_delegation_wakeup_admission(session_id) _requeue_async_delegation_event(process_registry, evt) return if claim is None: completion_queue = getattr(process_registry, "completion_queue", None) schedule_async_delegation_claim_retry(evt, completion_queue) + _release_async_delegation_wakeup_admission(session_id) return try: @@ -1135,6 +1194,7 @@ def _process_async_delegation_event( # one-shot path — never keep_legacy_retrying, or it would loop forever. release_async_delegation_delivery(evt, claim) _retry_unclaimed_async_delegation_event(process_registry, evt) + _release_async_delegation_wakeup_admission(session_id) logger.warning( "async delegation completion could not be formatted for session %s", session_id, @@ -1159,6 +1219,7 @@ def _process_async_delegation_event( _retry_unclaimed_async_delegation_event( process_registry, evt, keep_legacy_retrying=True ) + _release_async_delegation_wakeup_admission(session_id) logger.warning( "server-side async delegation dispatch failed for session %s", session_id, diff --git a/tests/test_async_delegation_webui_bridge.py b/tests/test_async_delegation_webui_bridge.py index 3a5937c3..65d90cb4 100644 --- a/tests/test_async_delegation_webui_bridge.py +++ b/tests/test_async_delegation_webui_bridge.py @@ -161,6 +161,10 @@ def _reset_wakeup_state() -> None: cfg.PENDING_BG_TASK_COMPLETIONS.clear() cfg.BG_TASK_COMPLETE_EVENTS_SEEN.clear() cfg.DEFERRED_PROCESS_WAKEUPS.clear() + # Clear any per-origin wakeup-admission reservations left by a test that + # monkeypatched the wakeup runner (which normally releases them). + with bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK: + bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT.clear() reset = getattr(peu, "_reset_legacy_async_delivery_dedupe_for_tests", None) if reset is not None: reset() @@ -1294,3 +1298,211 @@ def test_next_turn_drain_respects_origin_over_session_key_index(monkeypatch): assert delivery["release"] == [] +def test_concurrent_idle_wakeups_share_one_atomic_admission(monkeypatch): + """Issue #6959 regression: N simultaneous idle completions for one origin + must not each claim the durable delivery. The busy pre-check is not a + reservation — multiple completions can pass it together and race for a + single turn-admission slot; sibling 409s would then consume the finite + delivery-attempt budget. Only one completion may own the in-flight wakeup + admission; siblings defer WITHOUT claiming (zero attempts consumed).""" + _reset_wakeup_state() + registry = _install_fake_process_registry(monkeypatch) + delivery = _install_fake_durable_delivery_api(monkeypatch) + from api import routes + + turn_started = threading.Event() + allow_turn = threading.Event() + turn_calls: list[str] = [] + + def _start_turn(session_id, prompt, **_kwargs): + turn_calls.append(session_id) + turn_started.set() + assert allow_turn.wait(timeout=3), "first wakeup never unblocked" + return {"_status": 200, "stream_id": "stream-1"} + + monkeypatch.setattr(routes, "start_session_turn", _start_turn) + monkeypatch.setattr(bp, "_session_has_active_turn", lambda _session_id: False) + monkeypatch.setattr(bp, "_emit_bg_task_complete_events_coalesced", lambda *_args: 1) + monkeypatch.setattr(bp, "ASYNC_DELIVERY_ROUTING_RETRY_SECONDS", 30.0) + + events = [ + _async_delegation_event(delegation_id=f"deleg_race_{i}") + for i in range(3) + ] + try: + # First completion reserves the per-origin admission and claims. + bp._process_async_delegation_event( + events[0], + session_id="webui-session-1", + delegation_id=events[0]["delegation_id"], + process_registry=registry, + ) + assert _wait_until(turn_started.is_set), "first wakeup never started a turn" + + # Siblings arrive while the first wakeup is still in flight: they must + # defer WITHOUT claiming (no delivery attempt consumed). + for evt in events[1:]: + bp._process_async_delegation_event( + evt, + session_id="webui-session-1", + delegation_id=evt["delegation_id"], + process_registry=registry, + ) + + assert len(turn_calls) == 1, "at most one wakeup turn per origin" + assert len(delivery["claim"]) == 1, ( + "siblings must not claim while admission is held" + ) + assert delivery["claim"][0][0]["delegation_id"] == "deleg_race_0" + assert delivery["delivery_attempts"] == 1 + assert delivery["complete"] == [] + assert delivery["release"] == [] + assert delivery["delivery_state"] == "pending" + + # Release admission: exactly the one admitted wakeup delivers once. + allow_turn.set() + assert _wait_until(lambda: len(delivery["complete"]) == 1) + assert delivery["delivery_attempts"] == 1 + assert delivery["release"] == [] + assert delivery["delivery_state"] == "delivered" + assert len(turn_calls) == 1 + # Sibling deferrals armed a durable restore sweep (not dropped). + assert peu.async_delivery_retry_timer_count() >= 1 + finally: + allow_turn.set() + _reset_wakeup_state() + + +def test_idle_wakeup_admission_rotates_after_transient_rejection(monkeypatch): + """Issue #6959: when the admitted wakeup loses the turn (transient 409), + only ITS OWN claim is consumed; the per-origin admission slot rotates so + the next backlogged completion claims and delivers on the next + opportunity. No row reaches the terminal dropped state.""" + _reset_wakeup_state() + registry = _install_fake_process_registry(monkeypatch) + delivery = _install_fake_durable_delivery_api(monkeypatch) + from api import routes + + turn_started = threading.Event() + allow_turn = threading.Event() + round_ = {"n": 0} + + def _start_turn(session_id, prompt, **_kwargs): + turn_started.set() + assert allow_turn.wait(timeout=3), "wakeup never unblocked" + round_["n"] += 1 + if round_["n"] == 1: + return {"_status": 409, "error": "busy"} # lost turn admission + return {"_status": 200, "stream_id": "stream-1"} + + monkeypatch.setattr(routes, "start_session_turn", _start_turn) + monkeypatch.setattr(bp, "_session_has_active_turn", lambda _session_id: False) + monkeypatch.setattr(bp, "_emit_bg_task_complete_events_coalesced", lambda *_args: 1) + monkeypatch.setattr(bp, "ASYNC_DELIVERY_ROUTING_RETRY_SECONDS", 30.0) + + def _admission_empty() -> bool: + with bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK: + return not bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT + + evt_first = _async_delegation_event(delegation_id="deleg_rotate_0") + evt_second = _async_delegation_event(delegation_id="deleg_rotate_1") + try: + # Round 1: the admitted wakeup is transiently rejected. Its release + # must consume exactly one attempt and free the admission slot. + bp._process_async_delegation_event( + evt_first, + session_id="webui-session-1", + delegation_id=evt_first["delegation_id"], + process_registry=registry, + ) + assert _wait_until(turn_started.is_set) + allow_turn.set() + assert _wait_until(lambda: len(delivery["release"]) == 1) + assert delivery["delivery_attempts"] == 1 + assert delivery["delivery_state"] == "pending" + assert round_["n"] == 1 + assert _wait_until(_admission_empty), "slot must rotate after rejection" + + # Round 2: the next backlogged completion claims and is accepted. + turn_started.clear() + bp._process_async_delegation_event( + evt_second, + session_id="webui-session-1", + delegation_id=evt_second["delegation_id"], + process_registry=registry, + ) + assert _wait_until(turn_started.is_set) + allow_turn.set() + assert _wait_until(lambda: len(delivery["complete"]) == 1) + assert delivery["delivery_state"] == "delivered" + assert delivery["delivery_attempts"] == 2 + assert len(delivery["release"]) == 1 + assert round_["n"] == 2 + assert _wait_until(_admission_empty) + finally: + allow_turn.set() + _reset_wakeup_state() + + +def test_admission_slot_released_on_claim_and_format_failures(monkeypatch): + """Issue #6959 guard: every pre-dispatch exit of the delivery path must + release the per-origin admission reservation, or a failed claim/format + would block all future wakeups for that session.""" + _reset_wakeup_state() + registry = _install_fake_process_registry(monkeypatch) + delivery = _install_fake_durable_delivery_api(monkeypatch) + from api import routes + + accepted = [] + + monkeypatch.setattr( + routes, + "start_session_turn", + lambda *_args, **_kwargs: accepted.append(1) + or {"_status": 200, "stream_id": "stream-1"}, + ) + monkeypatch.setattr(bp, "_session_has_active_turn", lambda _session_id: False) + monkeypatch.setattr(bp, "_emit_bg_task_complete_events_coalesced", lambda *_args: 1) + monkeypatch.setattr(bp, "ASYNC_DELIVERY_ROUTING_RETRY_SECONDS", 30.0) + + def _admission_empty() -> bool: + with bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK: + return not bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT + + try: + # Claim returns None (row already delivered): slot must be released. + delivery["delivery_state"] = "delivered" + bp._process_async_delegation_event( + _async_delegation_event(delegation_id="deleg_noclaim"), + session_id="webui-session-1", + delegation_id="deleg_noclaim", + process_registry=registry, + ) + assert _admission_empty(), "claim-None exit must release the admission slot" + + # Formatting failure (empty prompt): slot must be released. + delivery["delivery_state"] = "pending" + monkeypatch.setattr(bp, "format_wakeup_prompt", lambda _evt: "") + bp._process_async_delegation_event( + _async_delegation_event(delegation_id="deleg_badfmt"), + session_id="webui-session-1", + delegation_id="deleg_badfmt", + process_registry=registry, + ) + assert _admission_empty(), "format-failure exit must release the admission slot" + + # A normal wakeup for the same session still claims and delivers. + monkeypatch.setattr(bp, "format_wakeup_prompt", lambda _evt: "delegation result") + bp._process_async_delegation_event( + _async_delegation_event(delegation_id="deleg_ok"), + session_id="webui-session-1", + delegation_id="deleg_ok", + process_registry=registry, + ) + assert _wait_until(lambda: len(delivery["complete"]) == 1) + assert delivery["delivery_state"] == "delivered" + assert accepted + finally: + _reset_wakeup_state() + + From 06fc0575d55ac079c4da66a27fb090d94bea8783 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Tue, 18 Aug 2026 18:51:02 -0300 Subject: [PATCH 153/446] fix(async): close pre-ACTIVE_RUNS stream window, never strand wakeup admission (#6989) --- api/background_process.py | 159 +++++++++++++------- tests/test_async_delegation_webui_bridge.py | 120 +++++++++++++++ 2 files changed, 221 insertions(+), 58 deletions(-) diff --git a/api/background_process.py b/api/background_process.py index a692fe7d..2a3c581f 100644 --- a/api/background_process.py +++ b/api/background_process.py @@ -1171,60 +1171,73 @@ def _process_async_delegation_event( ) return + # Ownership-transfer guard (#6959 gate): the outer finally releases the + # per-origin reservation on EVERY exit below — claim exception, claim-None, + # formatting failure, dispatch failure, or any exception raised by the + # retry helpers themselves — UNLESS the wakeup thread successfully started, + # in which case the thread's own finally now owns the release. No raisable + # call can therefore strand the reservation (a stranded slot would defer + # every later completion for this origin forever, without delivery). + transfer_owned = False try: - claim = claim_async_delegation_delivery(evt, "webui-background") - except Exception: - _release_async_delegation_wakeup_admission(session_id) - _requeue_async_delegation_event(process_registry, evt) - return - if claim is None: - completion_queue = getattr(process_registry, "completion_queue", None) - schedule_async_delegation_claim_retry(evt, completion_queue) - _release_async_delegation_wakeup_admission(session_id) - return + try: + claim = claim_async_delegation_delivery(evt, "webui-background") + except Exception: + _requeue_async_delegation_event(process_registry, evt) + return + if claim is None: + completion_queue = getattr(process_registry, "completion_queue", None) + schedule_async_delegation_claim_retry(evt, completion_queue) + return - try: - wakeup_prompt_raw = format_wakeup_prompt(evt) - wakeup_prompt = wakeup_prompt_raw.strip() if wakeup_prompt_raw else "" - if not wakeup_prompt: - raise RuntimeError("async delegation completion could not be formatted") - except Exception: - # A formatting failure is a permanent, non-retryable defect (a malformed - # event will never format correctly), so it must stay on the bounded - # one-shot path — never keep_legacy_retrying, or it would loop forever. - release_async_delegation_delivery(evt, claim) - _retry_unclaimed_async_delegation_event(process_registry, evt) - _release_async_delegation_wakeup_admission(session_id) - logger.warning( - "async delegation completion could not be formatted for session %s", - session_id, - exc_info=True, - ) - return + try: + wakeup_prompt_raw = format_wakeup_prompt(evt) + wakeup_prompt = wakeup_prompt_raw.strip() if wakeup_prompt_raw else "" + if not wakeup_prompt: + raise RuntimeError("async delegation completion could not be formatted") + except Exception: + # A formatting failure is a permanent, non-retryable defect (a malformed + # event will never format correctly), so it must stay on the bounded + # one-shot path — never keep_legacy_retrying, or it would loop forever. + release_async_delegation_delivery(evt, claim) + _retry_unclaimed_async_delegation_event(process_registry, evt) + logger.warning( + "async delegation completion could not be formatted for session %s", + session_id, + exc_info=True, + ) + return - try: - _start_async_delegation_wakeup_turn( - session_id, - wakeup_prompt, - delegation_id=delegation_id, - evt=evt, - claim=claim, - process_registry=process_registry, - ) - except Exception: - # A post-format dispatch failure against a resolved target is transient - # (the target may accept on a later pass), so keep the legacy completion - # retrying rather than dropping it after one bounded attempt. - release_async_delegation_delivery(evt, claim) - _retry_unclaimed_async_delegation_event( - process_registry, evt, keep_legacy_retrying=True - ) - _release_async_delegation_wakeup_admission(session_id) - logger.warning( - "server-side async delegation dispatch failed for session %s", - session_id, - exc_info=True, - ) + try: + _start_async_delegation_wakeup_turn( + session_id, + wakeup_prompt, + delegation_id=delegation_id, + evt=evt, + claim=claim, + process_registry=process_registry, + ) + except Exception: + # A post-format dispatch failure against a resolved target is transient + # (the target may accept on a later pass), so keep the legacy completion + # retrying rather than dropping it after one bounded attempt. + release_async_delegation_delivery(evt, claim) + _retry_unclaimed_async_delegation_event( + process_registry, evt, keep_legacy_retrying=True + ) + logger.warning( + "server-side async delegation dispatch failed for session %s", + session_id, + exc_info=True, + ) + return + # The wakeup thread is running: its own finally owns the release from + # here on (set only after Thread.start() returns, so a mid-start + # failure still releases here). + transfer_owned = True + finally: + if not transfer_owned: + _release_async_delegation_wakeup_admission(session_id) def _resolve_completion_target( @@ -1632,14 +1645,44 @@ def _session_has_active_turn(session_id: str) -> bool: ``_emit_to_session_streams``) to map a stream back to its owning session. ACTIVE_RUNS is registered at agent-worker start and removed in the worker's outer ``finally``, so it survives cancel/reconnect races better than - STREAMS. There is a brief window where ``_start_chat_stream_for_session`` - has populated STREAMS but the worker thread has not yet called - ``register_active_run``; in that window this returns False and the - subsequent ``start_session_turn`` is rejected with a 409 by - ``_start_chat_stream_for_session``'s own active-stream guard — i.e. the - same lock /api/chat/start uses is the authoritative race backstop. + STREAMS. We ALSO count a same-session live STREAMS entry (via the + synchronously-populated STREAM_SESSION_OWNERS registry): the worker + publishes its stream BEFORE it registers in ACTIVE_RUNS, and that + pre-registration window must not look idle to a sibling completion + (#6959 gate — see the STREAMS check below). ``_start_chat_stream_for_session``'s + own active-stream guard remains the authoritative 409 backstop for any + residual race. """ - return bool(_active_run_ids_for_session(session_id, attachable_only=False)) + # ACTIVE_RUNS rows (including a bounded cancelling-run unwind window, + # per _active_run_ids_for_session). + if _active_run_ids_for_session(session_id, attachable_only=False): + return True + + # Pre-ACTIVE_RUNS publication window (#6959 gate): the agent worker + # publishes its stream — register_stream_owner() first, then the live + # STREAMS channel — BEFORE it registers in ACTIVE_RUNS. In that window a + # same-session live STREAMS entry means a turn is already (or about to be) + # active, so it must count here: otherwise a sibling async-delegation + # completion would pass the busy pre-check, reserve the per-origin + # admission, claim, and then 409 against the already-published stream — + # burning the finite delivery-attempt budget on every retry round. The + # worker's teardown finally pops STREAMS (and unregisters the stream owner) + # before the turn-teardown idle-hook drain runs, so the just-ended turn + # does not keep the session busy. + from api import config as _cfg + + try: + with _cfg.STREAMS_LOCK: + live_stream_ids = list((_cfg.STREAMS or {}).keys()) + with _cfg.STREAM_SESSION_OWNERS_LOCK: + stream_owners = dict(_cfg.STREAM_SESSION_OWNERS or {}) + except Exception: + logger.debug("STREAMS active-turn check failed", exc_info=True) + return False + for _stream_id in live_stream_ids: + if str(stream_owners.get(_stream_id) or "") == str(session_id or ""): + return True + return False def _start_server_side_wakeup_turn( diff --git a/tests/test_async_delegation_webui_bridge.py b/tests/test_async_delegation_webui_bridge.py index 65d90cb4..e3e1de7f 100644 --- a/tests/test_async_delegation_webui_bridge.py +++ b/tests/test_async_delegation_webui_bridge.py @@ -1506,3 +1506,123 @@ def test_admission_slot_released_on_claim_and_format_failures(monkeypatch): _reset_wakeup_state() +def test_busy_predicate_covers_stream_publication_window_before_active_runs( + monkeypatch, +): + """#6959 gate (MUST-FIX 1 repro): a worker blocked *before* ACTIVE_RUNS + registration — stream already published (STREAMS + STREAM_SESSION_OWNERS + populated), worker thread not yet in ACTIVE_RUNS — must still count as an + active turn. Otherwise a sibling same-origin completion would pass the busy + pre-check, reserve, claim, and 409 against the already-published stream, + burning the finite delivery-attempt budget. With the fix, completions that + arrive in that window defer WITHOUT claiming (zero claims, zero attempts).""" + _reset_wakeup_state() + registry = _install_fake_process_registry(monkeypatch) + delivery = _install_fake_durable_delivery_api(monkeypatch) + # ACTIVE_RUNS is empty: the worker has NOT yet registered — only the + # pre-registration stream publication is visible. + monkeypatch.setattr(cfg, "ACTIVE_RUNS", {}) + monkeypatch.setattr(cfg, "STREAMS", {"stream-preactive": object()}) + monkeypatch.setattr( + cfg, + "STREAM_SESSION_OWNERS", + {"stream-preactive": "webui-session-1"}, + ) + monkeypatch.setattr(bp, "_emit_bg_task_complete_events_coalesced", lambda *_args: 1) + monkeypatch.setattr(bp, "ASYNC_DELIVERY_ROUTING_RETRY_SECONDS", 30.0) + + try: + # The real predicate (not monkeypatched) must see the published stream. + assert bp._session_has_active_turn("webui-session-1") is True + # Cross-origin isolation: another session is not blocked by it. + assert bp._session_has_active_turn("other-session") is False + + # A completion for the origin arrives in the publication window: it + # must defer via the busy path — zero claims, zero delivery attempts. + bp._process_async_delegation_event( + _async_delegation_event(delegation_id="deleg_preactive"), + session_id="webui-session-1", + delegation_id="deleg_preactive", + process_registry=registry, + ) + assert delivery["claim"] == [], ( + "no claim may be taken while the pre-ACTIVE_RUNS stream is live" + ) + assert delivery["delivery_attempts"] == 0 + assert delivery["release"] == [] + with bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK: + assert bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT == set(), ( + "busy-path exit must not leave a reservation behind" + ) + assert delivery["delivery_state"] == "pending" + finally: + _reset_wakeup_state() + + +def test_admission_slot_released_when_retry_helper_raises(monkeypatch): + """#6959 gate (MUST-FIX 2 repro): the post-reservation body must release + the per-origin admission even when a retry helper raises mid-path — the + old code released manually AFTER schedule_async_delegation_claim_retry, so + a raise there stranded the reservation permanently and every later + completion for the origin deferred forever without delivery.""" + _reset_wakeup_state() + registry = _install_fake_process_registry(monkeypatch) + delivery = _install_fake_durable_delivery_api(monkeypatch) + monkeypatch.setattr(bp, "_session_has_active_turn", lambda _session_id: False) + monkeypatch.setattr(bp, "ASYNC_DELIVERY_ROUTING_RETRY_SECONDS", 30.0) + + def _admission_empty() -> bool: + with bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_LOCK: + return not bp._ASYNC_DELEGATION_WAKEUP_ADMISSION_INFLIGHT + + try: + # Claim succeeds, then the claim-None retry scheduler raises: the + # exception must NOT strand the reservation. + delivery["delivery_state"] = "delivered" # claim returns None + monkeypatch.setattr( + bp, + "schedule_async_delegation_claim_retry", + lambda *a, **k: (_ for _ in ()).throw( + RuntimeError("retry scheduler boom") + ), + ) + with pytest.raises(RuntimeError): + bp._process_async_delegation_event( + _async_delegation_event(delegation_id="deleg_raise_claim"), + session_id="webui-session-1", + delegation_id="deleg_raise_claim", + process_registry=registry, + ) + assert _admission_empty(), ( + "reservation must be released even when the retry scheduler raises" + ) + + # The session is NOT bricked: a later completion still claims and + # delivers normally (real wakeup thread; its own finally releases). + from api import routes + + delivery["delivery_state"] = "pending" + monkeypatch.setattr( + bp, + "schedule_async_delegation_claim_retry", + lambda *a, **k: False, + ) + monkeypatch.setattr(bp, "format_wakeup_prompt", lambda _evt: "delegation result") + monkeypatch.setattr( + routes, + "start_session_turn", + lambda *_args, **_kwargs: {"_status": 200, "stream_id": "stream-1"}, + ) + bp._process_async_delegation_event( + _async_delegation_event(delegation_id="deleg_raise_ok"), + session_id="webui-session-1", + delegation_id="deleg_raise_ok", + process_registry=registry, + ) + assert _wait_until(lambda: len(delivery["complete"]) == 1) + assert delivery["delivery_state"] == "delivered" + assert _wait_until(_admission_empty) + finally: + _reset_wakeup_state() + + From f103dd4ca10716d7760320529eabbbbc811c4dbd Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 20 Sep 2026 10:44:04 +0000 Subject: [PATCH 154/446] docs(changelog): stamp #6989 atomic per-origin async wakeup admission Co-authored-by: webtecnica --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 784e2101..172494cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ ### Fixed +- **Concurrent background completions stop burning the async-delegation delivery budget.** + When several delegated runs finished at once and idle-woke the same session together, each + wakeup consumed a delivery attempt before discovering the others, so the budget could be + exhausted and later completions went undelivered. Admission is now an atomic per-origin + test-and-set: a second wakeup for an origin already in flight defers without claiming, so it + costs zero budget and stays eligible through durable restore. The reservation is released on + every exit — claim failure, formatting failure, dispatch failure, and turn completion — so a + failed wakeup cannot wedge an origin. Thanks @webtecnica. + - **Images and files produced during Codex commentary are viewable again.** The Agent's OpenAI Codex Responses adapter persists user-visible assistant progress in `codex_message_items` with `phase: "commentary"` while the outer `content` field stays empty. WebUI's session MEDIA authorization and its snapshot capture both read only that outer field, so a `MEDIA:` token emitted during commentary was invisible to both — the artifact was never authorized for the session token and never snapshotted, leaving the user unable to open a file the assistant had just produced. Both consumers now inspect the commentary sidecars as well. The widening is confined to genuinely assistant-authored, correctly-phased items: role, type, phase and content checks all fail closed, so a user-authored message, a tool result or a malformed item cannot grant a path token. Every existing guard is unchanged — exact-path matching, MIME allow-listing, session ownership, profile visibility and the state/secret hard-deny all still run on anything discovered through the new route, and snapshot capture keeps its confinement without double-capturing an artifact already taken from outer content. Verified against the Agent's real producer shape plus an adversarial probe covering user/wrong-role/wrong-phase, denied-state, wrong-path, duplicate, malformed and 300k-item inputs. Thanks @happy5318. (#7654, #7565) - **Custom model names containing colons are no longer truncated in the picker.** A model id like `ollamacloud/qwen3.5:397b` was cut at its internal colon, so the picker showed a mangled label and two models that differed only after the colon could render identically. The colon is overloaded here — it separates a `@provider:model` routing lane, and it also appears inside perfectly ordinary model names — so the label builder now takes the catalog as the authority for `@custom` entries and leaves a plain-lane model id whole. Labels are display-only: the original option value survives selection and is dispatched unchanged, so no routing behaviour moves. Catalogued, cold, empty, stale, host:port, leading- and trailing-colon and multi-colon names were each checked to stay non-blank and exception-free. Thanks @webtecnica. (#7401, #7240) From 7bb12d0aab62a51d200da4c29c76389c8a94d370 Mon Sep 17 00:00:00 2001 From: carlotestor Date: Fri, 18 Sep 2026 12:23:25 +0000 Subject: [PATCH 155/446] fix: keep per-profile models disk cache across /api/profile/switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/profile/switch called invalidate_models_cache(), whose test-isolation contract also unlinks models_cache..json. On a switch nothing about the sources changed, so the delete only forced a full cold rebuild (live provider fetch_models HTTPS calls, ~490k deepcopy calls) on the very next /api/models — several seconds on every switch. The disk cache is already keyed per profile and rejected on read when _models_cache_source_fingerprint() differs, so it is safe to keep. Add a delete_disk keyword (default True — every existing caller keeps the old behaviour) and have the switch route pass delete_disk=False. Regression tests cover: memory-only drop preserves the disk file, default call still unlinks it, the next get_available_models() serves the disk snapshot without a live rebuild, and the switch route opts out. Two existing zero-arg stubs of invalidate_models_cache accept **kwargs now. --- api/config.py | 13 +- api/routes.py | 9 +- tests/test_gateway_watcher_profile.py | 4 +- .../test_profile_switch_models_disk_cache.py | 126 ++++++++++++++++++ tests/test_trusted_header_auth.py | 4 +- 5 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 tests/test_profile_switch_models_disk_cache.py diff --git a/api/config.py b/api/config.py index 979ce3f7..87c1ce06 100644 --- a/api/config.py +++ b/api/config.py @@ -8156,7 +8156,7 @@ def _get_fresh_memory_models_cache(now: float) -> dict | None: return None -def invalidate_models_cache(): +def invalidate_models_cache(*, delete_disk: bool = True): """Force the TTL cache for get_available_models() to be cleared. Call this after modifying config.cfg in-memory (e.g. in tests) so @@ -8169,6 +8169,14 @@ def invalidate_models_cache(): that call invalidate_models_cache() still get back the previous test's result from the disk cache because the disk hit is checked before the memory cache rebuild runs. + + ``delete_disk=False`` drops only the in-memory snapshot and leaves the + per-profile disk cache in place. Use it when the *sources* have not + changed and the caller merely needs the next request to re-resolve which + profile's catalog to serve (e.g. a per-client profile switch): the disk + cache is already keyed per profile and guarded by + _models_cache_source_fingerprint(), so a stale snapshot is rejected on + read without paying for a full rebuild (live provider fetches). """ global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts global _available_models_live_rebuild_ts, _available_models_cache_source_fingerprint, _cache_build_cv @@ -8187,7 +8195,8 @@ def invalidate_models_cache(): _CREDENTIAL_POOL_CACHE.clear() # Also delete the disk cache so the next cold build starts fresh. # Disk delete is outside the lock — file I/O shouldn't block other readers. - _delete_models_cache_on_disk() + if delete_disk: + _delete_models_cache_on_disk() try: from api.plugin_providers import invalidate_plugin_model_provider_cache diff --git a/api/routes.py b/api/routes.py index 0e159784..fec6cc3d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -16858,11 +16858,14 @@ def handle_post(handler, parsed) -> bool: # process_wide=False: don't mutate the process-global _active_profile. # Per-client profile is managed via cookie + thread-local (#798). result = switch_profile(name, process_wide=False) - # Invalidate the models cache so the very next /api/models request - # rebuilds from the new profile's config.yaml rather than returning + # Drop the in-memory catalog so the very next /api/models request + # re-resolves the new profile's config.yaml rather than returning # the old profile's cached model list (#1200 — profile-switch model bug). + # Keep the per-profile disk cache: it is fingerprint-guarded, and + # deleting it forced a full cold rebuild (live provider fetches, + # several seconds) on every switch even though nothing changed. from api.config import invalidate_models_cache - invalidate_models_cache() + invalidate_models_cache(delete_disk=False) try: from api.gateway_watcher import restart_watcher_for_profile restart_watcher_for_profile(name) diff --git a/tests/test_gateway_watcher_profile.py b/tests/test_gateway_watcher_profile.py index 5b4a4200..fd34d7b8 100644 --- a/tests/test_gateway_watcher_profile.py +++ b/tests/test_gateway_watcher_profile.py @@ -316,7 +316,7 @@ def test_profile_switch_restarts_watcher_best_effort(monkeypatch): monkeypatch.setattr(routes, "read_body", lambda handler: {"name": "demo"}) monkeypatch.setattr(profiles, "_validate_profile_name", lambda name: None) monkeypatch.setattr(profiles, "switch_profile", lambda name, process_wide=False: {"ok": True, "name": name}) - monkeypatch.setattr(config, "invalidate_models_cache", lambda: calls.append("cache")) + monkeypatch.setattr(config, "invalidate_models_cache", lambda *_a, **_kw: calls.append("cache")) monkeypatch.setattr(gateway_watcher, "restart_watcher_for_profile", lambda name: calls.append(("watcher", name))) handler = _FakeHandler() @@ -335,7 +335,7 @@ def test_profile_switch_response_survives_watcher_restart_failure(monkeypatch): monkeypatch.setattr(routes, "read_body", lambda handler: {"name": "demo"}) monkeypatch.setattr(profiles, "_validate_profile_name", lambda name: None) monkeypatch.setattr(profiles, "switch_profile", lambda name, process_wide=False: {"ok": True, "name": name}) - monkeypatch.setattr(config, "invalidate_models_cache", lambda: None) + monkeypatch.setattr(config, "invalidate_models_cache", lambda *_a, **_kw: None) monkeypatch.setattr( gateway_watcher, "restart_watcher_for_profile", diff --git a/tests/test_profile_switch_models_disk_cache.py b/tests/test_profile_switch_models_disk_cache.py new file mode 100644 index 00000000..0146d163 --- /dev/null +++ b/tests/test_profile_switch_models_disk_cache.py @@ -0,0 +1,126 @@ +"""Profile switch must not discard the per-profile models disk cache. + +``POST /api/profile/switch`` calls ``invalidate_models_cache()`` so the next +``/api/models`` re-resolves the new profile's catalog (#1200). That helper also +deleted the on-disk ``models_cache..json`` — a test-isolation +behaviour — which forced a full cold rebuild (live provider ``fetch_models`` +HTTPS calls, ~490k deepcopy calls) on every switch even though no source had +changed. The disk cache is already keyed per profile and rejected on read when +``_models_cache_source_fingerprint()`` differs, so deleting it buys no +correctness on the switch path and only costs seconds of latency. + +The switch route now uses ``invalidate_models_cache(delete_disk=False)``. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + + +def _catalog(label: str) -> dict: + return { + "active_provider": "openai", + "default_model": label, + "configured_model_badges": {}, + "groups": [ + { + "provider": "OpenAI", + "provider_id": "openai", + "models": [{"id": label, "label": label, "supports_fast_tier": False}], + } + ], + "aliases": {}, + } + + +def _prime_memory_cache(monkeypatch, catalog: dict): + import api.config as cfg + + monkeypatch.setattr(cfg, "_available_models_cache", catalog, raising=False) + monkeypatch.setattr(cfg, "_available_models_cache_ts", 1.0, raising=False) + monkeypatch.setattr(cfg, "_available_models_cache_source_fingerprint", {"p": "x"}, raising=False) + monkeypatch.setattr(cfg, "_cache_build_in_progress", False, raising=False) + + +def test_invalidate_keeps_disk_cache_when_delete_disk_false(tmp_path, monkeypatch): + import api.config as cfg + + cache_path = tmp_path / "models_cache.profile.json" + cache_path.write_text("{}", encoding="utf-8") + monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path) + _prime_memory_cache(monkeypatch, _catalog("old-profile-model")) + + cfg.invalidate_models_cache(delete_disk=False) + + # In-memory snapshot dropped so the next request re-resolves the profile... + assert cfg._available_models_cache is None + assert cfg._available_models_cache_source_fingerprint is None + assert cfg._models_cache_provenance is None + # ...but the fingerprint-guarded disk cache survives. + assert cache_path.exists(), "delete_disk=False must not unlink the per-profile disk cache" + + +def test_invalidate_default_still_deletes_disk_cache(tmp_path, monkeypatch): + """Test-isolation contract is unchanged for every existing caller.""" + import api.config as cfg + + cache_path = tmp_path / "models_cache.profile.json" + cache_path.write_text("{}", encoding="utf-8") + monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path) + _prime_memory_cache(monkeypatch, _catalog("old-profile-model")) + + cfg.invalidate_models_cache() + + assert cfg._available_models_cache is None + assert not cache_path.exists() + + +def test_memory_only_invalidate_serves_next_request_from_disk_without_rebuild(tmp_path, monkeypatch): + """After a memory-only drop, get_available_models() must reload the disk + snapshot instead of running the live rebuild.""" + import api.config as cfg + + cache_path = tmp_path / "models_cache.profile.json" + cache_path.write_text("{}", encoding="utf-8") + disk_catalog = _catalog("disk-model") + monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path) + monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: disk_catalog if cache_path.exists() else None) + monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: None) + monkeypatch.setattr(cfg, "_models_cache_source_fingerprint", lambda: {"profile": "demo"}) + # Pin config mtime tracking so an unrelated config change (or leaked + # state from an earlier test) cannot force the reload branch. + cfg_file = tmp_path / "config.yaml" + cfg_file.write_text("model: {}\n", encoding="utf-8") + monkeypatch.setattr(cfg, "_get_config_path", lambda: cfg_file) + monkeypatch.setattr(cfg, "_cfg_path", cfg_file, raising=False) + monkeypatch.setattr(cfg, "_cfg_mtime", cfg_file.stat().st_mtime, raising=False) + + def _unexpected_rebuild(*_a, **_kw): + raise AssertionError("profile switch must not trigger a live models rebuild") + + monkeypatch.setattr(cfg, "_invoke_models_rebuild", _unexpected_rebuild) + _prime_memory_cache(monkeypatch, _catalog("stale-memory-model")) + + cfg.invalidate_models_cache(delete_disk=False) + result = cfg.get_available_models() + + assert result["default_model"] == "disk-model" + + +def test_profile_switch_route_preserves_disk_cache(): + """The switch handler must opt out of the disk delete.""" + src = (REPO / "api" / "routes.py").read_text(encoding="utf-8") + start = src.index('if parsed.path == "/api/profile/switch":') + end = src.index('if parsed.path == "/api/profile/create":', start) + block = src[start:end] + + calls = re.findall(r"invalidate_models_cache\(([^)]*)\)", block) + assert calls, "switch route must still invalidate the in-memory models cache (#1200)" + for args in calls: + assert "delete_disk=False" in args, ( + "/api/profile/switch must call invalidate_models_cache(delete_disk=False); " + "deleting the disk cache forces a multi-second cold rebuild on every switch" + ) diff --git a/tests/test_trusted_header_auth.py b/tests/test_trusted_header_auth.py index baf35692..814cef15 100644 --- a/tests/test_trusted_header_auth.py +++ b/tests/test_trusted_header_auth.py @@ -438,7 +438,7 @@ def test_profile_switch_accepts_bound_profile(monkeypatch): monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True) monkeypatch.setattr(routes, "read_body", lambda _handler: {"name": "devops"}) monkeypatch.setattr("api.profiles.switch_profile", lambda name, process_wide=False: {"ok": True, "profile": name}) - monkeypatch.setattr("api.config.invalidate_models_cache", lambda: None) + monkeypatch.setattr("api.config.invalidate_models_cache", lambda *_a, **_kw: None) monkeypatch.setattr("api.gateway_watcher.restart_watcher_for_profile", lambda _name: None) routes.handle_post(handler, SimpleNamespace(path="/api/profile/switch", query="")) @@ -851,7 +851,7 @@ def test_consumers_route_through_auth_owner(monkeypatch): monkeypatch.setattr(routes, "_check_csrf", lambda _handler: True) monkeypatch.setattr(routes, "read_body", lambda _handler: {"name": "devops"}) monkeypatch.setattr("api.profiles.switch_profile", lambda name, process_wide=False: {"ok": True, "profile": name}) - monkeypatch.setattr("api.config.invalidate_models_cache", lambda: None) + monkeypatch.setattr("api.config.invalidate_models_cache", lambda *_a, **_kw: None) monkeypatch.setattr("api.gateway_watcher.restart_watcher_for_profile", lambda _name: None) routes.handle_get(handler, SimpleNamespace(path="/api/auth/status", query="")) From 217a2fcea8eadc91385de0919086c732f1f6dfd3 Mon Sep 17 00:00:00 2001 From: carlotestor Date: Fri, 18 Sep 2026 18:39:44 +0000 Subject: [PATCH 156/446] review: document delete_disk invalidation modes; assert switch route kwargs via executed handle_post - docs/architecture/models-cache-invalidation.md: add 'Invalidation modes' section describing delete_disk=True/False and why /api/profile/switch uses the memory-only mode (fingerprint-guarded per-profile disk cache). - tests: replace the source-text scan of the switch route with two executed routes.handle_post tests: one captures invalidate_models_cache kwargs and asserts exactly [((), {'delete_disk': False})]; one runs the real invalidate_models_cache and asserts the disk file survives while the in-memory catalog is dropped. Both fail if the route reverts to the default delete_disk=True. --- .../architecture/models-cache-invalidation.md | 25 +++++ .../test_profile_switch_models_disk_cache.py | 96 +++++++++++++++---- 2 files changed, 104 insertions(+), 17 deletions(-) diff --git a/docs/architecture/models-cache-invalidation.md b/docs/architecture/models-cache-invalidation.md index f50d14b0..21f4f2bd 100644 --- a/docs/architecture/models-cache-invalidation.md +++ b/docs/architecture/models-cache-invalidation.md @@ -66,6 +66,26 @@ the fingerprint captured at publish time. TTL expires. When the runtime version cannot be resolved (early boot), that check is skipped rather than wedging the boot. +## Invalidation modes (`invalidate_models_cache`) + +`invalidate_models_cache(*, delete_disk=True)` is the only entry point that +drops the published snapshot. It has two modes: + +| Mode | What is dropped | When to use | +| --- | --- | --- | +| `delete_disk=True` (default) | in-memory snapshot **and** the per-profile `models_cache.json` on disk | A source may have changed, or test isolation requires a guaranteed cold build. Every pre-existing caller keeps this mode. | +| `delete_disk=False` | in-memory snapshot only; the disk snapshot is left in place | The sources have **not** changed and the caller only needs the next request to re-resolve *which* profile's catalog to serve. | + +`POST /api/profile/switch` uses `delete_disk=False`. The disk cache is already +keyed per profile (`_get_models_cache_path()`), and a stale or wrong-profile +snapshot is rejected on read by `_is_loadable_disk_cache()` via the source +fingerprint above, so deleting it on a switch bought no correctness — it only +forced a full cold rebuild (live provider `fetch_models` calls, several seconds) +on every switch. Because this mode leans entirely on the fingerprint check, it +is safe only while that check stays the single gate for serving a disk snapshot +(change-protocol items 1 and 4). Both modes must still call +`_sync_models_cache_provenance()` so the hot-path tuple cannot tear. + ## Change protocol 1. Add or change a source axis in `_models_cache_source_fingerprint()` only — @@ -90,6 +110,11 @@ timestamp-only churn keeps the fingerprint identical (and a session visit after Codex refresh needs no live rebuild), while genuine changes — a new model, a visibility change, any catalog field, any unknown field — still invalidate. +`tests/test_profile_switch_models_disk_cache.py` covers the invalidation modes: +`delete_disk=False` keeps the disk file and the next `get_available_models()` +reloads it without a live rebuild, the default still unlinks it, and the +executed `/api/profile/switch` route passes `delete_disk=False`. + ## References Issues/PRs: #2443, #7540, #7556, #7558. RCAs: `t_d127953d`, `t_16551f61`. diff --git a/tests/test_profile_switch_models_disk_cache.py b/tests/test_profile_switch_models_disk_cache.py index 0146d163..3f121971 100644 --- a/tests/test_profile_switch_models_disk_cache.py +++ b/tests/test_profile_switch_models_disk_cache.py @@ -14,10 +14,8 @@ The switch route now uses ``invalidate_models_cache(delete_disk=False)``. from __future__ import annotations -import re -from pathlib import Path - -REPO = Path(__file__).resolve().parent.parent +import json +from urllib.parse import urlparse def _catalog(label: str) -> dict: @@ -110,17 +108,81 @@ def test_memory_only_invalidate_serves_next_request_from_disk_without_rebuild(tm assert result["default_model"] == "disk-model" -def test_profile_switch_route_preserves_disk_cache(): - """The switch handler must opt out of the disk delete.""" - src = (REPO / "api" / "routes.py").read_text(encoding="utf-8") - start = src.index('if parsed.path == "/api/profile/switch":') - end = src.index('if parsed.path == "/api/profile/create":', start) - block = src[start:end] +class _FakeHandler: + def __init__(self): + self.status = None + self.sent_headers: list[tuple[str, str]] = [] + self.body = bytearray() + self.wfile = self + self.headers = {} - calls = re.findall(r"invalidate_models_cache\(([^)]*)\)", block) - assert calls, "switch route must still invalidate the in-memory models cache (#1200)" - for args in calls: - assert "delete_disk=False" in args, ( - "/api/profile/switch must call invalidate_models_cache(delete_disk=False); " - "deleting the disk cache forces a multi-second cold rebuild on every switch" - ) + def send_response(self, code): + self.status = code + + def send_header(self, key, value): + self.sent_headers.append((key, value)) + + def end_headers(self): + pass + + def write(self, data): + self.body.extend(data if isinstance(data, (bytes, bytearray)) else data.encode("utf-8")) + + def get_json(self): + return json.loads(self.body.decode("utf-8")) + + +def _drive_profile_switch(monkeypatch, name: str = "demo") -> _FakeHandler: + """Execute POST /api/profile/switch through routes.handle_post with the + auth / CSRF / profile-fs / watcher collaborators stubbed out, leaving + ``config.invalidate_models_cache`` to whatever the caller installed.""" + from api import gateway_watcher, profiles, routes + + for mod in ("api.auth", "api.helpers", "api.routes"): + monkeypatch.setattr(f"{mod}.is_auth_enabled", lambda: False, raising=False) + monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) + monkeypatch.setattr(routes, "read_body", lambda handler: {"name": name}) + monkeypatch.setattr(profiles, "_validate_profile_name", lambda _name: None) + monkeypatch.setattr(profiles, "switch_profile", lambda _name, process_wide=False: {"ok": True, "name": _name}) + monkeypatch.setattr(gateway_watcher, "restart_watcher_for_profile", lambda _name: None) + + handler = _FakeHandler() + routes.handle_post(handler, urlparse("/api/profile/switch")) + return handler + + +def test_profile_switch_route_calls_invalidate_with_delete_disk_false(monkeypatch): + """Executed-route guard: the live switch handler must pass + ``delete_disk=False`` (not rely on the default, which unlinks the disk + cache and forces a cold rebuild).""" + from api import config + + calls: list[tuple[tuple, dict]] = [] + monkeypatch.setattr(config, "invalidate_models_cache", lambda *a, **kw: calls.append((a, kw))) + + handler = _drive_profile_switch(monkeypatch) + + assert handler.status == 200, handler.body.decode("utf-8", "replace") + assert handler.get_json() == {"ok": True, "name": "demo"} + assert calls == [((), {"delete_disk": False})], ( + "/api/profile/switch must call invalidate_models_cache(delete_disk=False) exactly once; " + f"got {calls!r}" + ) + + +def test_profile_switch_route_leaves_disk_cache_file_in_place(tmp_path, monkeypatch): + """End-to-end through the real ``invalidate_models_cache``: after the + executed switch route, the in-memory catalog is dropped but the + per-profile disk snapshot still exists.""" + from api import config as cfg + + cache_path = tmp_path / "models_cache.profile.json" + cache_path.write_text("{}", encoding="utf-8") + monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: cache_path) + _prime_memory_cache(monkeypatch, _catalog("old-profile-model")) + + handler = _drive_profile_switch(monkeypatch) + + assert handler.status == 200, handler.body.decode("utf-8", "replace") + assert cfg._available_models_cache is None, "switch must still drop the in-memory catalog (#1200)" + assert cache_path.exists(), "switch must not unlink the per-profile disk cache" From 0415d07cd0d1ce824657a89e9787fcf0361f459d Mon Sep 17 00:00:00 2001 From: carlotestor Date: Sat, 19 Sep 2026 10:50:37 +0000 Subject: [PATCH 157/446] docs: invalidate_models_cache is the only delete_disk entry point, not the only snapshot-dropping path --- docs/architecture/models-cache-invalidation.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/architecture/models-cache-invalidation.md b/docs/architecture/models-cache-invalidation.md index 21f4f2bd..ce96c968 100644 --- a/docs/architecture/models-cache-invalidation.md +++ b/docs/architecture/models-cache-invalidation.md @@ -69,7 +69,13 @@ the fingerprint captured at publish time. ## Invalidation modes (`invalidate_models_cache`) `invalidate_models_cache(*, delete_disk=True)` is the only entry point that -drops the published snapshot. It has two modes: +offers the `delete_disk` choice. It is **not** the only path that drops the +published in-memory snapshot: `invalidate_provider_models_cache()`, +`_get_fresh_memory_models_cache()` (on a fingerprint mismatch or an invalid +cached shape) and the config-reload branch inside +`get_available_models()` also reset `_available_models_cache` and call +`_sync_models_cache_provenance()`. None of those paths take a `delete_disk` +argument; they only clear memory. `invalidate_models_cache` has two modes: | Mode | What is dropped | When to use | | --- | --- | --- | From 63d024dc45156d1ab0067a135dab776ce5dd6328 Mon Sep 17 00:00:00 2001 From: carlotestor Date: Fri, 18 Sep 2026 11:40:51 +0000 Subject: [PATCH 158/446] feat(sidebar): make the visible-session window size configurable CLI_VISIBLE_SESSION_LIMIT is hardcoded to 20. That constant also bounds how many delegated subagent children can render at once, since a child only nests when its parent row is in the same payload, so operators running wide delegate_task fan-outs cannot see all children without editing the source. Read it from HERMES_WEBUI_VISIBLE_SESSION_LIMIT via the existing _env_int helper, keeping 20 as the default. --- api/models.py | 6 ++++- tests/test_visible_session_limit_env.py | 35 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/test_visible_session_limit_env.py diff --git a/api/models.py b/api/models.py index 84a91b9c..99226cb4 100644 --- a/api/models.py +++ b/api/models.py @@ -48,7 +48,11 @@ from api.agent_sessions import ( from api.process_event_utils import stamp_message_source logger = logging.getLogger(__name__) -CLI_VISIBLE_SESSION_LIMIT = 20 +# Size of the interactive sidebar recency window. Also bounds how many +# delegated subagent children can be rendered at once, since a child only +# nests when it wins a slot in this window. Override with +# HERMES_WEBUI_VISIBLE_SESSION_LIMIT. +CLI_VISIBLE_SESSION_LIMIT = _cfg._env_int("HERMES_WEBUI_VISIBLE_SESSION_LIMIT", 20) # How many messageful cron sessions to surface in the project-chip layer. # Needs to exceed CLI_VISIBLE_SESSION_LIMIT so older cron runs stay # addressable even when many newer non-cron sessions dominate the default diff --git a/tests/test_visible_session_limit_env.py b/tests/test_visible_session_limit_env.py new file mode 100644 index 00000000..178465f5 --- /dev/null +++ b/tests/test_visible_session_limit_env.py @@ -0,0 +1,35 @@ +"""CLI_VISIBLE_SESSION_LIMIT is overridable via HERMES_WEBUI_VISIBLE_SESSION_LIMIT. + +The sidebar recency window also bounds how many delegated subagent children can +render at once, since a child only nests when its row wins a slot in the same +payload. Operators running wide fan-outs need to raise it without editing code. +""" + +import importlib + + +def _reload_limit(monkeypatch, value): + if value is None: + monkeypatch.delenv("HERMES_WEBUI_VISIBLE_SESSION_LIMIT", raising=False) + else: + monkeypatch.setenv("HERMES_WEBUI_VISIBLE_SESSION_LIMIT", value) + import api.models as models + + return importlib.reload(models).CLI_VISIBLE_SESSION_LIMIT + + +def test_defaults_to_20_when_unset(monkeypatch): + assert _reload_limit(monkeypatch, None) == 20 + + +def test_env_override_raises_the_window(monkeypatch): + assert _reload_limit(monkeypatch, "64") == 64 + + +def test_invalid_value_falls_back_to_default(monkeypatch): + assert _reload_limit(monkeypatch, "bogus") == 20 + + +def test_zero_and_negative_fall_back_to_default(monkeypatch): + assert _reload_limit(monkeypatch, "0") == 20 + assert _reload_limit(monkeypatch, "-5") == 20 From 093191cfb8642fc87d5ccdb37533190d1fef607f Mon Sep 17 00:00:00 2001 From: carlotestor Date: Sat, 19 Sep 2026 11:10:48 +0000 Subject: [PATCH 159/446] docs: state memory and disk behaviour per invalidation path (provider invalidation and config reload also delete the disk snapshot) --- .../architecture/models-cache-invalidation.md | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/docs/architecture/models-cache-invalidation.md b/docs/architecture/models-cache-invalidation.md index ce96c968..5557be43 100644 --- a/docs/architecture/models-cache-invalidation.md +++ b/docs/architecture/models-cache-invalidation.md @@ -66,21 +66,28 @@ the fingerprint captured at publish time. TTL expires. When the runtime version cannot be resolved (early boot), that check is skipped rather than wedging the boot. -## Invalidation modes (`invalidate_models_cache`) +## Invalidation paths: memory vs. disk -`invalidate_models_cache(*, delete_disk=True)` is the only entry point that -offers the `delete_disk` choice. It is **not** the only path that drops the -published in-memory snapshot: `invalidate_provider_models_cache()`, -`_get_fresh_memory_models_cache()` (on a fingerprint mismatch or an invalid -cached shape) and the config-reload branch inside -`get_available_models()` also reset `_available_models_cache` and call -`_sync_models_cache_provenance()`. None of those paths take a `delete_disk` -argument; they only clear memory. `invalidate_models_cache` has two modes: +Every path that drops the published in-memory snapshot resets +`_available_models_cache` (plus its timestamps and source fingerprint) and +calls `_sync_models_cache_provenance()` so the hot-path tuple cannot tear. They +differ in what they do to the per-profile `models_cache.json` on disk +(`_delete_models_cache_on_disk()`): -| Mode | What is dropped | When to use | +| Path | In-memory snapshot | Disk snapshot | | --- | --- | --- | -| `delete_disk=True` (default) | in-memory snapshot **and** the per-profile `models_cache.json` on disk | A source may have changed, or test isolation requires a guaranteed cold build. Every pre-existing caller keeps this mode. | -| `delete_disk=False` | in-memory snapshot only; the disk snapshot is left in place | The sources have **not** changed and the caller only needs the next request to re-resolve *which* profile's catalog to serve. | +| `invalidate_models_cache(delete_disk=True)` (default) | dropped | **deleted** unconditionally | +| `invalidate_models_cache(delete_disk=False)` | dropped | left in place | +| `invalidate_provider_models_cache(provider_id)` | dropped | **deleted** unconditionally (no `delete_disk` option) | +| `_get_fresh_memory_models_cache()` on a fingerprint mismatch or invalid cached shape | dropped | untouched | +| config-reload branch in `get_available_models()` (`_cfg_changed`) | dropped | deleted by `reload_config()` → `_refresh_config_cache()` **only if** a config was already loaded (`_old_cfg_mtime != 0.0`); a first-ever load (server start, profile switch) keeps it | + +`invalidate_models_cache` is the only entry point that offers the +`delete_disk` choice. `delete_disk=True` is for when a source may have changed, +or test isolation requires a guaranteed cold build; every pre-existing caller +keeps this mode. `delete_disk=False` is for when the sources have **not** +changed and the caller only needs the next request to re-resolve *which* +profile's catalog to serve. `POST /api/profile/switch` uses `delete_disk=False`. The disk cache is already keyed per profile (`_get_models_cache_path()`), and a stale or wrong-profile @@ -89,8 +96,7 @@ fingerprint above, so deleting it on a switch bought no correctness — it only forced a full cold rebuild (live provider `fetch_models` calls, several seconds) on every switch. Because this mode leans entirely on the fingerprint check, it is safe only while that check stays the single gate for serving a disk snapshot -(change-protocol items 1 and 4). Both modes must still call -`_sync_models_cache_provenance()` so the hot-path tuple cannot tear. +(change-protocol items 1 and 4). ## Change protocol From 899b89e1d3456de78a3bf92efd03158230bc4553 Mon Sep 17 00:00:00 2001 From: carlotestor Date: Fri, 18 Sep 2026 18:39:47 +0000 Subject: [PATCH 160/446] docs+test: document HERMES_WEBUI_VISIBLE_SESSION_LIMIT; probe it in a subprocess - README env table + .env.example gain the new setting (default 20, fallback semantics), pinned by test_setting_is_documented. - tests/test_visible_session_limit_env.py no longer importlib.reload()s api.models in the shared pytest process (which recreated its module-level locks/caches/classes behind other modules' backs); each case now imports api.models in a fresh interpreter with a scrubbed env, matching test_issue3283_profiles_config_import_order. --- .env.example | 5 ++ README.md | 1 + tests/test_visible_session_limit_env.py | 72 ++++++++++++++++++------- 3 files changed, 60 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index 3a3d4c13..2682308f 100644 --- a/.env.example +++ b/.env.example @@ -132,6 +132,11 @@ # Extra sources for the notes drawer (see docs for the expected format). # HERMES_WEBUI_EXTERNAL_NOTES_SOURCES= +# Sidebar recency window: how many recent sessions are listed (default: 20). +# Also caps how many delegated subagent children can nest at once; raise it +# for wide fan-outs. Invalid or non-positive values fall back to 20. +# HERMES_WEBUI_VISIBLE_SESSION_LIMIT=20 + # Directory scanned for WebUI plugins (default: ~/.hermes/plugins). # HERMES_WEBUI_PLUGINS_DIR=~/.hermes/plugins diff --git a/README.md b/README.md index 7e73b9f1..3edf0f7b 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,7 @@ Full list of environment variables: | `HERMES_HOME` | Windows: `%LOCALAPPDATA%\hermes`; POSIX: `~/.hermes` | Base directory for Hermes state (affects all paths) | | `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file | | `HERMES_WEBUI_SERVER_CWD` | *(unset)* | Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable | +| `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` | `20` | Size of the sidebar's interactive recency window (how many recent non-cron/webhook sessions are listed). Also bounds how many delegated subagent children can nest at once, since a child only renders when its row wins a slot in the window — raise it for wide fan-outs. Non-integer or non-positive values fall back to the default | | `HERMES_WEBUI_AGENT_CACHE_MAX` | `25` | Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads) | | `HERMES_WEBUI_SESSIONS_MAX` | `100` | Legacy operator override for the max compact `Session` objects held in the in-memory LRU. Prefer the `webui.sessions_cache_max` key in `config.yaml` (which takes precedence); this env var remains a fallback. Bounds resident memory so long-running installs cannot accumulate every session ever touched and eventually crash (#4765/#2233/#4633). Eviction only ever drops clean, persisted, non-active sessions; an evicted session lazily reloads from its JSON sidecar on next access | diff --git a/tests/test_visible_session_limit_env.py b/tests/test_visible_session_limit_env.py index 178465f5..e1fc0f32 100644 --- a/tests/test_visible_session_limit_env.py +++ b/tests/test_visible_session_limit_env.py @@ -3,33 +3,69 @@ The sidebar recency window also bounds how many delegated subagent children can render at once, since a child only nests when its row wins a slot in the same payload. Operators running wide fan-outs need to raise it without editing code. + +The constant is bound once at import time, so each case imports ``api.models`` +in a fresh subprocess (same pattern as test_issue3283_profiles_config_import_order) +rather than ``importlib.reload``-ing it in the shared test process — reloading +would recreate the module's locks, caches, and classes while other modules keep +references to the old objects. """ -import importlib +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] + +_PROBE = """ +import api.models +print(api.models.CLI_VISIBLE_SESSION_LIMIT) +""" -def _reload_limit(monkeypatch, value): - if value is None: - monkeypatch.delenv("HERMES_WEBUI_VISIBLE_SESSION_LIMIT", raising=False) - else: - monkeypatch.setenv("HERMES_WEBUI_VISIBLE_SESSION_LIMIT", value) - import api.models as models - - return importlib.reload(models).CLI_VISIBLE_SESSION_LIMIT +def _limit_in_fresh_interpreter(tmp_path, value): + home = tmp_path / "home" + home.mkdir() + env = os.environ.copy() + for key in list(env): + if key.startswith("HERMES_WEBUI_") or key in ("HERMES_HOME", "HERMES_BASE_HOME"): + env.pop(key) + env["HOME"] = str(home) + env["PYTHONPATH"] = str(REPO_ROOT) + if value is not None: + env["HERMES_WEBUI_VISIBLE_SESSION_LIMIT"] = value + proc = subprocess.run( + [sys.executable, "-c", _PROBE], + cwd=REPO_ROOT, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=60, + check=False, + ) + assert proc.returncode == 0, proc.stderr + return int(proc.stdout.strip()) -def test_defaults_to_20_when_unset(monkeypatch): - assert _reload_limit(monkeypatch, None) == 20 +def test_defaults_to_20_when_unset(tmp_path): + assert _limit_in_fresh_interpreter(tmp_path, None) == 20 -def test_env_override_raises_the_window(monkeypatch): - assert _reload_limit(monkeypatch, "64") == 64 +def test_env_override_raises_the_window(tmp_path): + assert _limit_in_fresh_interpreter(tmp_path, "64") == 64 -def test_invalid_value_falls_back_to_default(monkeypatch): - assert _reload_limit(monkeypatch, "bogus") == 20 +@pytest.mark.parametrize("value", ["bogus", "0", "-5"]) +def test_invalid_or_nonpositive_falls_back_to_default(tmp_path, value): + assert _limit_in_fresh_interpreter(tmp_path, value) == 20 -def test_zero_and_negative_fall_back_to_default(monkeypatch): - assert _reload_limit(monkeypatch, "0") == 20 - assert _reload_limit(monkeypatch, "-5") == 20 +def test_setting_is_documented(): + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + env_example = (REPO_ROOT / ".env.example").read_text(encoding="utf-8") + assert "| `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` | `20` |" in readme + assert "# HERMES_WEBUI_VISIBLE_SESSION_LIMIT=20" in env_example From 78bb3665602fa8d1f8b8e71d987f412f59b10554 Mon Sep 17 00:00:00 2001 From: carlotestor Date: Sat, 19 Sep 2026 11:23:31 +0000 Subject: [PATCH 161/446] fix: keep the target profile's models disk cache on the first /api/models after a per-client switch switch_profile(process_wide=False) skips reload_config(), so _cfg_mtime still held the previous profile's nonzero mtime. The first /api/models for the new profile took the config-reload branch and _refresh_config_cache() treated _old_cfg_mtime != 0.0 as a config edit, unlinking the *target* profile's models_cache..json - defeating delete_disk=False on the very next request. Only delete the disk snapshot when the same config.yaml path was already loaded (_old_cfg_path == config_path); a path change (profile switch) keeps it. Executed two-profile end-to-end test (real homes, configs, cache files) plus a guard that a same-profile config edit still deletes the snapshot. Doc table updated. --- api/config.py | 24 ++- .../architecture/models-cache-invalidation.md | 12 +- ...ch_next_models_request_keeps_disk_cache.py | 156 ++++++++++++++++++ 3 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 tests/test_profile_switch_next_models_request_keeps_disk_cache.py diff --git a/api/config.py b/api/config.py index 87c1ce06..46710c35 100644 --- a/api/config.py +++ b/api/config.py @@ -549,9 +549,15 @@ def _refresh_config_cache(config_path: Path | None = None) -> None: if config_path is None: config_path = _get_config_path() _cfg_cache.clear() - # Remember the old mtime so we can tell whether config actually changed - # vs. first-ever load (mtime == 0.0, e.g. server start or profile switch). + # Remember the old mtime AND path so we can tell whether *this* config file + # actually changed vs. a first-ever load (mtime == 0.0, server start) or a + # load of a different profile's config.yaml. _cfg_mtime is process-global, + # so after a per-client profile switch (process_wide=False skips + # reload_config()) it still holds the previous profile's nonzero mtime; + # without the path check the next /api/models on the new profile would + # look like an edit and unlink the new profile's disk snapshot. _old_cfg_mtime = _cfg_mtime + _old_cfg_path = _cfg_path _cfg_path = config_path _cfg_mtime = 0.0 try: @@ -607,11 +613,15 @@ def _refresh_config_cache(config_path: Path | None = None) -> None: _apply_config_defaults(_cfg_cache) _cfg_fingerprint = _fingerprint_config(_cfg_cache) # Bust the models cache so the next request sees fresh config values. - # Only delete the disk cache when config has actually changed -- not on - # first-ever load (when _old_cfg_mtime == 0.0, i.e. server start or - # profile switch) -- preserving the disk cache so the next restart - # still hits the fast path without a cold run. - if _old_cfg_mtime != 0.0: + # Only delete the disk cache when the SAME config file was already loaded + # (a real edit / explicit reload of the active profile's config.yaml) -- + # not on first-ever load (_old_cfg_mtime == 0.0, server start) and not + # when the path changed because the request belongs to another profile + # (per-client profile switch). The per-profile disk snapshot is + # fingerprint-guarded on read, so a path change needs no unlink; deleting + # it here forced a full cold rebuild on the first /api/models after every + # switch. + if _old_cfg_mtime != 0.0 and _old_cfg_path == config_path: _delete_models_cache_on_disk() diff --git a/docs/architecture/models-cache-invalidation.md b/docs/architecture/models-cache-invalidation.md index 5557be43..9fd3a798 100644 --- a/docs/architecture/models-cache-invalidation.md +++ b/docs/architecture/models-cache-invalidation.md @@ -80,7 +80,17 @@ differ in what they do to the per-profile `models_cache.json` on disk | `invalidate_models_cache(delete_disk=False)` | dropped | left in place | | `invalidate_provider_models_cache(provider_id)` | dropped | **deleted** unconditionally (no `delete_disk` option) | | `_get_fresh_memory_models_cache()` on a fingerprint mismatch or invalid cached shape | dropped | untouched | -| config-reload branch in `get_available_models()` (`_cfg_changed`) | dropped | deleted by `reload_config()` → `_refresh_config_cache()` **only if** a config was already loaded (`_old_cfg_mtime != 0.0`); a first-ever load (server start, profile switch) keeps it | +| config-reload branch in `get_available_models()` (`_cfg_changed`) | dropped | deleted by `reload_config_if_stale()` → `_refresh_config_cache()` **only if** the *same* `config.yaml` path was already loaded and its mtime moved (`_old_cfg_mtime != 0.0 and _old_cfg_path == config_path`, i.e. a real edit of the active profile's config); a first-ever load (server start, `_cfg_mtime == 0.0`) or a path change keeps it | + +The path guard matters for per-client profile switches: `switch_profile(name, +process_wide=False)` deliberately skips `reload_config()`, so the process-global +`_cfg_mtime` / `_cfg_path` still describe the *previous* profile's config after +`POST /api/profile/switch`. The first `/api/models` for the new profile then +takes the config-reload branch (different path, different mtime). Without the +`_old_cfg_path == config_path` check that reload looked like a config edit and +unlinked the *target* profile's `models_cache..json`, defeating the +`delete_disk=False` switch on the very next request +(`tests/test_profile_switch_next_models_request_keeps_disk_cache.py`). `invalidate_models_cache` is the only entry point that offers the `delete_disk` choice. `delete_disk=True` is for when a source may have changed, diff --git a/tests/test_profile_switch_next_models_request_keeps_disk_cache.py b/tests/test_profile_switch_next_models_request_keeps_disk_cache.py new file mode 100644 index 00000000..cf969ddc --- /dev/null +++ b/tests/test_profile_switch_next_models_request_keeps_disk_cache.py @@ -0,0 +1,156 @@ +"""Greptile review thread on #7632 (docs/architecture/models-cache-invalidation.md): + +A per-client ``POST /api/profile/switch`` (``process_wide=False``) skips +``reload_config()``, so the process-global ``_cfg_mtime`` still holds the +*previous* profile's nonzero mtime. The next ``/api/models`` for the new +profile sees a different config mtime, takes the config-reload branch, and +``_refresh_config_cache()`` used to treat ``_old_cfg_mtime != 0.0`` as "config +edited" and unlink the disk snapshot — the *target* profile's +``models_cache..json`` — defeating ``delete_disk=False`` on the very next +request. + +``_refresh_config_cache()`` now only deletes the disk snapshot when the same +config.yaml path was already loaded (a real edit of the active profile's +config). These tests drive real profile homes, real config files and real disk +cache files through ``switch_profile()`` + ``get_available_models()``. +""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def _catalog(label: str) -> dict: + return { + "active_provider": "openai", + "default_model": label, + "configured_model_badges": {}, + "groups": [ + { + "provider": "OpenAI", + "provider_id": "openai", + "models": [{"id": label, "label": label, "supports_fast_tier": False}], + } + ], + "aliases": {}, + } + + +def _setup_two_profiles(tmp_path: Path, monkeypatch): + """Real default + named profile homes, distinct config mtimes, one disk + cache file per profile. Returns (default_cache, demo_cache, demo_config).""" + import api.config as cfg + import api.profiles as profiles + + monkeypatch.delenv("HERMES_CONFIG_PATH", raising=False) + default_home = tmp_path / ".hermes" + default_home.mkdir() + (default_home / "config.yaml").write_text("model:\n default: default-model\n", encoding="utf-8") + demo_home = default_home / "profiles" / "demo" + demo_home.mkdir(parents=True) + demo_config = demo_home / "config.yaml" + demo_config.write_text("model:\n default: demo-model\n", encoding="utf-8") + os.utime(demo_config, (1_000_000, 1_000_000)) # guarantee a different mtime + + monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", default_home) + monkeypatch.setattr(profiles, "_active_profile", "default") + monkeypatch.setattr(profiles._tls, "profile", None, raising=False) + + default_cache = tmp_path / "models_cache.json" + demo_cache = tmp_path / "models_cache.demo.json" + default_cache.write_text("{}", encoding="utf-8") + demo_cache.write_text("{}", encoding="utf-8") + monkeypatch.setattr(cfg, "_models_cache_path", default_cache) + + # Serve the disk snapshot; never allow a live rebuild. + monkeypatch.setattr(cfg, "_load_models_cache_from_disk", lambda: _catalog("disk-model")) + monkeypatch.setattr(cfg, "_load_stale_models_cache_from_disk", lambda: None) + + def _unexpected_rebuild(*_a, **_kw): + raise AssertionError("must not trigger a live models rebuild") + + monkeypatch.setattr(cfg, "_invoke_models_rebuild", _unexpected_rebuild) + + # Snapshot/restore the process-global config state touched by reload_config(). + for attr in ("_cfg_mtime", "_cfg_path", "_cfg_fingerprint"): + monkeypatch.setattr(cfg, attr, getattr(cfg, attr), raising=False) + saved_cache = dict(cfg._cfg_cache) + + def _restore(): + cfg._cfg_cache.clear() + cfg._cfg_cache.update(saved_cache) + + monkeypatch.setattr(cfg, "_available_models_cache", None, raising=False) + monkeypatch.setattr(cfg, "_available_models_cache_source_fingerprint", None, raising=False) + monkeypatch.setattr(cfg, "_cache_build_in_progress", False, raising=False) + + # Load the default profile's config as the server would have at boot. + cfg.reload_config() + assert cfg._cfg_path == default_home / "config.yaml" + assert cfg._cfg_mtime != 0.0 + + return default_cache, demo_cache, demo_config, _restore + + +def test_first_models_request_after_per_client_switch_keeps_target_disk_cache(tmp_path, monkeypatch): + import api.config as cfg + import api.profiles as profiles + + default_cache, demo_cache, _demo_config, restore = _setup_two_profiles(tmp_path, monkeypatch) + try: + # Warm the default profile: /api/models with the default cookie. + cfg.get_available_models() + assert default_cache.exists() + + # Per-client switch, exactly as the route does it. + profiles.switch_profile("demo", process_wide=False) + cfg.invalidate_models_cache(delete_disk=False) + assert demo_cache.exists() + prev_mtime = cfg._cfg_mtime + assert prev_mtime != 0.0, "per-client switch leaves the previous profile's mtime in place" + + # First /api/models from that client (cookie → thread-local profile). + profiles.set_request_profile("demo") + try: + result = cfg.get_available_models() + finally: + profiles.clear_request_profile() + + assert result["default_model"] == "disk-model" + assert cfg._cfg_path == _demo_config, "config-reload branch must have re-pointed to the demo config" + assert cfg._cfg_mtime == _demo_config.stat().st_mtime + assert demo_cache.exists(), ( + "first /api/models after a per-client profile switch must not unlink the " + "target profile's models_cache.demo.json" + ) + assert default_cache.exists() + finally: + restore() + + +def test_same_profile_config_edit_still_deletes_disk_cache(tmp_path, monkeypatch): + """The path guard must not weaken the real-edit contract: an mtime change + on the *same* config.yaml still drops the disk snapshot.""" + import api.config as cfg + import api.profiles as profiles + + default_cache, demo_cache, demo_config, restore = _setup_two_profiles(tmp_path, monkeypatch) + try: + profiles.switch_profile("demo", process_wide=False) + cfg.invalidate_models_cache(delete_disk=False) + profiles.set_request_profile("demo") + try: + cfg.get_available_models() + assert demo_cache.exists() + # Edit demo's config.yaml → mtime moves on the same path. + demo_config.write_text("model:\n default: demo-model-2\n", encoding="utf-8") + os.utime(demo_config, (2_000_000, 2_000_000)) + cfg.invalidate_models_cache(delete_disk=False) + cfg.get_available_models() + finally: + profiles.clear_request_profile() + assert not demo_cache.exists(), "editing the active profile's config.yaml must still delete its disk cache" + assert default_cache.exists() + finally: + restore() From d03891a3077ec44722fa7919102cb13bdcbac0a0 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Wed, 12 Aug 2026 17:01:29 -0300 Subject: [PATCH 162/446] fix(commands): autocomplete only announces webui-dispatchable slash commands (#6951) --- static/commands.js | 27 +++++++ static/messages.js | 6 +- tests/test_cli_only_slash_commands.py | 104 +++++++++++++++++++++++++- 3 files changed, 133 insertions(+), 4 deletions(-) diff --git a/static/commands.js b/static/commands.js index 685cbf5c..cc98233c 100644 --- a/static/commands.js +++ b/static/commands.js @@ -182,6 +182,30 @@ function executeCommand(text){ return {noEcho:!!cmd.noEcho}; } +// Agent-registry slash commands that the WebUI actually dispatches when +// submitted. The autocomplete must not advertise anything outside this set: +// a non-dispatchable registry command (e.g. /agents) would otherwise fall +// through send() to the ordinary chat path and trigger an unintended +// model/API request. Keep this in sync with _AGENT_COMMANDS_RUN_ON_WEBUI in +// messages.js -- the announced list is a subset of the dispatched list +// (fail-closed), never a superset. Plugin-category commands are always +// dispatchable via the plugin exec transport. #6951. +const _WEBUI_DISPATCHABLE_AGENT_COMMANDS=new Set([ + // Backend-executed via /api/commands/exec (see _ALLOWED_AGENT_COMMANDS in api/commands.py). + 'reload-mcp','reload_mcp','reload-skills','reload_skills','codex-runtime','codex_runtime','credits', + // Native WebUI behaviors handled inside send() without an agent round-trip. + 'moa','sessions','resume', + // Desktop Companion extension command handled by handlePetSlashCommand(). + 'pet', +]); + +function _isWebuiDispatchableAgentCommand(cmd){ + const name=String(cmd&&cmd.name||'').trim().toLowerCase(); + if(_WEBUI_DISPATCHABLE_AGENT_COMMANDS.has(name))return true; + // Plugin-registered commands execute via the /api/commands/exec plugin transport. + return String(cmd&&cmd.category||'').trim()==='Plugin'; +} + function getMatchingCommands(prefix){ const q=prefix.toLowerCase(); const matches=COMMANDS.filter(c=>c.name.startsWith(q)).map(c=>({...c,source:'builtin'})); @@ -214,6 +238,9 @@ function getMatchingCommands(prefix){ const name=String(cmd&&cmd.name||'').toLowerCase(); if(!name.startsWith(q)||seen.has(name))continue; if(cmd.cli_only&&name!=='pet')continue; + // #6951: only announce commands send() actually dispatches -- anything + // else would silently fall through to the normal chat path as plain text. + if(!_isWebuiDispatchableAgentCommand(cmd))continue; matches.push({ name, desc:String(cmd&&cmd.description||'').trim()||'Agent command', diff --git a/static/messages.js b/static/messages.js index 92a10712..7592dd6e 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1409,14 +1409,16 @@ async function send(){ if(!S.session){await newSession();await renderSessionList();} // Busy-control slash commands must be intercepted HERE, before the // defaultMessageMode routing block, so the user can always type /steer, /interrupt, - // /queue, /terminal, /goal, or /yolo while the agent is running and have + // /queue, /terminal, /goal, /yolo, or /stop while the agent is running and have // them execute immediately. // Without this intercept they fall through to the queue and execute after // the current turn ends — by which point there is no active stream and // cmdSteer / cmdInterrupt say "No active task to stop." + // /stop must cancel the active run immediately instead of being steered + // or queued as the literal text "/stop" (#6951). if(text.startsWith('/')&&!literalSlash){ const _pc=typeof parseCommand==='function'&&parseCommand(text); - if(_pc&&['steer','interrupt','queue','terminal','goal','yolo'].includes(_pc.name)){ + if(_pc&&['steer','interrupt','queue','terminal','goal','yolo','stop'].includes(_pc.name)){ const _bc=COMMANDS.find(c=>c.name===_pc.name); if(_bc){ $('msg').value='';autoResize(); diff --git a/tests/test_cli_only_slash_commands.py b/tests/test_cli_only_slash_commands.py index 9ac5c2d6..84992a23 100644 --- a/tests/test_cli_only_slash_commands.py +++ b/tests/test_cli_only_slash_commands.py @@ -163,6 +163,30 @@ def _run_commands_js(script_body: str) -> dict: aliases: ['plugin_review'], cli_only: false, gateway_only: false + }}, + {{ + name: 'agents', + description: 'Manage background agents', + category: 'Session', + aliases: ['tasks'], + cli_only: false, + gateway_only: false + }}, + {{ + name: 'sessions', + description: 'List sessions', + category: 'Session', + aliases: [], + cli_only: false, + gateway_only: false + }}, + {{ + name: 'resume', + description: 'Resume a session', + category: 'Session', + aliases: [], + cli_only: false, + gateway_only: false }} ] }}; @@ -356,8 +380,8 @@ def test_cli_only_slugs_reserve_skill_autocomplete_namespace(): assert result["delegate_names"] == [] assert result["incident_names"] == ["incident-review"] assert result["incident_sources"] == ["bundle"] - assert result["triage_names"] == ["triage-review"] - assert result["triage_sources"] == ["agent"] + assert result["triage_names"] == [] + assert result["triage_sources"] == [] assert result["plugin_names"] == ["plugin-review"] assert result["plugin_sources"] == ["plugin"] assert "skills" in result["skills_names"] @@ -659,3 +683,79 @@ def test_builtin_command_opt_outs_do_not_hit_agent_metadata_lookup(): assert optout_idx != -1 assert metadata_idx != -1 assert "if(_parsedCmd&&!_cmd)" in intercept[optout_idx:metadata_idx + 120] + + +# ── #6951: autocomplete must only announce commands the WebUI dispatches ───── + + +def test_non_dispatchable_agent_command_hidden_from_autocomplete(): + """#6951: registry commands the WebUI does not dispatch (e.g. /agents) must + not be advertised, since submitting them would fall through to plain chat.""" + result = _run_commands_js( + """ + await loadAgentCommandMetadata(true); + const agents = await getSlashAutocompleteMatches('/agents'); + const ag = await getSlashAutocompleteMatches('/ag'); + return { + agents_names: agents.map(item => item.name), + ag_names: ag.map(item => item.name) + }; + """ + ) + assert result["agents_names"] == [] + assert result["ag_names"] == [] + + +def test_dispatchable_agent_commands_stay_in_autocomplete(): + """#6951: commands the WebUI does dispatch -- backend exec allowlist and + native WebUI behaviors -- must keep appearing in autocomplete.""" + result = _run_commands_js( + """ + await loadAgentCommandMetadata(true); + const reload = await getSlashAutocompleteMatches('/reload'); + const sessions = await getSlashAutocompleteMatches('/sessions'); + const resume = await getSlashAutocompleteMatches('/resume'); + const plugin = await getSlashAutocompleteMatches('/plugin'); + return { + reload_names: reload.map(item => item.name), + reload_sources: reload.map(item => item.source), + sessions_names: sessions.map(item => item.name), + resume_names: resume.map(item => item.name), + plugin_names: plugin.map(item => item.name) + }; + """ + ) + assert result["reload_names"] == ["reload-skills"] + assert result["reload_sources"] == ["agent"] + assert result["sessions_names"] == ["sessions"] + assert result["resume_names"] == ["resume"] + assert result["plugin_names"] == ["plugin-review"] + + +def test_autocomplete_allowlist_covers_backend_exec_allowlist(): + """#6951: every canonical backend-exec name from messages.js must be in the + commands.js dispatchable allowlist (announced list is a subset of the + dispatched list -- fail-closed).""" + assert "const _WEBUI_DISPATCHABLE_AGENT_COMMANDS=new Set([" in COMMANDS_JS + for slug in ( + "'reload-mcp'", + "'reload_mcp'", + "'reload-skills'", + "'reload_skills'", + "'codex-runtime'", + "'codex_runtime'", + "'credits'", + ): + assert slug in COMMANDS_JS + + +def test_busy_path_intercepts_stop_before_mode_routing(): + """#6951: while busy, /stop must cancel the active run immediately instead + of being steered/queued as the literal text '/stop'.""" + busy_idx = MESSAGES_JS.find("Busy-control slash commands must be intercepted") + assert busy_idx != -1 + mode_idx = MESSAGES_JS.find("const defaultMessageMode=", busy_idx) + assert mode_idx != -1 + busy_block = MESSAGES_JS[busy_idx:mode_idx] + assert "['steer','interrupt','queue','terminal','goal','yolo','stop']" in busy_block + assert "cmdStop" in busy_block or "COMMANDS.find(c=>c.name===_pc.name)" in busy_block From a85c1d1f629f08bca0c76ae748998e01b2c20011 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 17 Aug 2026 10:02:25 -0300 Subject: [PATCH 163/446] fix(commands): exact cross-authority parity for announced slash commands (#6951) --- static/commands.js | 21 +- tests/test_cli_only_slash_commands.py | 273 ++++++++++++++++++++++++-- 2 files changed, 273 insertions(+), 21 deletions(-) diff --git a/static/commands.js b/static/commands.js index cc98233c..79503005 100644 --- a/static/commands.js +++ b/static/commands.js @@ -186,13 +186,22 @@ function executeCommand(text){ // submitted. The autocomplete must not advertise anything outside this set: // a non-dispatchable registry command (e.g. /agents) would otherwise fall // through send() to the ordinary chat path and trigger an unintended -// model/API request. Keep this in sync with _AGENT_COMMANDS_RUN_ON_WEBUI in -// messages.js -- the announced list is a subset of the dispatched list -// (fail-closed), never a superset. Plugin-category commands are always -// dispatchable via the plugin exec transport. #6951. +// model/API request. +// +// This set holds canonical registry names only. getMatchingCommands() matches +// cmd.name (the canonical metadata name), so alias/underscore forms such as +// /reload_mcp are covered implicitly: typing one resolves through +// getAgentCommandMetadata() at dispatch time and the canonical name is what +// gets tested against the dispatcher allowlist (#6951). +// +// Keep this in sync with _AGENT_COMMANDS_RUN_ON_WEBUI in messages.js and +// _ALLOWED_AGENT_COMMANDS in api/commands.py -- the announced list is a +// subset of the dispatched list (fail-closed), never a superset. +// Plugin-category commands are always dispatchable via the plugin exec +// transport. #6951. const _WEBUI_DISPATCHABLE_AGENT_COMMANDS=new Set([ // Backend-executed via /api/commands/exec (see _ALLOWED_AGENT_COMMANDS in api/commands.py). - 'reload-mcp','reload_mcp','reload-skills','reload_skills','codex-runtime','codex_runtime','credits', + 'reload-mcp','reload-skills','codex-runtime','credits', // Native WebUI behaviors handled inside send() without an agent round-trip. 'moa','sessions','resume', // Desktop Companion extension command handled by handlePetSlashCommand(). @@ -244,6 +253,8 @@ function getMatchingCommands(prefix){ matches.push({ name, desc:String(cmd&&cmd.description||'').trim()||'Agent command', + // Surface the registry's argument hint on the autocomplete row (#6951). + arg:String(cmd&&cmd.args_hint||'').trim()||undefined, source:cmd.category==='Plugin'?'plugin':'agent', }); seen.add(name); diff --git a/tests/test_cli_only_slash_commands.py b/tests/test_cli_only_slash_commands.py index 84992a23..f083384c 100644 --- a/tests/test_cli_only_slash_commands.py +++ b/tests/test_cli_only_slash_commands.py @@ -2,12 +2,15 @@ import json from pathlib import Path +import re import subprocess import tempfile import textwrap from types import SimpleNamespace -from api.commands import list_commands +from api.commands import list_commands, _ALLOWED_AGENT_COMMANDS, _AGENT_COMMAND_ALIASES + +import pytest REPO_ROOT = Path(__file__).resolve().parents[1] @@ -15,6 +18,36 @@ COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8") MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8") +def _extract_js_set_members(source: str, varname: str) -> set[str]: + """Return the string members of a `const NAME=new Set([...])` literal.""" + m = re.search(rf"const\s+{re.escape(varname)}\s*=\s*new Set\(\[(.*?)\]\)", source, re.S) + assert m, f"could not find const {varname}=new Set([...]) in source" + return set(re.findall(r"'([^']*)'", m.group(1))) + + +def _canonical_agent_names(names: set[str]) -> set[str]: + """Map underscore alias forms to canonical names via api/commands.py's map.""" + return {_AGENT_COMMAND_ALIASES.get(n, n) for n in names} + + +def _extract_busy_intercept_block() -> str: + """Extract the busy-path slash intercept `if` block verbatim from send().""" + marker = "Busy-control slash commands must be intercepted" + marker_idx = MESSAGES_JS.find(marker) + assert marker_idx != -1 + start = MESSAGES_JS.find("if(text.startsWith('/')&&!literalSlash){", marker_idx) + assert start != -1 + depth = 0 + for i in range(start, len(MESSAGES_JS)): + if MESSAGES_JS[i] == "{": + depth += 1 + elif MESSAGES_JS[i] == "}": + depth -= 1 + if depth == 0: + return MESSAGES_JS[start : i + 1] + raise AssertionError("unbalanced braces while extracting busy intercept block") + + def test_api_commands_exposes_cli_only_metadata_for_webui_intercept(): """CLI-only commands must remain visible so the frontend can explain them.""" registry = [ @@ -732,21 +765,37 @@ def test_dispatchable_agent_commands_stay_in_autocomplete(): assert result["plugin_names"] == ["plugin-review"] -def test_autocomplete_allowlist_covers_backend_exec_allowlist(): - """#6951: every canonical backend-exec name from messages.js must be in the - commands.js dispatchable allowlist (announced list is a subset of the - dispatched list -- fail-closed).""" - assert "const _WEBUI_DISPATCHABLE_AGENT_COMMANDS=new Set([" in COMMANDS_JS - for slug in ( - "'reload-mcp'", - "'reload_mcp'", - "'reload-skills'", - "'reload_skills'", - "'codex-runtime'", - "'codex_runtime'", - "'credits'", - ): - assert slug in COMMANDS_JS +def test_autocomplete_allowlist_is_exact_parity_with_dispatchers(): + """#6951 (re-gate): the announced allowlist must be in exact agreement with + BOTH real dispatch authorities -- api/commands.py's _ALLOWED_AGENT_COMMANDS + (the /api/commands/exec allowlist) and messages.js's + _AGENT_COMMANDS_RUN_ON_WEBUI (the send() dispatch set). This compares the + actual parsed sets, so drift in ANY authority fails the test: a command + added to one allowlist but not the others, a renamed alias, or a removed + entry all break parity here.""" + announced = _extract_js_set_members(COMMANDS_JS, "_WEBUI_DISPATCHABLE_AGENT_COMMANDS") + dispatched = _extract_js_set_members(MESSAGES_JS, "_AGENT_COMMANDS_RUN_ON_WEBUI") + + # The backend-exec family must agree exactly across all three authorities + # (canonical names after alias normalization). + backend_allowed = set(_ALLOWED_AGENT_COMMANDS) + assert _canonical_agent_names(dispatched) == backend_allowed + assert _canonical_agent_names(announced) & backend_allowed == backend_allowed + + # Announced backend-exec commands are exactly the backend-exec allowlist + # (no announced exec command outside /api/commands/exec, none missing). + announced_backend = _canonical_agent_names(announced) & backend_allowed + assert announced_backend == backend_allowed + + # Every remaining announced command must be a native WebUI behavior that + # send() handles without an agent round-trip (moa/sessions/resume/pet). + native = announced - announced_backend + assert native == {"moa", "sessions", "resume", "pet"} + + # The plugin transport is parity by rule, not by list: the filter accepts + # category==='Plugin' and the dispatcher routes the exact same value. + assert "category==='Plugin'" in COMMANDS_JS + assert "_agentCmd.category==='Plugin'" in MESSAGES_JS def test_busy_path_intercepts_stop_before_mode_routing(): @@ -759,3 +808,195 @@ def test_busy_path_intercepts_stop_before_mode_routing(): busy_block = MESSAGES_JS[busy_idx:mode_idx] assert "['steer','interrupt','queue','terminal','goal','yolo','stop']" in busy_block assert "cmdStop" in busy_block or "COMMANDS.find(c=>c.name===_pc.name)" in busy_block + + +def _run_production_autocomplete_js(commands_payload: list[dict], script_body: str) -> dict: + """Run the REAL getMatchingCommands/autocomplete pipeline from commands.js + against a /api/commands payload (defaults to the real hermes_cli registry + serialized exactly as list_commands() produces it).""" + script = textwrap.dedent( + f""" + const vm = require('vm'); + const ctx = {{ + console, + localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }}, + t: (key) => key, + api: async (path) => {{ + if (path === '/api/commands') return {{ commands: {json.dumps(commands_payload)} }}; + if (path === '/api/commands/bundles') return {{ bundles: [] }}; + if (path === '/api/skills') return {{ skills: [] }}; + throw new Error('unexpected api path: ' + path); + }} + }}; + vm.createContext(ctx); + vm.runInContext({json.dumps(COMMANDS_JS)}, ctx); + (async () => {{ + const result = await vm.runInContext(`(async () => {{ + {script_body} + }})()`, ctx); + process.stdout.write(JSON.stringify(result)); + }})().catch(err => {{ + console.error(err && err.stack || err); + process.exit(1); + }}); + """ + ) + with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as handle: + handle.write(script) + script_path = Path(handle.name) + try: + proc = subprocess.run(["node", str(script_path)], check=True, capture_output=True, text=True) + finally: + script_path.unlink(missing_ok=True) + return json.loads(proc.stdout) + + +def _real_registry_payload() -> list[dict] | None: + """Serialize the real hermes_cli COMMAND_REGISTRY via the production + list_commands() path. Returns None when hermes_cli is unavailable.""" + try: + return list_commands() + except Exception: + return None + + +def test_real_registry_announced_commands_are_all_dispatchable(): + """#6951 (re-gate): with the REAL hermes_cli registry as /api/commands + payload, every command the production autocomplete announces must be + dispatchable -- backend-exec via /api/commands/exec, a native WebUI + behavior, or a plugin command. /agents (in the registry, NOT dispatched) + must not be announced. This is the announced <= dispatched invariant + exercised against the real production data path.""" + payload = _real_registry_payload() + if payload is None: + pytest.skip("hermes_cli registry unavailable") + announced_by_prefix = {} + for cmd in payload: + name = str(cmd.get("name") or "") + if not name: + continue + result = _run_production_autocomplete_js( + payload, + f""" + await loadAgentCommandMetadata(true); + const matches = await getSlashAutocompleteMatches('/{name}'); + return matches.map(item => ({{ name: item.name, source: item.source }})); + """, + ) + announced_by_prefix[name] = result + + backend_allowed = set(_ALLOWED_AGENT_COMMANDS) + for name, rows in announced_by_prefix.items(): + for row in rows: + if row["source"] not in ("agent", "plugin"): + continue # builtin/subarg/skill rows are out of scope + assert row["name"] in backend_allowed or row["name"] in ( + "moa", + "sessions", + "resume", + "pet", + ), f"{row['name']} announced but not dispatchable" + + # /agents is in the real registry but must not be announced (it is neither + # backend-exec nor a native WebUI behavior nor a plugin). + assert announced_by_prefix.get("agents", []) == [], announced_by_prefix.get("agents") + + +def test_alias_typed_text_reaches_exec_dispatch(): + """#6951 (re-gate): typing an underscore alias (/reload_skills) must + resolve through getAgentCommandMetadata to the canonical name, which is + what the dispatcher allowlist tests -- so alias forms dispatch without + needing their own autocomplete row.""" + result = _run_commands_js( + """ + const byAlias = await getAgentCommandMetadata('reload_skills'); + const canonical = byAlias && byAlias.name; + return { canonical, dispatched: canonical === 'reload-skills' }; + """ + ) + assert result["canonical"] == "reload-skills" + assert result["dispatched"] is True + + +def _run_busy_intercept_js(script_body: str) -> dict: + """Execute the REAL busy-path slash intercept block from messages.js with + the REAL COMMANDS table + parseCommand from commands.js. This exercises the + production routing branch -- not just its source text. + + The harness defines `async function busyIntercept(text, literalSlash)` in + the vm context (so it closes over the real COMMANDS/parseCommand) and the + script_body then calls it. The block's inner `return;` exits run() early + when the command is intercepted, so `intercepted: true` is returned.""" + block = _extract_busy_intercept_block() + script = textwrap.dedent( + f""" + const vm = require('vm'); + const calls = []; + const ctx = {{ + console, + localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }}, + t: (key) => key, + S: {{ + busy: true, + activeStreamId: 'stream-1', + session: {{ session_id: 'sess-1' }}, + pendingFiles: [] + }}, + cancelStream: async (reason) => {{ calls.push('cancelStream:' + reason); return true; }}, + showToast: () => {{}}, + $: () => ({{ value: '' }}), + autoResize: () => {{}}, + api: async () => ({{}}), + _trySteer: async () => {{ calls.push('steer'); }}, + queueSessionMessage: () => {{ calls.push('queue'); }} + }}; + vm.createContext(ctx); + vm.runInContext({json.dumps(COMMANDS_JS)}, ctx); + vm.runInContext(`async function busyIntercept(text, literalSlash){{ + let reachedModeRouting = false; + const run = async () => {{ + {block} + reachedModeRouting = true; + }}; + await run(); + return {{ intercepted: !reachedModeRouting }}; + }}`, ctx); + (async () => {{ + const result = await vm.runInContext(`(async () => {{ + {script_body} + }})()`, ctx); + process.stdout.write(JSON.stringify({{ result, calls }})); + }})().catch(err => {{ + console.error(err && err.stack || err); + process.exit(1); + }}); + """ + ) + with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as handle: + handle.write(script) + script_path = Path(handle.name) + try: + proc = subprocess.run(["node", str(script_path)], check=True, capture_output=True, text=True) + finally: + script_path.unlink(missing_ok=True) + return json.loads(proc.stdout) + + +def test_busy_stop_executes_real_cancel_branch(): + """#6951 (re-gate): executing the real busy-intercept branch from messages.js + with the real COMMANDS table must route /stop to cmdStop -> cancelStream, + and must NOT fall through to steer/queue routing.""" + out = _run_busy_intercept_js( + """ + const r1 = await busyIntercept('/stop', false); + const r2 = await busyIntercept('/agents', false); + return { stop: r1, agents: r2 }; + """ + ) + # /stop is intercepted by the busy branch (cmdStop -> cancelStream ran, + # mode routing never reached), /agents is NOT a busy-control command so it + # falls through to mode routing (documented: it is no longer announced). + assert out["result"]["stop"] == {"intercepted": True} + assert out["result"]["agents"] == {"intercepted": False} + assert any(c.startswith("cancelStream:slash-stop") for c in out["calls"]), out["calls"] + assert not any(c.startswith("steer") for c in out["calls"]), out["calls"] From ff06f6d0326663b24c5f51a5bc0bc3e45ffe6b2d Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 24 Aug 2026 10:13:57 -0300 Subject: [PATCH 164/446] fix(commands): exact cross-authority parity for announced slash commands --- static/commands.js | 13 +++++-------- static/messages.js | 12 +++++++++++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/static/commands.js b/static/commands.js index 79503005..ea3ff0aa 100644 --- a/static/commands.js +++ b/static/commands.js @@ -1,3 +1,8 @@ +const _BACKEND_EXECUTED_AGENT_COMMANDS = ['reload-mcp','reload-skills','codex-runtime','credits']; +const _WEBUI_DISPATCHABLE_AGENT_COMMANDS = new Set([ + 'reload-mcp','reload-skills','codex-runtime','credits', + 'moa','sessions','resume','pet' +]); // ── Slash commands ────────────────────────────────────────────────────────── // Built-in commands intercepted before send(). Each command runs locally // (no round-trip to the agent) and shows feedback via toast or local message. @@ -199,14 +204,6 @@ function executeCommand(text){ // subset of the dispatched list (fail-closed), never a superset. // Plugin-category commands are always dispatchable via the plugin exec // transport. #6951. -const _WEBUI_DISPATCHABLE_AGENT_COMMANDS=new Set([ - // Backend-executed via /api/commands/exec (see _ALLOWED_AGENT_COMMANDS in api/commands.py). - 'reload-mcp','reload-skills','codex-runtime','credits', - // Native WebUI behaviors handled inside send() without an agent round-trip. - 'moa','sessions','resume', - // Desktop Companion extension command handled by handlePetSlashCommand(). - 'pet', -]); function _isWebuiDispatchableAgentCommand(cmd){ const name=String(cmd&&cmd.name||'').trim().toLowerCase(); diff --git a/static/messages.js b/static/messages.js index 7592dd6e..1a7380dd 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1,3 +1,14 @@ +const _BACKEND_EXECUTED_AGENT_COMMANDS = ['reload-mcp','reload-skills','codex-runtime','credits']; +const _AGENT_COMMAND_ALIASES = { + 'reload_mcp': 'reload-mcp', + 'reload_skills': 'reload-skills', + 'codex_runtime': 'codex-runtime', + 'credits': 'credits' +}; +const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set([ + 'reload-mcp','reload-skills','codex-runtime','credits', + 'reload_mcp','reload_skills','codex_runtime','credits' +]); function _markSessionViewed(sid, messageCount) { if(typeof _setSessionViewedCount!=='function' || !sid) return; const next = Number.isFinite(messageCount) ? Number(messageCount) : 0; @@ -1184,7 +1195,6 @@ const _sessionTitleProvisionalBySid = new Map(); // their canonical command is registered on the backend (for example // /reload-mcp). Keep this intentionally narrow and include underscore variants // observed by users so typing either form still routes through executeAgentCommand. -const _AGENT_COMMANDS_RUN_ON_WEBUI = new Set(['reload-mcp', 'reload_mcp', 'reload-skills', 'reload_skills', 'codex-runtime', 'codex_runtime', 'credits']); function _clearStaleBusyStateBeforeSend({compressionRunning=false}={}){ if(!S||!S.busy||compressionRunning) return false; From 813b293c8275265f389f696a47c9c2f0f3b7d2dd Mon Sep 17 00:00:00 2001 From: webtecnica Date: Mon, 24 Aug 2026 22:25:46 -0300 Subject: [PATCH 165/446] fix(commands): remove duplicate top-level const breaking messages.js load static/commands.js and static/messages.js both declared const _BACKEND_EXECUTED_AGENT_COMMANDS at file scope. static/index.html loads them consecutively as classic defer scripts in the same global lexical realm, so the second declaration threw SyntaxError: Identifier '_BACKEND_EXECUTED_AGENT_COMMANDS' has already been declared, aborting messages.js entirely. The constant was unused in both files (a duplicate authority mirror, not a genuinely shared source), so both declarations are removed. The existing three-way parity test is preserved, and a new regression evaluates commands.js followed by messages.js in one shared classic-script vm realm, asserting both files evaluate cleanly and the dispatch consts of both files are bound. --- static/commands.js | 1 - static/messages.js | 1 - tests/test_cli_only_slash_commands.py | 87 +++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/static/commands.js b/static/commands.js index ea3ff0aa..fa787e24 100644 --- a/static/commands.js +++ b/static/commands.js @@ -1,4 +1,3 @@ -const _BACKEND_EXECUTED_AGENT_COMMANDS = ['reload-mcp','reload-skills','codex-runtime','credits']; const _WEBUI_DISPATCHABLE_AGENT_COMMANDS = new Set([ 'reload-mcp','reload-skills','codex-runtime','credits', 'moa','sessions','resume','pet' diff --git a/static/messages.js b/static/messages.js index 1a7380dd..d1dbac12 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1,4 +1,3 @@ -const _BACKEND_EXECUTED_AGENT_COMMANDS = ['reload-mcp','reload-skills','codex-runtime','credits']; const _AGENT_COMMAND_ALIASES = { 'reload_mcp': 'reload-mcp', 'reload_skills': 'reload-skills', diff --git a/tests/test_cli_only_slash_commands.py b/tests/test_cli_only_slash_commands.py index f083384c..aca56d7a 100644 --- a/tests/test_cli_only_slash_commands.py +++ b/tests/test_cli_only_slash_commands.py @@ -798,6 +798,93 @@ def test_autocomplete_allowlist_is_exact_parity_with_dispatchers(): assert "_agentCmd.category==='Plugin'" in MESSAGES_JS +def _run_shared_classic_realm_js() -> dict: + """Evaluate commands.js then messages.js in ONE vm realm -- exactly how + static/index.html loads them (consecutive non-module `defer` classic + scripts share a single global lexical realm). A duplicate top-level + `const` across the two files throws during the second script's + declaration instantiation and aborts it before any statement runs, so the + second script's dispatch consts would never bind. Browser-only globals + referenced at load time are stubbed; runtime (call-time) references are + out of scope for this guard. + """ + script = textwrap.dedent( + f""" + const vm = require('vm'); + const ctx = vm.createContext({{ + console, + localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }}, + t: (key) => key, + document: {{ addEventListener(){{}}, getElementById(){{ return null; }}, hidden: false }}, + window: {{ addEventListener(){{}}, speechSynthesis: {{ speaking: false, paused: false }} }}, + location: {{ href: 'http://localhost/', protocol: 'http:', host: 'localhost' }}, + navigator: {{ userAgent: 'test' }}, + setTimeout: () => 0, clearTimeout: () => 0, + setInterval: () => 0, clearInterval: () => 0, + requestAnimationFrame: () => 0, cancelAnimationFrame: () => 0, + fetch: async () => ({{}}), + EventSource: function(){{}}, + MutationObserver: function(){{ this.observe=()=>{{}}; }}, + Blob: function(){{}}, FileReader: function(){{ this.readAsText=()=>{{}}; }}, + URL: function(){{ this.href=''; }}, FormData: function(){{}}, + WebSocket: function(){{}}, + crypto: {{ getRandomValues: (arr) => arr }}, + CustomEvent: function(){{}} + }}); + vm.runInContext({json.dumps(COMMANDS_JS)}, ctx); + let secondError = null; + try {{ + vm.runInContext({json.dumps(MESSAGES_JS)}, ctx); + }} catch (e) {{ + secondError = String(e && e.message || e); + }} + const bound = vm.runInContext(`({{ + runOnWebui: typeof _AGENT_COMMANDS_RUN_ON_WEBUI, + dispatchable: typeof _WEBUI_DISPATCHABLE_AGENT_COMMANDS, + backendConst: typeof _BACKEND_EXECUTED_AGENT_COMMANDS + }})`, ctx); + process.stdout.write(JSON.stringify({{ secondError, ...bound }})); + """ + ) + with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as handle: + handle.write(script) + script_path = Path(handle.name) + try: + proc = subprocess.run(["node", str(script_path)], check=True, capture_output=True, text=True) + finally: + script_path.unlink(missing_ok=True) + return json.loads(proc.stdout) + + +def test_commands_and_messages_evaluate_in_one_shared_classic_script_realm(): + """#6962 (re-gate): commands.js and messages.js must coexist as classic + scripts in the same global realm (static/index.html loads them back to + back). A duplicate top-level `const _BACKEND_EXECUTED_AGENT_COMMANDS` + across both files previously threw + `SyntaxError: Identifier ... has already been declared` on messages.js, + aborting its evaluation entirely. Both files must evaluate cleanly in one + realm, the dispatch consts of BOTH files must be bound, and the removed + duplicate authority mirror must not exist in the shared namespace.""" + out = _run_shared_classic_realm_js() + assert out["secondError"] is None, ( + f"messages.js failed to evaluate after commands.js in the shared realm: " + f"{out['secondError']}" + ) + assert out["runOnWebui"] == "object", ( + "messages.js dispatch const _AGENT_COMMANDS_RUN_ON_WEBUI not bound in " + "the shared realm -- declaration instantiation was aborted" + ) + assert out["dispatchable"] == "object", ( + "commands.js dispatch const _WEBUI_DISPATCHABLE_AGENT_COMMANDS not " + "bound in the shared realm" + ) + assert out["backendConst"] == "undefined", ( + "duplicate/unused _BACKEND_EXECUTED_AGENT_COMMANDS mirror must not be " + "declared in the shared realm (declared once -> it would collide; " + "declared in one file -> it is dead authority drift)" + ) + + def test_busy_path_intercepts_stop_before_mode_routing(): """#6951: while busy, /stop must cancel the active run immediately instead of being steered/queued as the literal text '/stop'.""" From 70dd8e4a4bddac3c31f78c20af3916939811fa51 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 20 Sep 2026 12:32:21 +0000 Subject: [PATCH 166/446] docs(changelog): stamp #6962 dispatchable-only slash autocomplete Co-authored-by: webtecnica --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 172494cc..4645069c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ ### Fixed +- **Slash-command autocomplete stops offering commands the WebUI cannot run.** The composer's + `/` menu announced all 51 registered commands, but many are CLI-only — picking one produced + a command that went nowhere. The menu now announces only the WebUI-dispatchable subset (16), + so the difference shows up where it matters: typing a prefix like `/a` or `/re` no longer + fills the list with dead options. Filtering is confined to the suggestion list — manually + typing a CLI-only command behaves exactly as before, and plugin commands plus the native + `/moa`, `/sessions`, `/resume` and `/pet` remain available. Thanks @webtecnica. + - **Concurrent background completions stop burning the async-delegation delivery budget.** When several delegated runs finished at once and idle-woke the same session together, each wakeup consumed a delivery attempt before discovering the others, so the budget could be From dc9911739d480af800e54714cdc2bae44f1f47f7 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 20 Sep 2026 12:55:11 +0000 Subject: [PATCH 167/446] test(goal): assert /goal membership, not the exact passthrough list literal #6962 adds 'stop' to the busy-command passthrough list in messages.js so a busy /stop cancels immediately. test_frontend_has_goal_slash_command_and_status_event_handler asserted the exact list literal, so it failed on a change that does not affect /goal. Assert that 'goal' is a member of the list instead. Verified the assertion still fails when 'goal' is actually removed from the list. --- tests/test_goal_command_webui.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_goal_command_webui.py b/tests/test_goal_command_webui.py index 2fd7800c..651d15f9 100644 --- a/tests/test_goal_command_webui.py +++ b/tests/test_goal_command_webui.py @@ -671,7 +671,14 @@ def test_frontend_has_goal_slash_command_and_status_event_handler(): assert "goal'" in MESSAGES_JS assert "source.addEventListener('goal'" in MESSAGES_JS assert "source.addEventListener('goal_continue'" in MESSAGES_JS - assert "['steer','interrupt','queue','terminal','goal','yolo'].includes(_pc.name)" in MESSAGES_JS + # #6962 added 'stop' to this busy-command passthrough list so a busy /stop + # cancels immediately. Assert /goal's membership rather than the exact list + # literal, so adding another passthrough command does not fail this test. + _passthrough = re.search( + r"if\(_pc&&\[([^\]]*)\]\.includes\(_pc\.name\)\)", MESSAGES_JS + ) + assert _passthrough, "busy-command passthrough list not found in messages.js" + assert "'goal'" in _passthrough.group(1) assert "queueSessionMessage" in MESSAGES_JS 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 168/446] 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 169/446] 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 @@
- +