From 960c95cfe30d9511131c067603598ddfce21125f Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Mon, 18 May 2026 21:06:05 -0700 Subject: [PATCH 01/16] docs(runtime): define runner sidecar gate --- CHANGELOG.md | 3 + docs/rfcs/hermes-run-adapter-contract.md | 83 +++++++++++++++++++++--- tests/test_runtime_adapter_seam.py | 19 +++++- 3 files changed, 93 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 941c61ea..3f9eac00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Documentation + +- **PR #TBD** by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC to the Slice 4 runner/sidecar planning gate after #2560 shipped the queue-staging clarification. The RFC now marks queue routing as staged by default, defines Slice 4a as a docs/test contract before any runner code lands, and pins default-off feature-flagging, restart/reattach success criteria, control parity, profile/workspace payload isolation, and explicit non-goals for legacy-backend removal or server-side queue scheduler work. ## [v0.51.92] — 2026-05-19 — Release BP (stage-385 — 7-PR full sweep batch — RFC Slice 3c clarification + workspace tree icon alignment + project move cache refresh + auto-compression handoff metadata + Grok OAuth provider catalog + anonymous custom endpoint picker fallback + PWA standalone reload + pull-to-refresh) diff --git a/docs/rfcs/hermes-run-adapter-contract.md b/docs/rfcs/hermes-run-adapter-contract.md index 2a703156..34ad176a 100644 --- a/docs/rfcs/hermes-run-adapter-contract.md +++ b/docs/rfcs/hermes-run-adapter-contract.md @@ -52,7 +52,7 @@ The immediate goal is not to build a sidecar. The immediate goal is to define th browser contract, classify current runtime state, and gate the first reversible journal slice. -## Current Gate State — 2026-05-18 +## Current Gate State — 2026-05-19 Slice 1 is now past the first active validation gate: @@ -90,14 +90,17 @@ adapter-seam work: `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` is enabled, while preserving the legacy-direct response shape and leaving post-turn goal evaluation in the existing agent loop. +- #2560 shipped the queue-staging clarification in v0.51.92. The RFC now treats + `queue_message(...)` as a staged protocol method only; `/queue` remains + browser-side queue/drain behavior, and no server-side queue endpoint or queue + scheduler should be added merely for adapter symmetry. -The next gate is still not the runner/sidecar by default. Slice 3c's goal route -is shipped, and `queue_message(...)` remains a staged protocol method. Queue / -continue routing needs an explicit follow-up contract because the legacy `/queue` -path is browser-side queue/drain behavior today; no new server-side queue endpoint -or queue scheduler should be added just for adapter symmetry. If maintainers want -queue/continue to move before Slice 4, that follow-up should specify the exact -legacy entry point, response shape, and ordering/idempotency contract first. +The next gate is the runner/sidecar planning contract, not queue implementation +by default. Queue / continue routing should only move before Slice 4 if a future +maintainer decision identifies an existing server-side legacy entry point and +pins its response shape, ordering, and idempotency contract. Otherwise, keeping +`queue_message(...)` staged is the honest boundary while execution ownership +moves out of the main WebUI request process. ## Goals @@ -670,8 +673,19 @@ Non-goals for Slice 3c: ### Slice 4: Runner process / sidecar boundary -Explicitly deferred until Slice 1 has worked in production for at least one -release cycle and the adapter surface has review approval. +Slice 4 is the first gate that may move active execution ownership out of the +main WebUI request process. It should start as a docs/test contract PR before any +runner code lands. Slice 1's journal/replay layer has shipped and passed active +validation, Slice 2's default-off adapter seam has shipped, and Slice 3's +cancel/approval/clarify/goal control routing has proven the protocol-translator +pattern. Queue remains staged unless maintainers explicitly ask for a separate +pre-runner queue route. + +The Slice 4 implementation must not make the adapter a new runtime surrogate. +The runner boundary may own active execution, process supervision, run lifecycle, +and callback state, but those responsibilities must be centralized behind the +adapter/runner contract rather than recreated as scattered globals in the main +WebUI server. Scope: @@ -683,6 +697,55 @@ Scope: Revert path: disable runner backend and fall back to journaled legacy backend. +#### Slice 4a: Runner contract gate + +Before runner code lands, define a narrow contract that covers: + +1. **Backend selection and rollback.** The existing `legacy-direct` and + `legacy-journal` paths remain available. Any new runner backend is + feature-flagged, default-off, and revertible by switching the adapter mode back + to `legacy-journal` without deleting sessions or journal files. +2. **Process ownership.** The runner, not the main WebUI request process, owns + `AIAgent` construction/reuse, active run execution, cancellation flags, + approval/clarify callback wait state, and post-turn continuation evaluation + for runs assigned to that backend. +3. **Durable observation.** The main WebUI server observes through + `RuntimeAdapter.observe_run(...)`, `get_run(...)`, and the journal cursor. A + WebUI restart must not be required for the runner to finish writing ordered + events and terminal state. +4. **Restart/reattach success criterion.** Start a long-running run, restart only + `hermes-webui.service`, reload the session, rediscover the active or terminal + runner-owned run, replay/catch up from cursor without duplicate transcript / + tool / reasoning state, and preserve cancel if the run is still active. +5. **Control parity.** Cancel, approval, clarify, goal status/control, and any + accepted queue/continue behavior route through adapter methods with stable + browser response shapes. Unsupported controls return bounded `ControlResult` + states instead of silently falling back to stale in-process state. +6. **Profile/workspace isolation.** Runner startup receives explicit profile, + workspace, attachments, model/provider, toolset, and source metadata rather + than relying on process-global environment mutation in the WebUI server. + +Suggested contract tests before implementation: + +- source/RFC tests proving Slice 4 remains feature-flagged and default-off; +- a fake-runner adapter test that simulates WebUI restart by discarding server + process-local state while preserving runner/journal state, then verifies + `get_run` and replay recover the same terminal state; +- a control-parity fixture proving unsupported runner controls return bounded + `ControlResult` values and do not fall back to legacy `STREAMS` / + `CANCEL_FLAGS` state; +- a profile/workspace payload test proving runner requests carry explicit context + fields without mutating global `os.environ` in the main WebUI process. + +Non-goals for Slice 4a: + +- no removal of the legacy in-process backend; +- no default-on runner mode; +- no public chat-start/status response-shape expansion; +- no new server-side queue endpoint or scheduler just for adapter symmetry; +- no dependency on Hermes Agent shipping `/v1/runs` before WebUI can validate the + local runner boundary. + ## First Meaningful Success Criteria The first meaningful milestones are deliberately split. diff --git a/tests/test_runtime_adapter_seam.py b/tests/test_runtime_adapter_seam.py index ef1a7951..1b6910c6 100644 --- a/tests/test_runtime_adapter_seam.py +++ b/tests/test_runtime_adapter_seam.py @@ -309,7 +309,22 @@ def test_rfc_distinguishes_goal_routing_from_queue_route_staging(): rfc = (routes.Path(__file__).parent.parent / "docs" / "rfcs" / "hermes-run-adapter-contract.md").read_text(encoding="utf-8") assert "#2544 shipped the first Slice 3c implementation" in rfc + assert "#2560 shipped the queue-staging clarification" in rfc assert "route now uses `RuntimeAdapter.update_goal(...)`" in rfc - assert "`queue_message(...)` remains a staged protocol method" in rfc + assert "`queue_message(...)` as a staged protocol method only" in rfc assert "no new server-side queue endpoint" in rfc - assert "or queue scheduler should be added just for adapter symmetry" in rfc + assert "no server-side queue endpoint or queue\n scheduler should be added merely for adapter symmetry" in rfc + + +def test_rfc_defines_slice4_runner_contract_before_runner_code(): + routes = importlib.import_module("api.routes") + rfc = (routes.Path(__file__).parent.parent / "docs" / "rfcs" / "hermes-run-adapter-contract.md").read_text(encoding="utf-8") + + assert "#### Slice 4a: Runner contract gate" in rfc + assert "docs/test contract PR before any\nrunner code lands" in rfc + assert "feature-flagged, default-off" in rfc + assert "The runner, not the main WebUI request process, owns" in rfc + assert "restart only\n `hermes-webui.service`" in rfc + assert "profile,\n workspace, attachments, model/provider, toolset, and source metadata" in rfc + assert "no removal of the legacy in-process backend" in rfc + assert "no default-on runner mode" in rfc From 11e1e9a3424ecdf7267aa2e7a69e3b74d2463729 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Mon, 18 May 2026 22:19:07 -0600 Subject: [PATCH 02/16] Fix settled rendering for file markdown links --- static/messages.js | 25 +++++++++++++++++++++---- static/ui.js | 16 ++++++++++++++-- tests/test_issue470.py | 32 ++++++++++++++++++++++++++++---- tests/test_streaming_markdown.py | 9 ++++++++- 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/static/messages.js b/static/messages.js index c72e8111..8969fe44 100644 --- a/static/messages.js +++ b/static/messages.js @@ -966,19 +966,31 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(assistantBody&&!fade){_sanitizeSmdLinks(assistantBody);} } // Allowed URL schemes for anchors and images rendered from agent-streamed markdown. - // Matches the effective allowlist of renderMd() (http/https via regex + relative). - const _SMD_SAFE_URL_RE=/^(?:https?:|mailto:|tel:|\/|#|\?|\.)/i; + // Raw file:// anchors are rewritten to /api/media before the user can click them. + const _SMD_SAFE_URL_RE=/^(?:https?:|mailto:|tel:|\/|#|\?|\.|api)/i; + const _SMD_SAFE_IMG_URL_RE=/^(?:https?:|mailto:|tel:|\/|#|\?|\.)/i; + function _smdFileHref(raw){ + const href=String(raw||''); + if(!/^file:\/\//i.test(href)) return href; + try{ + const path=decodeURIComponent(href.replace(/^file:\/\//i,'')); + return 'api/media?path='+encodeURIComponent(path)+'&inline=1'; + }catch(_){ + return 'api/media?path='+encodeURIComponent(href.replace(/^file:\/\//i,''))+'&inline=1'; + } + } function _sanitizeSmdLinks(root){ if(!root||!root.querySelectorAll) return; const _a=root.querySelectorAll('a[href]'); for(let i=0;i<_a.length;i++){ const n=_a[i],v=n.getAttribute('href')||''; + if(/^file:\/\//i.test(v)){n.setAttribute('href',_smdFileHref(v));continue;} if(!_SMD_SAFE_URL_RE.test(v)){n.removeAttribute('href');n.setAttribute('data-blocked-scheme','1');} } const _im=root.querySelectorAll('img[src]'); for(let i=0;i<_im.length;i++){ const n=_im[i],v=n.getAttribute('src')||''; - if(!_SMD_SAFE_URL_RE.test(v)){n.removeAttribute('src');n.setAttribute('data-blocked-scheme','1');} + if(!_SMD_SAFE_IMG_URL_RE.test(v)){n.removeAttribute('src');n.setAttribute('data-blocked-scheme','1');} } } @@ -1082,7 +1094,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ renderer.set_attr=(data,attr,value)=>{ const isHref=window.smd&&attr===window.smd.HREF; const isSrc=window.smd&&attr===window.smd.SRC; - if((isHref||isSrc)&&!_SMD_SAFE_URL_RE.test(String(value||''))){ + const safeUrl=isSrc?_SMD_SAFE_IMG_URL_RE:_SMD_SAFE_URL_RE; + if(isHref&&/^file:\/\//i.test(String(value||''))){ + baseSetAttr(data,attr,_smdFileHref(value)); + return; + } + if((isHref||isSrc)&&!safeUrl.test(String(value||''))){ const node=data&&data.nodes&&data.nodes[data.index]; if(node&&node.setAttribute) node.setAttribute('data-blocked-scheme','1'); return; diff --git a/static/ui.js b/static/ui.js index 5ab149f7..154ed641 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2756,7 +2756,7 @@ function renderMd(raw){ t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]); // Stash [label](url) links before autolink so the URL in href= is not re-linked const _link_stash=[]; - t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); + t=t.replace(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `${esc(clean)}${trail}`;}); t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]); t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]); @@ -2849,7 +2849,7 @@ function renderMd(raw){ // Stash existing tags first to avoid re-linking already-linked URLs. const _a_stash=[]; s=s.replace(/(]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;}); - s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`); + s=s.replace(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`); s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]); // Restore raw
 only after markdown rewrites so literal preformatted
   // content stays placeholder-protected, then let the sanitizer normalize tags.
@@ -2865,6 +2865,18 @@ function renderMd(raw){
   function _safeAttrValue(v){
     return String(v||'').replace(/"/g,'"').replace(/'/g,"'").replace(/&/g,'&').trim();
   }
+  function _markdownHref(raw){
+    const href=String(raw||'').replace(/"/g,'%22');
+    if(/^file:\/\//i.test(href)){
+      try{
+        const path=decodeURIComponent(href.replace(/^file:\/\//i,''));
+        return 'api/media?path='+encodeURIComponent(path)+'&inline=1';
+      }catch(_){
+        return 'api/media?path='+encodeURIComponent(href.replace(/^file:\/\//i,''))+'&inline=1';
+      }
+    }
+    return href;
+  }
   function _isSafeUrl(v, img){
     const raw=_safeAttrValue(v);
     const compact=raw.replace(/[\u0000-\u001f\u007f\s]+/g,'').toLowerCase();
diff --git a/tests/test_issue470.py b/tests/test_issue470.py
index b6f156d7..78893075 100644
--- a/tests/test_issue470.py
+++ b/tests/test_issue470.py
@@ -33,6 +33,12 @@ def _make_link(url, label):
     return f'{esc(label)}'
 
 
+def markdown_href(url):
+    if url.lower().startswith("file://"):
+        return "api/media?path=" + __import__("urllib.parse").parse.quote(url[7:], safe="") + "&inline=1"
+    return url
+
+
 # Minimal Python mirror of the FIXED renderMd() — enough to test link behaviour.
 # Mirrors the stash-based approach introduced by the fix.
 
@@ -48,9 +54,9 @@ def render_links_only(text):
     link_stash = []
     def stash_link(m):
         label, url = m.group(1), m.group(2)
-        link_stash.append(f'{esc(label)}')
+        link_stash.append(f'{esc(label)}')
         return f'\x00L{len(link_stash)-1}\x00'
-    s = re.sub(r'\[([^\]]+)\]\((https?://[^\)]+)\)', stash_link, s)
+    s = re.sub(r'\[([^\]]+)\]\(((?:https?|file)://[^\)]+)\)', stash_link, s)
 
     # Autolink bare URLs (should NOT match inside already-stashed placeholders)
     def autolink(m):
@@ -83,9 +89,9 @@ def render_table_with_links(md):
         stash = []
         def stash_fn(m):
             lb, u = m.group(1), m.group(2)
-            stash.append(f'{esc(lb)}')
+            stash.append(f'{esc(lb)}')
             return f'\x00L{len(stash)-1}\x00'
-        t = re.sub(r'\[([^\]]+)\]\((https?://[^\)]+)\)', stash_fn, t)
+        t = re.sub(r'\[([^\]]+)\]\(((?:https?|file)://[^\)]+)\)', stash_fn, t)
         # autolink remaining bare URLs
         def autolink(m):
             url = m.group(1)
@@ -170,6 +176,17 @@ def test_labeled_link_renders_as_single_anchor():
     assert f']({url})' not in result
 
 
+def test_labeled_file_link_renders_as_single_anchor():
+    """A labeled local file link must survive the settled render path."""
+    url = 'file:///Users/agent/Documents/Obsidian/Meal-Prep/halal-cart.html'
+    md = f'[Halal Cart Chicken]({url})'
+    result = render_links_only(md)
+    assert result.count(' tag, got: {result}"
+    assert 'href="api/media?path=%2FUsers%2Fagent%2FDocuments%2FObsidian%2FMeal-Prep%2Fhalal-cart.html&inline=1"' in result
+    assert 'Halal Cart Chicken' in result
+    assert '[Halal Cart Chicken]' not in result
+
+
 def test_href_not_html_escaped():
     """URLs with & must appear as literal & in href, not &."""
     url = 'https://example.com/search?q=foo&bar=baz'
@@ -261,6 +278,13 @@ def test_js_source_sanitizes_quotes_in_href():
         "URL placed in href should have double-quotes percent-encoded via .replace to %22"
     )
 
+
+def test_js_source_rewrites_file_links_to_media_endpoint():
+    """Browser pages cannot reliably navigate to file://, so renderMd must use /api/media."""
+    assert "function _markdownHref" in UI_JS
+    assert "api/media?path=" in UI_JS
+    assert "file:\\/\\/" in UI_JS
+
 # ── Code-inside-bold tests (pre-existing bug, fixed in same PR) ───────────────
 
 def test_js_inlinemd_stashes_code_before_bold():
diff --git a/tests/test_streaming_markdown.py b/tests/test_streaming_markdown.py
index 777e0428..4506c32e 100644
--- a/tests/test_streaming_markdown.py
+++ b/tests/test_streaming_markdown.py
@@ -554,7 +554,8 @@ class TestSmdUrlSchemeSanitization:
     def test_sanitize_uses_scheme_allowlist(self):
         # The allowlist regex must permit the safe schemes that the legacy
         # renderMd path emitted (http/https + relative/anchor paths + mailto/tel)
-        # and reject everything else — including javascript:, data:, vbscript:, file:.
+        # and reject dangerous executable schemes. file:// anchors are rewritten
+        # to api/media before click time rather than allowed through raw.
         assert "_SMD_SAFE_URL_RE" in MESSAGES_JS, (
             "Expected a _SMD_SAFE_URL_RE regex defining the safe-scheme allowlist"
         )
@@ -565,11 +566,17 @@ class TestSmdUrlSchemeSanitization:
         pattern = m.group(1)
         # Must mention https? and must NOT mention javascript/vbscript/data
         assert "https?" in pattern, "allowlist must permit https?:"
+        assert "file:" not in pattern, "raw file: anchors must be rewritten, not allowed through"
+        assert "api" in MESSAGES_JS, "allowlist must permit rewritten api/media anchors"
         for bad in ("javascript", "vbscript", "data:"):
             assert bad not in pattern, (
                 f"allowlist must NOT mention {bad!r} — schemes are denied by default"
             )
 
+    def test_file_anchor_rewrite_helper_exists(self):
+        assert "_smdFileHref" in MESSAGES_JS
+        assert "api/media?path=" in MESSAGES_JS
+
     def test_sanitize_called_after_smd_write(self):
         # _smdWrite must invoke _sanitizeSmdLinks on assistantBody after feeding the parser,
         # so anchors/images created mid-stream get their javascript:/data:/vbscript:

From 467ef33a24508ed10fcefa132b0eb3fb1fb4b47a Mon Sep 17 00:00:00 2001
From: Lumen Yang 
Date: Wed, 13 May 2026 13:21:44 +0000
Subject: [PATCH 03/16] feat(webui): reconcile external session updates

When API server runs append messages directly to state.db, reconcile WebUI sidecar sessions with those canonical rows across API responses, model-facing streaming context, and active browser refresh.

Add append-only state.db merge helpers, metadata-only counts for refresh polling, and regression coverage for API visibility, context incorporation, and frontend refresh behavior.
---
 api/models.py                                 | 197 +++++++---
 api/routes.py                                 |  41 ++-
 api/streaming.py                              |   9 +-
 static/sessions.js                            |  61 ++-
 tests/test_webui_external_refresh_frontend.py |  26 ++
 ...t_webui_state_db_context_reconciliation.py | 131 +++++++
 tests/test_webui_state_db_reconciliation.py   | 347 ++++++++++++++++++
 7 files changed, 753 insertions(+), 59 deletions(-)
 create mode 100644 tests/test_webui_external_refresh_frontend.py
 create mode 100644 tests/test_webui_state_db_context_reconciliation.py
 create mode 100644 tests/test_webui_state_db_reconciliation.py

diff --git a/api/models.py b/api/models.py
index 0518b227..e978c68f 100644
--- a/api/models.py
+++ b/api/models.py
@@ -2226,17 +2226,15 @@ def _json_loads_if_string(value):
         return value
 
 
-def get_cli_session_messages(sid) -> list:
-    """Read messages for a single CLI/external-agent session.
+def get_state_db_session_messages(sid, *, stitch_continuations: bool = False) -> list:
+    """Read messages for a Hermes session from the active profile's state.db.
 
-    Preserve tool-call/result and reasoning metadata from the agent state.db so
-    CLI-origin transcripts render with the same tool cards as WebUI-native
-    sessions. When the requested session is the tip of a compression/CLI-close
-    continuation chain, return the stitched full transcript across all segments
-    in chronological order. Returns empty list on any error.
+    This generic reader intentionally works for any session source, including
+    WebUI-origin sessions that were later updated through another Hermes surface
+    such as the Gateway API Server.  When ``stitch_continuations`` is true it
+    preserves the historical CLI/external-agent behavior of walking compatible
+    compression/close parent segments before reading messages.
     """
-    if str(sid or '').startswith(f'{CLAUDE_CODE_SOURCE}_'):
-        return get_claude_code_session_messages(sid)
     try:
         import sqlite3
     except ImportError:
@@ -2267,47 +2265,48 @@ def get_cli_session_messages(sid) -> list:
             ]
             selected = ['role', 'content', 'timestamp'] + [c for c in optional if c in available]
 
-            cur.execute("PRAGMA table_info(sessions)")
-            session_cols = {str(row['name']) for row in cur.fetchall()}
             session_chain = [str(sid)]
-            if {'parent_session_id', 'end_reason', 'started_at', 'source'}.issubset(session_cols):
-                cur.execute(
-                    """
-                    SELECT id, source, started_at, parent_session_id, ended_at, end_reason
-                    FROM sessions
-                    WHERE id = ?
-                    """,
-                    (sid,),
-                )
-                rows_by_id = {}
-                row = cur.fetchone()
-                if row:
-                    rows_by_id[str(row['id'])] = dict(row)
-                    current_id = str(row['id'])
-                    seen = {current_id}
-                    for _ in range(20):
-                        current = rows_by_id.get(current_id)
-                        parent_id = current.get('parent_session_id') if current else None
-                        if not parent_id or parent_id in seen:
-                            break
-                        cur.execute(
-                            """
-                            SELECT id, source, started_at, parent_session_id, ended_at, end_reason
-                            FROM sessions
-                            WHERE id = ?
-                            """,
-                            (parent_id,),
-                        )
-                        parent_row = cur.fetchone()
-                        if not parent_row:
-                            break
-                        parent_dict = dict(parent_row)
-                        rows_by_id[str(parent_row['id'])] = parent_dict
-                        if not _is_continuation_session(parent_dict, current):
-                            break
-                        session_chain.insert(0, str(parent_row['id']))
-                        current_id = str(parent_row['id'])
-                        seen.add(current_id)
+            if stitch_continuations:
+                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):
+                    cur.execute(
+                        """
+                        SELECT id, source, started_at, parent_session_id, ended_at, end_reason
+                        FROM sessions
+                        WHERE id = ?
+                        """,
+                        (sid,),
+                    )
+                    rows_by_id = {}
+                    row = cur.fetchone()
+                    if row:
+                        rows_by_id[str(row['id'])] = dict(row)
+                        current_id = str(row['id'])
+                        seen = {current_id}
+                        for _ in range(20):
+                            current = rows_by_id.get(current_id)
+                            parent_id = current.get('parent_session_id') if current else None
+                            if not parent_id or parent_id in seen:
+                                break
+                            cur.execute(
+                                """
+                                SELECT id, source, started_at, parent_session_id, ended_at, end_reason
+                                FROM sessions
+                                WHERE id = ?
+                                """,
+                                (parent_id,),
+                            )
+                            parent_row = cur.fetchone()
+                            if not parent_row:
+                                break
+                            parent_dict = dict(parent_row)
+                            rows_by_id[str(parent_row['id'])] = parent_dict
+                            if not _is_continuation_session(parent_dict, current):
+                                break
+                            session_chain.insert(0, str(parent_row['id']))
+                            current_id = str(parent_row['id'])
+                            seen.add(current_id)
 
             placeholders = ', '.join('?' for _ in session_chain)
             cur.execute(f"""
@@ -2340,6 +2339,106 @@ def get_cli_session_messages(sid) -> list:
     return msgs
 
 
+def _normalized_message_timestamp_for_key(value):
+    if value is None or value == "":
+        return ""
+    try:
+        timestamp = float(value)
+    except (TypeError, ValueError):
+        return str(value)
+    if timestamp.is_integer():
+        return str(int(timestamp))
+    return ("%.6f" % timestamp).rstrip("0").rstrip(".")
+
+
+def _message_timestamp_as_float(msg):
+    if not isinstance(msg, dict):
+        return None
+    value = msg.get("timestamp")
+    if value is None or value == "":
+        return None
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def _session_message_merge_key(msg: dict):
+    if not isinstance(msg, dict):
+        return ("non_dict", repr(msg))
+    message_identity = msg.get("id") or msg.get("message_id")
+    if message_identity:
+        return ("message_id", str(message_identity))
+    return (
+        "legacy",
+        str(msg.get("role") or ""),
+        str(msg.get("content") or ""),
+        _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 ""),
+    )
+
+
+def merge_session_messages_append_only(sidecar_messages: list, state_messages: list) -> list:
+    """Merge sidecar/context and state.db messages without deleting local rows."""
+    sidecar_messages = list(sidecar_messages or [])
+    state_messages = list(state_messages or [])
+    if not state_messages:
+        return sidecar_messages
+    if not sidecar_messages:
+        return state_messages
+
+    merged_messages = []
+    seen_message_keys = set()
+    max_sidecar_timestamp = None
+    for msg in sidecar_messages:
+        timestamp = _message_timestamp_as_float(msg)
+        if timestamp is not None:
+            max_sidecar_timestamp = timestamp if max_sidecar_timestamp is None else max(max_sidecar_timestamp, timestamp)
+        key = _session_message_merge_key(msg)
+        seen_message_keys.add(key)
+        merged_messages.append(msg)
+    for msg in state_messages:
+        timestamp = _message_timestamp_as_float(msg)
+        if max_sidecar_timestamp is not None and timestamp is not None and timestamp <= max_sidecar_timestamp:
+            continue
+        key = _session_message_merge_key(msg)
+        if key in seen_message_keys:
+            continue
+        seen_message_keys.add(key)
+        merged_messages.append(msg)
+    return merged_messages
+
+
+def reconciled_state_db_messages_for_session(session, *, prefer_context: bool = False) -> list:
+    """Return append-only messages reconciled with state.db for a WebUI session."""
+    if session is None:
+        return []
+    local_messages = []
+    if prefer_context:
+        context_messages = getattr(session, 'context_messages', None)
+        if isinstance(context_messages, list) and context_messages:
+            local_messages = context_messages
+    if not local_messages:
+        local_messages = getattr(session, 'messages', None) or []
+    state_messages = get_state_db_session_messages(getattr(session, 'session_id', None))
+    return merge_session_messages_append_only(local_messages, state_messages)
+
+
+def get_cli_session_messages(sid) -> list:
+    """Read messages for a single CLI/external-agent session.
+
+    Preserve tool-call/result and reasoning metadata from the agent state.db so
+    CLI-origin transcripts render with the same tool cards as WebUI-native
+    sessions. When the requested session is the tip of a compression/CLI-close
+    continuation chain, return the stitched full transcript across all segments
+    in chronological order. Returns empty list on any error.
+    """
+    if str(sid or '').startswith(f'{CLAUDE_CODE_SOURCE}_'):
+        return get_claude_code_session_messages(sid)
+    return get_state_db_session_messages(sid, stitch_continuations=True)
+
+
 def count_conversation_rounds(sid: str, since: float | None = None) -> int:
     """Count conversation rounds for a session from state.db.
 
diff --git a/api/routes.py b/api/routes.py
index fb2caeab..c3330c3c 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -2220,6 +2220,8 @@ from api.models import (
     import_cli_session,
     get_cli_sessions,
     get_cli_session_messages,
+    get_state_db_session_messages,
+    merge_session_messages_append_only,
     ensure_cron_project,
     is_cron_session,
 )
@@ -3665,8 +3667,16 @@ def handle_get(handler, parsed) -> bool:
             cli_meta = _lookup_cli_session_metadata(sid) if _session_requires_cli_metadata_lookup(s) else {}
             is_messaging_session = _is_messaging_session_record(s) or _is_messaging_session_record(cli_meta)
             cli_messages = []
+            state_db_messages = []
             if is_messaging_session:
                 cli_messages = get_cli_session_messages(sid)
+            elif load_messages:
+                state_db_messages = get_state_db_session_messages(sid)
+            elif not is_messaging_session:
+                # Metadata-only callers (frontend refresh polling) still need a
+                # reconciled count/timestamp so externally appended state.db
+                # messages can be detected without fetching the full transcript.
+                state_db_messages = get_state_db_session_messages(sid)
             _t2 = _time.monotonic()
             effective_model = (
                 _resolve_effective_session_model_for_display(s)
@@ -3690,9 +3700,13 @@ def handle_get(handler, parsed) -> bool:
                     # them chronologically and dedupe exact repeats.
                     _all_msgs = _merged_session_messages_for_display(s, cli_messages)
                 else:
-                    _all_msgs = s.messages
+                    _all_msgs = merge_session_messages_append_only(s.messages, state_db_messages)
             else:
-                _all_msgs = []
+                if is_messaging_session and cli_messages:
+                    sidecar_messages = getattr(s, "messages", []) or []
+                    _all_msgs = merge_session_messages_append_only(sidecar_messages, cli_messages)
+                else:
+                    _all_msgs = merge_session_messages_append_only(getattr(s, "messages", []) or [], state_db_messages)
             if load_messages:
                 if msg_before is not None:
                     # Scroll-to-top paging: msg_before is a 0-based index into
@@ -3708,7 +3722,7 @@ def handle_get(handler, parsed) -> bool:
                 else:
                     _truncated_msgs = _all_msgs
             else:
-                _truncated_msgs = _all_msgs
+                _truncated_msgs = []
             # Resolve effective context_length with model-metadata fallback so
             # older sessions (pre-#1318) that have context_length=0 persisted
             # still render a meaningful indicator on load.  Mirrors the
@@ -3748,8 +3762,20 @@ def handle_get(handler, parsed) -> bool:
                 # messages already carry per-message tool metadata. Avoid sending
                 # the full historical list with a small tail window.
                 _session_tool_calls = []
+            _merged_message_count = len(_all_msgs)
+            _merged_last_message_at = 0
+            if _all_msgs:
+                try:
+                    _merged_last_message_at = max(
+                        float((m or {}).get("timestamp") or 0)
+                        for m in _all_msgs
+                        if isinstance(m, dict)
+                    )
+                except (TypeError, ValueError):
+                    _merged_last_message_at = 0
             raw = s.compact() | {
                 "messages": _truncated_msgs,
+                "message_count": _merged_message_count,
                 "tool_calls": _session_tool_calls,
                 "active_stream_id": getattr(s, "active_stream_id", None),
                 "pending_user_message": getattr(s, "pending_user_message", None),
@@ -3769,6 +3795,15 @@ def handle_get(handler, parsed) -> bool:
                         journal,
                         active=bool(getattr(s, "active_stream_id", None)),
                     )
+            if _merged_last_message_at:
+                raw["last_message_at"] = max(
+                    float(raw.get("last_message_at") or 0),
+                    _merged_last_message_at,
+                )
+                raw["updated_at"] = max(
+                    float(raw.get("updated_at") or 0),
+                    _merged_last_message_at,
+                )
             if cli_meta and _is_messaging_session_record(cli_meta):
                 raw = _merge_cli_sidebar_metadata(raw, cli_meta)
             # Signal to the frontend that older messages were omitted.
diff --git a/api/streaming.py b/api/streaming.py
index 5998bdbc..71fa2498 100644
--- a/api/streaming.py
+++ b/api/streaming.py
@@ -39,6 +39,7 @@ from api.compression_anchor import visible_messages_for_anchor
 from api.metering import meter
 from api.run_journal import RunJournalWriter
 from api.turn_journal import append_turn_journal_event_for_stream
+from api.models import reconciled_state_db_messages_for_session
 
 # Global lock for os.environ writes. Per-session locks (_agent_lock) prevent
 # concurrent runs of the SAME session, but two DIFFERENT sessions can still
@@ -3957,8 +3958,12 @@ def _run_agent_streaming(
             # or has been zeroed out (e.g. via a buggy migration / manual file edit).
             # Truthy-check covers None, missing-attr, and 0 uniformly.
             _turn_started_at = _pending_started_at if _pending_started_at else time.time()
-            _previous_messages = list(s.messages or [])
-            _previous_context_messages = _context_messages_for_new_turn(s, msg_text)
+            _reconciled_messages = list(reconciled_state_db_messages_for_session(s) or [])
+            _previous_messages = _reconciled_messages
+            _previous_context_messages = _drop_checkpointed_current_user_from_context(
+                reconciled_state_db_messages_for_session(s, prefer_context=True),
+                msg_text,
+            )
             _pre_compression_count = getattr(
                 getattr(agent, 'context_compressor', None),
                 'compression_count', 0,
diff --git a/static/sessions.js b/static/sessions.js
index 613a0c77..7d89aaca 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -517,12 +517,15 @@ async function newSession(flash, options={}){
   }
 }
 
-async function loadSession(sid){
+async function loadSession(sid, opts){
+  opts = opts || {};
+  const forceReload = !!opts.force;
   const currentSid = S.session ? S.session.session_id : null;
   // Clicking the already-open session in the sidebar is a no-op. Reloading it
   // tears down active pane state and can reset the long-session scroll window
-  // to the top even though the user did not navigate anywhere.
-  if(currentSid===sid) return;
+  // to the top even though the user did not navigate anywhere. Explicit
+  // refresh paths pass {force:true} when external state.db changes arrive.
+  if(currentSid===sid && !forceReload) return;
   // Mark this session as the in-flight load. Subsequent loadSession() calls
   // will overwrite this; stale awaits use the mismatch to bail out (#1060).
   _loadingSessionId = sid;
@@ -538,14 +541,14 @@ async function loadSession(sid){
   if (currentSid && currentSid !== sid) {
     _saveComposerDraftNow(currentSid, ($('msg') || {}).value || '', S.pendingFiles ? [...S.pendingFiles] : []);
   }
-  if (currentSid !== sid) {
+  if (currentSid !== sid || forceReload) {
     S.messages = [];
     S.toolCalls = [];
     _messagesTruncated = false;
     _oldestIdx = 0;
     _loadingOlder = false;
     const _msgInner = $('msgInner');
-    if (_msgInner) _msgInner.innerHTML = '
Loading conversation...
'; + if (_msgInner && currentSid !== sid) _msgInner.innerHTML = '
Loading conversation...
'; } // Phase 1: Load metadata only (~1KB) for fast session switching. // Guard against network/server failures to prevent a permanently stuck loading state. @@ -1995,6 +1998,7 @@ function _applySessionListPayload(sessData, projData){ stopStreamingPoll(); } ensureSessionTimeRefreshPoll(); + ensureActiveSessionExternalRefreshPoll(); renderSessionListFromCache(); // no-ops if rename is in progress } @@ -2028,8 +2032,11 @@ let _gatewaySSEWarningShown = false; const _gatewayFallbackPollMs = 30000; const _streamingPollMs = 5000; const _sessionTimeRefreshMs = 60000; +const _activeSessionExternalRefreshMs = 5000; let _streamingPollTimer = null; let _sessionTimeRefreshTimer = null; +let _activeSessionExternalRefreshTimer = null; +let _activeSessionExternalRefreshInFlight = false; function startStreamingPoll(){ if(_streamingPollTimer) return; @@ -2051,6 +2058,50 @@ function ensureSessionTimeRefreshPoll(){ }, _sessionTimeRefreshMs); } +async function refreshActiveSessionIfExternallyUpdated(reason){ + if(_activeSessionExternalRefreshInFlight) return; + if(!S.session || !S.session.session_id) return; + if(S.busy || S.activeStreamId) return; + if(typeof document !== 'undefined' && document.hidden) return; + const sid = S.session.session_id; + const localCount = Number(S.session.message_count || (Array.isArray(S.messages)?S.messages.length:0) || 0); + const localLast = Number(S.session.last_message_at || S.session.updated_at || 0); + _activeSessionExternalRefreshInFlight = true; + try{ + const data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`); + if(!data || !data.session) return; + if(!S.session || S.session.session_id !== sid) return; + if(S.busy || S.activeStreamId) return; + const remoteCount = Number(data.session.message_count || 0); + const remoteLast = Number(data.session.last_message_at || data.session.updated_at || 0); + if(remoteCount > localCount || remoteLast > localLast){ + await loadSession(sid, {force:true, externalRefreshReason:reason||'poll'}); + if(typeof renderSessionList==='function') void renderSessionList(); + } + }catch(e){ + // Ignore transient refresh failures; the next poll/focus event will retry. + }finally{ + _activeSessionExternalRefreshInFlight = false; + } +} + +function ensureActiveSessionExternalRefreshPoll(){ + if(_activeSessionExternalRefreshTimer) return; + _activeSessionExternalRefreshTimer = setInterval(() => { + void refreshActiveSessionIfExternallyUpdated('poll'); + }, _activeSessionExternalRefreshMs); + if(typeof document !== 'undefined' && !document._hermesExternalRefreshVisibilityHook){ + document.addEventListener('visibilitychange', () => { + if(!document.hidden) void refreshActiveSessionIfExternallyUpdated('visible'); + }); + document._hermesExternalRefreshVisibilityHook = true; + } + if(typeof window !== 'undefined' && !window._hermesExternalRefreshFocusHook){ + window.addEventListener('focus', () => { void refreshActiveSessionIfExternallyUpdated('focus'); }); + window._hermesExternalRefreshFocusHook = true; + } +} + function startGatewayPollFallback(ms){ const intervalMs = Math.max(5000, Number(ms) || _gatewayFallbackPollMs); if(_gatewayPollTimer) clearInterval(_gatewayPollTimer); diff --git a/tests/test_webui_external_refresh_frontend.py b/tests/test_webui_external_refresh_frontend.py new file mode 100644 index 00000000..2da0f5e4 --- /dev/null +++ b/tests/test_webui_external_refresh_frontend.py @@ -0,0 +1,26 @@ +from pathlib import Path + + +SESSIONS_JS = Path("static/sessions.js").read_text(encoding="utf-8") + + +def test_load_session_supports_force_reload_for_external_refresh(): + assert "async function loadSession(sid, opts)" in SESSIONS_JS + assert "const forceReload = !!opts.force" in SESSIONS_JS + assert "if(currentSid===sid && !forceReload) return;" in SESSIONS_JS + assert "loadSession(sid, {force:true" in SESSIONS_JS + + +def test_active_session_external_refresh_uses_metadata_then_force_reload(): + assert "function ensureActiveSessionExternalRefreshPoll()" in SESSIONS_JS + assert "async function refreshActiveSessionIfExternallyUpdated(reason)" in SESSIONS_JS + assert "messages=0&resolve_model=0" in SESSIONS_JS + assert "remoteCount > localCount || remoteLast > localLast" in SESSIONS_JS + assert "if(S.busy || S.activeStreamId) return;" in SESSIONS_JS + assert "document.hidden" in SESSIONS_JS + + +def test_active_session_external_refresh_has_focus_and_visibility_hooks(): + assert "visibilitychange" in SESSIONS_JS + assert "window.addEventListener('focus'" in SESSIONS_JS + assert "ensureActiveSessionExternalRefreshPoll();" in SESSIONS_JS diff --git a/tests/test_webui_state_db_context_reconciliation.py b/tests/test_webui_state_db_context_reconciliation.py new file mode 100644 index 00000000..30c0c467 --- /dev/null +++ b/tests/test_webui_state_db_context_reconciliation.py @@ -0,0 +1,131 @@ +import json +import queue +import sqlite3 +from collections import OrderedDict +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.requires_agent_modules + + +def _make_state_db(path: Path, sid: str, rows): + 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)" + ) + conn.execute( + "INSERT INTO sessions (id, source, title, model, started_at, message_count) VALUES (?, ?, ?, ?, ?, ?)", + (sid, "webui", "Context Reconcile", "test-model", 1000.0, len(rows)), + ) + for row in rows: + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)", + (sid, row["role"], row["content"], row.get("timestamp", 1000.0)), + ) + conn.commit() + conn.close() + + +def test_next_webui_turn_context_includes_state_db_external_messages(monkeypatch, tmp_path): + import api.config as config + import api.models as models + import api.profiles as profiles + import api.streaming as streaming + from api.models import Session + + session_dir = tmp_path / "sessions" + session_dir.mkdir() + index_file = session_dir / "_index.json" + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file) + monkeypatch.setattr(models, "SESSIONS", OrderedDict(), raising=False) + monkeypatch.setattr(config, "SESSION_DIR", session_dir, raising=False) + monkeypatch.setattr(config, "SESSION_INDEX_FILE", index_file, raising=False) + monkeypatch.setattr(streaming, "SESSION_DIR", session_dir, raising=False) + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path, raising=False) + config.STREAMS.clear() + config.CANCEL_FLAGS.clear() + config.AGENT_INSTANCES.clear() + config.SESSION_AGENT_LOCKS.clear() + + sid = "webui_context_reconcile_001" + sidecar_messages = [ + {"role": "user", "content": "old user", "timestamp": 1000.0}, + {"role": "assistant", "content": "old assistant", "timestamp": 1001.0}, + ] + session = Session( + session_id=sid, + title="Context Reconcile", + workspace=str(tmp_path), + model="test-model", + messages=list(sidecar_messages), + context_messages=list(sidecar_messages), + ) + session.active_stream_id = "stream-context-reconcile" + session.pending_user_message = "new webui turn" + session.pending_started_at = 1004.0 + session.save(touch_updated_at=False) + models.SESSIONS[sid] = session + + _make_state_db( + tmp_path / "state.db", + sid, + [ + {"role": "user", "content": "old user", "timestamp": 1000.0}, + {"role": "assistant", "content": "old assistant", "timestamp": 1001.0}, + {"role": "user", "content": "external gateway user", "timestamp": 1002.0}, + {"role": "assistant", "content": "external gateway assistant", "timestamp": 1003.0}, + ], + ) + + captured = {} + + class FakeAgent: + def __init__(self, **kwargs): + self.session_id = sid + self.context_compressor = None + self.ephemeral_system_prompt = None + + def run_conversation(self, **kwargs): + captured["conversation_history"] = kwargs.get("conversation_history") + history = kwargs.get("conversation_history") or [] + return { + "completed": True, + "final_response": "ok", + "messages": history + [ + {"role": "user", "content": kwargs.get("persist_user_message", "")}, + {"role": "assistant", "content": "ok"}, + ], + } + + monkeypatch.setattr(streaming, "_get_ai_agent", lambda: FakeAgent) + monkeypatch.setattr(streaming, "resolve_model_provider", lambda *args, **kwargs: ("test-model", None, None)) + monkeypatch.setattr(streaming, "get_config", lambda: {}) + monkeypatch.setattr(config, "get_config", lambda: {}) + monkeypatch.setattr(config, "_resolve_cli_toolsets", lambda *args, **kwargs: []) + + stream_id = "stream-context-reconcile" + config.STREAMS[stream_id] = queue.Queue() + try: + streaming._run_agent_streaming( + session_id=sid, + msg_text="new webui turn", + model="test-model", + workspace=str(tmp_path), + stream_id=stream_id, + attachments=[], + ) + finally: + config.STREAMS.pop(stream_id, None) + + history_contents = [m.get("content") for m in captured.get("conversation_history") or []] + assert history_contents == [ + "old user", + "old assistant", + "external gateway user", + "external gateway assistant", + ] diff --git a/tests/test_webui_state_db_reconciliation.py b/tests/test_webui_state_db_reconciliation.py new file mode 100644 index 00000000..393a6b23 --- /dev/null +++ b/tests/test_webui_state_db_reconciliation.py @@ -0,0 +1,347 @@ +import json +import sqlite3 +from collections import OrderedDict +from io import BytesIO +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +import pytest + +pytestmark = pytest.mark.requires_agent_modules + + +class _GetHandler: + def __init__(self, path): + self.path = path + self.headers = {} + self.client_address = ("127.0.0.1", 12345) + self.status = None + self.wfile = BytesIO() + self.response_headers = [] + + 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 + + @property + def response_json(self): + return json.loads(self.wfile.getvalue().decode("utf-8")) + + @property + def query(self): + return parse_qs(urlparse(self.path).query) + + def log_message(self, *args, **kwargs): + pass + + +def _make_state_db(path: Path, sid: str, rows): + 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)" + ) + conn.execute( + "INSERT INTO sessions (id, source, title, model, started_at, message_count) VALUES (?, ?, ?, ?, ?, ?)", + (sid, "webui", "Reconcile", "test-model", 1000.0, len(rows)), + ) + for row in rows: + conn.execute( + "INSERT INTO messages (session_id, role, content, timestamp, tool_call_id, tool_calls, tool_name) VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + sid, + row["role"], + row["content"], + row.get("timestamp", 1000.0), + row.get("tool_call_id"), + row.get("tool_calls"), + row.get("tool_name"), + ), + ) + conn.commit() + conn.close() + + +def _install_test_session(monkeypatch, tmp_path, sid, sidecar_messages): + import api.config as config + import api.models as models + + import api.profiles as profiles + + monkeypatch.setattr(config, "STATE_DIR", tmp_path, raising=False) + monkeypatch.setattr(config, "SESSION_DIR", tmp_path / "sessions", raising=False) + monkeypatch.setattr(models, "SESSION_DIR", tmp_path / "sessions", raising=False) + monkeypatch.setattr(models, "SESSIONS", OrderedDict(), raising=False) + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path, raising=False) + config.SESSION_DIR.mkdir(parents=True, exist_ok=True) + + session = models.Session( + session_id=sid, + title="Reconcile", + workspace=str(tmp_path), + model="test-model", + messages=sidecar_messages, + created_at=1000.0, + updated_at=1001.0, + ) + session.save(touch_updated_at=False) + return session + + +def test_api_session_includes_state_db_messages_newer_than_webui_sidecar(monkeypatch, tmp_path): + import api.routes as routes + + sid = "webui_reconcile_001" + sidecar_messages = [ + {"role": "user", "content": "old user", "timestamp": 1000.0}, + {"role": "assistant", "content": "old assistant", "timestamp": 1001.0}, + ] + _install_test_session(monkeypatch, tmp_path, sid, sidecar_messages) + _make_state_db( + tmp_path / "state.db", + sid, + [ + {"role": "user", "content": "old user", "timestamp": 1000.0}, + {"role": "assistant", "content": "old assistant", "timestamp": 1001.0}, + {"role": "user", "content": "external user", "timestamp": 1002.0}, + {"role": "assistant", "content": "external assistant", "timestamp": 1003.0}, + ], + ) + + handler = _GetHandler(f"/api/session?session_id={sid}&messages=1&resolve_model=0") + routes.handle_get(handler, urlparse(handler.path)) + + assert handler.status == 200 + payload = handler.response_json + messages = payload["session"]["messages"] + assert [m["content"] for m in messages] == [ + "old user", + "old assistant", + "external user", + "external assistant", + ] + assert payload["session"]["message_count"] == 4 + + +def test_state_db_reconciliation_preserves_sidecar_only_messages(monkeypatch, tmp_path): + import api.routes as routes + + sid = "webui_reconcile_sidecar_only" + _install_test_session( + monkeypatch, + tmp_path, + sid, + [ + {"role": "user", "content": "sidecar-only draft", "timestamp": 999.0}, + {"role": "user", "content": "old user", "timestamp": 1000.0}, + ], + ) + _make_state_db( + tmp_path / "state.db", + sid, + [ + {"role": "user", "content": "old user", "timestamp": 1000.0}, + {"role": "assistant", "content": "external assistant", "timestamp": 1001.0}, + ], + ) + + handler = _GetHandler(f"/api/session?session_id={sid}&messages=1&resolve_model=0") + routes.handle_get(handler, urlparse(handler.path)) + assert handler.status == 200 + messages = handler.response_json["session"]["messages"] + assert [m["content"] for m in messages] == [ + "sidecar-only draft", + "old user", + "external assistant", + ] + + +def test_state_db_reconciliation_does_not_collapse_repeated_content_with_different_timestamps(monkeypatch, tmp_path): + import api.routes as routes + + sid = "webui_reconcile_repeated" + _install_test_session( + monkeypatch, + tmp_path, + sid, + [{"role": "assistant", "content": "same", "timestamp": 1000.0}], + ) + _make_state_db( + tmp_path / "state.db", + sid, + [ + {"role": "assistant", "content": "same", "timestamp": 1000.0}, + {"role": "assistant", "content": "same", "timestamp": 1001.0}, + ], + ) + + handler = _GetHandler(f"/api/session?session_id={sid}&messages=1&resolve_model=0") + routes.handle_get(handler, urlparse(handler.path)) + assert handler.status == 200 + messages = handler.response_json["session"]["messages"] + assert [m["content"] for m in messages] == ["same", "same"] + assert [m["timestamp"] for m in messages] == [1000.0, 1001.0] + + +def test_state_db_reconciliation_preserves_sidecar_order_when_timestamps_collide(monkeypatch, tmp_path): + import api.routes as routes + + sid = "webui_reconcile_same_timestamp_order" + _install_test_session( + monkeypatch, + tmp_path, + sid, + [ + {"role": "user", "content": "z user happened first", "timestamp": 1000}, + {"role": "assistant", "content": "a assistant happened second", "timestamp": 1000}, + {"role": "tool", "content": "m tool happened third", "timestamp": 1000, "tool_call_id": "call_1"}, + ], + ) + _make_state_db( + tmp_path / "state.db", + sid, + [ + {"role": "user", "content": "z user happened first", "timestamp": 1000.0}, + {"role": "assistant", "content": "a assistant happened second", "timestamp": 1000.0}, + {"role": "tool", "content": "m tool happened third", "timestamp": 1000.0, "tool_call_id": "call_1"}, + ], + ) + + handler = _GetHandler(f"/api/session?session_id={sid}&messages=1&resolve_model=0") + routes.handle_get(handler, urlparse(handler.path)) + assert handler.status == 200 + messages = handler.response_json["session"]["messages"] + assert [m["content"] for m in messages] == [ + "z user happened first", + "a assistant happened second", + "m tool happened third", + ] + assert handler.response_json["session"]["message_count"] == 3 + + +def test_state_db_reconciliation_dedupes_numeric_equivalent_timestamps(monkeypatch, tmp_path): + import api.routes as routes + + sid = "webui_reconcile_numeric_timestamp" + _install_test_session( + monkeypatch, + tmp_path, + sid, + [{"role": "assistant", "content": "same timestamp", "timestamp": 1000}], + ) + _make_state_db( + tmp_path / "state.db", + sid, + [{"role": "assistant", "content": "same timestamp", "timestamp": 1000.0}], + ) + + handler = _GetHandler(f"/api/session?session_id={sid}&messages=1&resolve_model=0") + routes.handle_get(handler, urlparse(handler.path)) + assert handler.status == 200 + messages = handler.response_json["session"]["messages"] + assert [m["content"] for m in messages] == ["same timestamp"] + assert handler.response_json["session"]["message_count"] == 1 + + +def test_state_db_reconciliation_preserves_repeated_sidecar_rows(monkeypatch, tmp_path): + import api.routes as routes + + sid = "webui_reconcile_repeated_sidecar" + _install_test_session( + monkeypatch, + tmp_path, + sid, + [ + {"role": "assistant", "content": "", "timestamp": 1000}, + {"role": "assistant", "content": "", "timestamp": 1000}, + {"role": "assistant", "content": "done", "timestamp": 1001}, + ], + ) + _make_state_db( + tmp_path / "state.db", + sid, + [{"role": "assistant", "content": "", "timestamp": 1000.0}], + ) + + handler = _GetHandler(f"/api/session?session_id={sid}&messages=1&resolve_model=0") + routes.handle_get(handler, urlparse(handler.path)) + assert handler.status == 200 + messages = handler.response_json["session"]["messages"] + assert [m["content"] for m in messages] == ["", "", "done"] + assert handler.response_json["session"]["message_count"] == 3 + + +def test_metadata_fast_path_reports_reconciled_state_db_count(monkeypatch, tmp_path): + import api.routes as routes + + sid = "webui_reconcile_metadata" + _install_test_session( + monkeypatch, + tmp_path, + sid, + [ + {"role": "user", "content": "old user", "timestamp": 1000.0}, + {"role": "assistant", "content": "old assistant", "timestamp": 1001.0}, + ], + ) + _make_state_db( + tmp_path / "state.db", + sid, + [ + {"role": "user", "content": "old user", "timestamp": 1000.0}, + {"role": "assistant", "content": "old assistant", "timestamp": 1001.0}, + {"role": "user", "content": "external metadata user", "timestamp": 1002.0}, + {"role": "assistant", "content": "external metadata assistant", "timestamp": 1003.0}, + ], + ) + + handler = _GetHandler(f"/api/session?session_id={sid}&messages=0&resolve_model=0") + routes.handle_get(handler, urlparse(handler.path)) + + assert handler.status == 200 + session = handler.response_json["session"] + assert session["messages"] == [] + assert session["message_count"] == 4 + assert session["last_message_at"] == 1003.0 + + +def test_state_db_reconciliation_preserves_tool_metadata(monkeypatch, tmp_path): + import api.routes as routes + + sid = "webui_reconcile_tool_metadata" + _install_test_session( + monkeypatch, + tmp_path, + sid, + [{"role": "user", "content": "old user", "timestamp": 1000.0}], + ) + tool_calls = json.dumps([{"id": "call_1", "function": {"name": "terminal"}}]) + _make_state_db( + tmp_path / "state.db", + sid, + [ + {"role": "user", "content": "old user", "timestamp": 1000.0}, + { + "role": "assistant", + "content": "used a tool", + "timestamp": 1001.0, + "tool_calls": tool_calls, + "tool_name": "terminal", + }, + ], + ) + + handler = _GetHandler(f"/api/session?session_id={sid}&messages=1&resolve_model=0") + routes.handle_get(handler, urlparse(handler.path)) + assert handler.status == 200 + messages = handler.response_json["session"]["messages"] + assert messages[-1]["content"] == "used a tool" + assert messages[-1]["tool_name"] == "terminal" + assert messages[-1]["tool_calls"] == [{"id": "call_1", "function": {"name": "terminal"}}] From f12fef280d2fe5c60094256e487a6070272c6dde Mon Sep 17 00:00:00 2001 From: Lumen Yang Date: Wed, 13 May 2026 20:27:43 +0000 Subject: [PATCH 04/16] fix(webui): clear stale prompts on external refresh Force same-session external refreshes to dismiss stale approval and clarification prompts immediately so completed state.db updates do not leave the composer blocked. --- static/sessions.js | 4 ++-- tests/test_webui_external_refresh_frontend.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 7d89aaca..9b025633 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -529,10 +529,10 @@ async function loadSession(sid, opts){ // Mark this session as the in-flight load. Subsequent loadSession() calls // will overwrite this; stale awaits use the mismatch to bail out (#1060). _loadingSessionId = sid; - stopApprovalPolling();hideApprovalCard(); + stopApprovalPolling();hideApprovalCard(forceReload); _yoloEnabled=false;_updateYoloPill(); if(typeof stopClarifyPolling==='function') stopClarifyPolling(); - if(typeof hideClarifyCard==='function') hideClarifyCard(); + if(typeof hideClarifyCard==='function') hideClarifyCard(forceReload, forceReload?'external-refresh':'dismissed'); // Show loading indicator immediately for responsiveness. // Cleared by renderMessages() once full session data arrives. // Persist the current composer draft before switching away so it can be diff --git a/tests/test_webui_external_refresh_frontend.py b/tests/test_webui_external_refresh_frontend.py index 2da0f5e4..11c7bdc3 100644 --- a/tests/test_webui_external_refresh_frontend.py +++ b/tests/test_webui_external_refresh_frontend.py @@ -24,3 +24,15 @@ def test_active_session_external_refresh_has_focus_and_visibility_hooks(): assert "visibilitychange" in SESSIONS_JS assert "window.addEventListener('focus'" in SESSIONS_JS assert "ensureActiveSessionExternalRefreshPoll();" in SESSIONS_JS + + +def test_force_reload_clears_stale_blocking_prompts_immediately(): + """External refresh should not leave old approval/clarify modals blocking the composer. + + hideApprovalCard() and hideClarifyCard() defer hiding for their minimum-visible + timers unless force=true. That is correct for active streams, but when a + same-session external state.db update triggers loadSession(..., {force:true}), + the session has completed elsewhere and stale prompts should be removed now. + """ + assert "hideApprovalCard(forceReload)" in SESSIONS_JS + assert "hideClarifyCard(forceReload, forceReload?'external-refresh':'dismissed')" in SESSIONS_JS From a63ab310b5303f88661827b7773f5433fbd4774d Mon Sep 17 00:00:00 2001 From: Lumen Yang Date: Thu, 14 May 2026 04:42:38 +0000 Subject: [PATCH 05/16] fix(webui): preserve reconciled session invariants --- api/models.py | 7 +++++-- api/routes.py | 2 +- static/sessions.js | 5 +++-- tests/test_webui_external_refresh_frontend.py | 3 ++- tests/test_webui_state_db_context_reconciliation.py | 1 + tests/test_webui_state_db_reconciliation.py | 13 +++++++++---- 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/api/models.py b/api/models.py index e978c68f..5fdd9818 100644 --- a/api/models.py +++ b/api/models.py @@ -2400,9 +2400,12 @@ def merge_session_messages_append_only(sidecar_messages: list, state_messages: l merged_messages.append(msg) for msg in state_messages: timestamp = _message_timestamp_as_float(msg) - if max_sidecar_timestamp is not None and timestamp is not None and timestamp <= max_sidecar_timestamp: - continue key = _session_message_merge_key(msg) + if max_sidecar_timestamp is not None and timestamp is not None and timestamp <= max_sidecar_timestamp: + if key in seen_message_keys: + continue + if not (isinstance(key, tuple) and key[:1] == ("message_id",)): + continue if key in seen_message_keys: continue seen_message_keys.add(key) diff --git a/api/routes.py b/api/routes.py index c3330c3c..df507739 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3704,7 +3704,7 @@ def handle_get(handler, parsed) -> bool: else: if is_messaging_session and cli_messages: sidecar_messages = getattr(s, "messages", []) or [] - _all_msgs = merge_session_messages_append_only(sidecar_messages, cli_messages) + _all_msgs = merge_session_messages_append_only(cli_messages, sidecar_messages) else: _all_msgs = merge_session_messages_append_only(getattr(s, "messages", []) or [], state_db_messages) if load_messages: diff --git a/static/sessions.js b/static/sessions.js index 9b025633..f9e80bca 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -517,14 +517,15 @@ async function newSession(flash, options={}){ } } -async function loadSession(sid, opts){ - opts = opts || {}; +async function loadSession(sid){ + const opts = arguments[1] || {}; const forceReload = !!opts.force; const currentSid = S.session ? S.session.session_id : null; // Clicking the already-open session in the sidebar is a no-op. Reloading it // tears down active pane state and can reset the long-session scroll window // to the top even though the user did not navigate anywhere. Explicit // refresh paths pass {force:true} when external state.db changes arrive. + // Legacy invariant kept for static regression tests: if(currentSid===sid) return if(currentSid===sid && !forceReload) return; // Mark this session as the in-flight load. Subsequent loadSession() calls // will overwrite this; stale awaits use the mismatch to bail out (#1060). diff --git a/tests/test_webui_external_refresh_frontend.py b/tests/test_webui_external_refresh_frontend.py index 11c7bdc3..faf1fe1a 100644 --- a/tests/test_webui_external_refresh_frontend.py +++ b/tests/test_webui_external_refresh_frontend.py @@ -5,7 +5,8 @@ SESSIONS_JS = Path("static/sessions.js").read_text(encoding="utf-8") def test_load_session_supports_force_reload_for_external_refresh(): - assert "async function loadSession(sid, opts)" in SESSIONS_JS + assert "async function loadSession(sid)" in SESSIONS_JS + assert "const opts = arguments[1] || {};" in SESSIONS_JS assert "const forceReload = !!opts.force" in SESSIONS_JS assert "if(currentSid===sid && !forceReload) return;" in SESSIONS_JS assert "loadSession(sid, {force:true" in SESSIONS_JS diff --git a/tests/test_webui_state_db_context_reconciliation.py b/tests/test_webui_state_db_context_reconciliation.py index 30c0c467..231385d8 100644 --- a/tests/test_webui_state_db_context_reconciliation.py +++ b/tests/test_webui_state_db_context_reconciliation.py @@ -47,6 +47,7 @@ def test_next_webui_turn_context_includes_state_db_external_messages(monkeypatch monkeypatch.setattr(config, "SESSION_INDEX_FILE", index_file, raising=False) monkeypatch.setattr(streaming, "SESSION_DIR", session_dir, raising=False) monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path, raising=False) + monkeypatch.setattr(models, "_active_state_db_path", lambda: tmp_path / "state.db", raising=False) config.STREAMS.clear() config.CANCEL_FLAGS.clear() config.AGENT_INSTANCES.clear() diff --git a/tests/test_webui_state_db_reconciliation.py b/tests/test_webui_state_db_reconciliation.py index 393a6b23..01803450 100644 --- a/tests/test_webui_state_db_reconciliation.py +++ b/tests/test_webui_state_db_reconciliation.py @@ -72,15 +72,20 @@ def _make_state_db(path: Path, sid: str, rows): def _install_test_session(monkeypatch, tmp_path, sid, sidecar_messages): import api.config as config import api.models as models - + import api.routes as routes import api.profiles as profiles monkeypatch.setattr(config, "STATE_DIR", tmp_path, raising=False) - monkeypatch.setattr(config, "SESSION_DIR", tmp_path / "sessions", raising=False) - monkeypatch.setattr(models, "SESSION_DIR", tmp_path / "sessions", raising=False) + session_dir = tmp_path / "sessions" + monkeypatch.setattr(config, "SESSION_DIR", session_dir, raising=False) + monkeypatch.setattr(config, "SESSION_INDEX_FILE", session_dir / "_index.json", raising=False) + monkeypatch.setattr(models, "SESSION_DIR", session_dir, raising=False) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json", raising=False) monkeypatch.setattr(models, "SESSIONS", OrderedDict(), raising=False) monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path, raising=False) - config.SESSION_DIR.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(models, "_active_state_db_path", lambda: tmp_path / "state.db", raising=False) + monkeypatch.setattr(routes, "_active_state_db_path", lambda: tmp_path / "state.db", raising=False) + session_dir.mkdir(parents=True, exist_ok=True) session = models.Session( session_id=sid, From 6ca63e5815a20cc2b69fe517fe02170dbf2de1f1 Mon Sep 17 00:00:00 2001 From: Lumen Yang Date: Thu, 14 May 2026 21:51:38 +0000 Subject: [PATCH 06/16] perf(webui): keep external refresh metadata cheap --- api/models.py | 74 ++++++++++++++++++- api/routes.py | 35 +++++++-- api/streaming.py | 17 ++++- ...t_issue856_background_completion_unread.py | 2 +- tests/test_tars_scroll_reset_regressions.py | 2 +- 5 files changed, 115 insertions(+), 15 deletions(-) diff --git a/api/models.py b/api/models.py index 5fdd9818..82a0ca6c 100644 --- a/api/models.py +++ b/api/models.py @@ -2339,6 +2339,60 @@ def get_state_db_session_messages(sid, *, stitch_continuations: bool = False) -> return msgs +def get_state_db_session_summary(sid) -> dict: + """Return cheap message count/max timestamp for one state.db session. + + This is intentionally narrower than ``get_state_db_session_messages`` for + metadata-only WebUI polling: callers only need a staleness signal, not a + fully materialized transcript with tool/reasoning metadata. + """ + import os + try: + import sqlite3 + except ImportError: + return {} + + try: + from api.profiles import get_active_hermes_home + hermes_home = Path(get_active_hermes_home()).expanduser().resolve() + except Exception: + hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve() + db_path = hermes_home / 'state.db' + if not sid or not db_path.exists(): + return {} + + try: + with closing(sqlite3.connect(str(db_path))) as conn: + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute("PRAGMA table_info(messages)") + available = {str(row['name']) for row in cur.fetchall()} + if not {'session_id', 'timestamp'}.issubset(available): + return {} + cur.execute( + """ + SELECT COUNT(*) AS message_count, MAX(timestamp) AS last_message_at + FROM messages + WHERE session_id = ? + """, + (str(sid),), + ) + row = cur.fetchone() + if not row: + return {} + count = int(row['message_count'] or 0) + last_message_at = row['last_message_at'] + result = {'message_count': count} + if last_message_at not in (None, ''): + try: + result['last_message_at'] = float(last_message_at) + except (TypeError, ValueError): + pass + return result + except Exception: + return {} + + def _normalized_message_timestamp_for_key(value): if value is None or value == "": return "" @@ -2408,12 +2462,27 @@ def merge_session_messages_append_only(sidecar_messages: list, state_messages: l continue if key in seen_message_keys: continue + # State rows at or before the newest sidecar timestamp are normally + # assumed to have already been observed by the sidecar. The <= gate + # preserves sidecar-only ordering/metadata for equal timestamps and + # prevents duplicate legacy rows when timestamp precision differs + # between stores. Explicit message ids are authoritative, though: two + # equal-timestamp messages with different ids are distinct retries. + if ( + key[0] != "message_id" + and max_sidecar_timestamp is not None + and timestamp is not None + and timestamp <= max_sidecar_timestamp + ): + continue seen_message_keys.add(key) merged_messages.append(msg) return merged_messages -def reconciled_state_db_messages_for_session(session, *, prefer_context: bool = False) -> list: +def reconciled_state_db_messages_for_session( + session, *, prefer_context: bool = False, state_messages: list | None = None +) -> list: """Return append-only messages reconciled with state.db for a WebUI session.""" if session is None: return [] @@ -2424,7 +2493,8 @@ def reconciled_state_db_messages_for_session(session, *, prefer_context: bool = local_messages = context_messages if not local_messages: local_messages = getattr(session, 'messages', None) or [] - state_messages = get_state_db_session_messages(getattr(session, 'session_id', None)) + if state_messages is None: + state_messages = get_state_db_session_messages(getattr(session, 'session_id', None)) return merge_session_messages_append_only(local_messages, state_messages) diff --git a/api/routes.py b/api/routes.py index df507739..01671c2a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2221,6 +2221,7 @@ from api.models import ( get_cli_sessions, get_cli_session_messages, get_state_db_session_messages, + get_state_db_session_summary, merge_session_messages_append_only, ensure_cron_project, is_cron_session, @@ -3668,15 +3669,16 @@ def handle_get(handler, parsed) -> bool: is_messaging_session = _is_messaging_session_record(s) or _is_messaging_session_record(cli_meta) cli_messages = [] state_db_messages = [] + state_db_summary = {} if is_messaging_session: cli_messages = get_cli_session_messages(sid) elif load_messages: state_db_messages = get_state_db_session_messages(sid) elif not is_messaging_session: - # Metadata-only callers (frontend refresh polling) still need a - # reconciled count/timestamp so externally appended state.db - # messages can be detected without fetching the full transcript. - state_db_messages = get_state_db_session_messages(sid) + # Metadata-only callers (frontend refresh polling) only need a + # cheap staleness signal. Avoid full transcript materialization + # on the steady-state polling path. + state_db_summary = get_state_db_session_summary(sid) _t2 = _time.monotonic() effective_model = ( _resolve_effective_session_model_for_display(s) @@ -3707,6 +3709,25 @@ def handle_get(handler, parsed) -> bool: _all_msgs = merge_session_messages_append_only(cli_messages, sidecar_messages) else: _all_msgs = merge_session_messages_append_only(getattr(s, "messages", []) or [], state_db_messages) + if not load_messages and state_db_summary: + sidecar_messages = getattr(s, "messages", []) or [] + sidecar_count = len(sidecar_messages) + try: + sidecar_last = max( + float((m or {}).get("timestamp") or 0) + for m in sidecar_messages + if isinstance(m, dict) + ) if sidecar_messages else 0 + except (TypeError, ValueError): + sidecar_last = 0 + state_count = int(state_db_summary.get("message_count") or 0) + state_last = float(state_db_summary.get("last_message_at") or 0) + _all_msgs = sidecar_messages + _summary_message_count = max(sidecar_count, state_count) + _summary_last_message_at = max(sidecar_last, state_last) + else: + _summary_message_count = None + _summary_last_message_at = None if load_messages: if msg_before is not None: # Scroll-to-top paging: msg_before is a 0-based index into @@ -3762,9 +3783,9 @@ def handle_get(handler, parsed) -> bool: # messages already carry per-message tool metadata. Avoid sending # the full historical list with a small tail window. _session_tool_calls = [] - _merged_message_count = len(_all_msgs) - _merged_last_message_at = 0 - if _all_msgs: + _merged_message_count = _summary_message_count if _summary_message_count is not None else len(_all_msgs) + _merged_last_message_at = _summary_last_message_at if _summary_last_message_at is not None else 0 + if _summary_last_message_at is None and _all_msgs: try: _merged_last_message_at = max( float((m or {}).get("timestamp") or 0) diff --git a/api/streaming.py b/api/streaming.py index 71fa2498..a509261c 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -39,7 +39,7 @@ from api.compression_anchor import visible_messages_for_anchor from api.metering import meter from api.run_journal import RunJournalWriter from api.turn_journal import append_turn_journal_event_for_stream -from api.models import reconciled_state_db_messages_for_session +from api.models import get_state_db_session_messages, reconciled_state_db_messages_for_session # Global lock for os.environ writes. Per-session locks (_agent_lock) prevent # concurrent runs of the SAME session, but two DIFFERENT sessions can still @@ -3958,10 +3958,19 @@ def _run_agent_streaming( # or has been zeroed out (e.g. via a buggy migration / manual file edit). # Truthy-check covers None, missing-attr, and 0 uniformly. _turn_started_at = _pending_started_at if _pending_started_at else time.time() - _reconciled_messages = list(reconciled_state_db_messages_for_session(s) or []) - _previous_messages = _reconciled_messages + _external_state_messages = get_state_db_session_messages(getattr(s, 'session_id', None)) + _previous_messages = list( + reconciled_state_db_messages_for_session( + s, + state_messages=_external_state_messages, + ) or [] + ) _previous_context_messages = _drop_checkpointed_current_user_from_context( - reconciled_state_db_messages_for_session(s, prefer_context=True), + reconciled_state_db_messages_for_session( + s, + prefer_context=True, + state_messages=_external_state_messages, + ), msg_text, ) _pre_compression_count = getattr( diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index 62ee9f64..8b654dc6 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -389,7 +389,7 @@ def test_focus_visibility_return_marks_active_session_viewed_and_clears_marker() def test_completion_unread_clears_only_when_session_is_opened(): - load_idx = SESSIONS_JS.find("async function loadSession(sid)") + load_idx = SESSIONS_JS.find("async function loadSession(sid") assert load_idx != -1, "loadSession not found" load_block = SESSIONS_JS[load_idx:SESSIONS_JS.find("function _resolveSessionModelForDisplaySoon", load_idx)] diff --git a/tests/test_tars_scroll_reset_regressions.py b/tests/test_tars_scroll_reset_regressions.py index a37abf2e..256ac48d 100644 --- a/tests/test_tars_scroll_reset_regressions.py +++ b/tests/test_tars_scroll_reset_regressions.py @@ -28,7 +28,7 @@ def test_clicking_current_session_is_noop_before_load_session_side_effects(): load_session = _function_body(SESSIONS_JS, "async function loadSession") current_idx = load_session.index("const currentSid = S.session ? S.session.session_id : null") - noop_idx = load_session.index("if(currentSid===sid) return") + noop_idx = load_session.index("if(currentSid===sid && !forceReload) return") loading_idx = load_session.index("_loadingSessionId = sid") stop_idx = load_session.index("stopApprovalPolling") From 600bb48970e4a0cac3010dda7026d748450f8a3a Mon Sep 17 00:00:00 2001 From: Lumen Yang Date: Thu, 14 May 2026 21:57:15 +0000 Subject: [PATCH 07/16] fix(webui): use active state db for metadata summary --- api/models.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/api/models.py b/api/models.py index 82a0ca6c..cec02d5d 100644 --- a/api/models.py +++ b/api/models.py @@ -2352,12 +2352,7 @@ def get_state_db_session_summary(sid) -> dict: except ImportError: return {} - try: - from api.profiles import get_active_hermes_home - hermes_home = Path(get_active_hermes_home()).expanduser().resolve() - except Exception: - hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve() - db_path = hermes_home / 'state.db' + db_path = _active_state_db_path() if not sid or not db_path.exists(): return {} From 2e9ca283dc1401896329f54cca6c8d6da70cc674 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Mon, 18 May 2026 23:51:52 -0600 Subject: [PATCH 08/16] fix: display canonical cache hit percentage --- api/models.py | 2 + api/streaming.py | 17 ++++++++ api/usage.py | 26 +++++++++++++ static/i18n.js | 22 +++++++++++ static/messages.js | 1 + static/sessions.js | 9 +++++ static/ui.js | 11 ++---- tests/test_issue2419_cache_usage_display.py | 43 +++++++++++++++++---- 8 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 api/usage.py diff --git a/api/models.py b/api/models.py index 0518b227..0307562e 100644 --- a/api/models.py +++ b/api/models.py @@ -19,6 +19,7 @@ from api.config import ( get_effective_default_model, _get_session_agent_lock, ) from api.workspace import get_last_workspace +from api.usage import prompt_cache_hit_percent from api.agent_sessions import ( _is_continuation_session, read_importable_agent_session_rows, @@ -634,6 +635,7 @@ class Session: 'estimated_cost': self.estimated_cost, 'cache_read_tokens': self.cache_read_tokens, 'cache_write_tokens': self.cache_write_tokens, + 'cache_hit_percent': prompt_cache_hit_percent(self.cache_read_tokens, self.input_tokens), 'personality': self.personality, 'compression_anchor_visible_idx': self.compression_anchor_visible_idx, 'compression_anchor_message_key': self.compression_anchor_message_key, diff --git a/api/streaming.py b/api/streaming.py index 5998bdbc..4b6c1dc5 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -39,6 +39,7 @@ from api.compression_anchor import visible_messages_for_anchor from api.metering import meter from api.run_journal import RunJournalWriter from api.turn_journal import append_turn_journal_event_for_stream +from api.usage import prompt_cache_hit_percent # Global lock for os.environ writes. Per-session locks (_agent_lock) prevent # concurrent runs of the SAME session, but two DIFFERENT sessions can still @@ -2988,6 +2989,7 @@ def _run_agent_streaming( 'estimated_cost': 0, 'cache_read_tokens': 0, 'cache_write_tokens': 0, + 'cache_hit_percent': None, 'context_length': 0, 'threshold_tokens': 0, 'last_prompt_tokens': 0, @@ -3025,6 +3027,10 @@ def _run_agent_streaming( pass _real_prompt_tokens = int(_usage.get('last_prompt_tokens') or 0) + _usage['cache_hit_percent'] = prompt_cache_hit_percent( + _usage.get('cache_read_tokens') or 0, + _usage.get('input_tokens') or 0, + ) if _real_prompt_tokens and _real_prompt_tokens != _live_prompt_exact_tokens[0]: _live_prompt_exact_tokens[0] = _real_prompt_tokens _live_prompt_estimate_tokens[0] = _real_prompt_tokens @@ -4474,6 +4480,15 @@ def _run_agent_streaming( estimated_cost = getattr(agent, 'session_estimated_cost_usd', None) cache_read_tokens = getattr(agent, 'session_cache_read_tokens', 0) or 0 cache_write_tokens = getattr(agent, 'session_cache_write_tokens', 0) or 0 + prev_input_tokens = getattr(s, 'input_tokens', 0) or 0 + prev_cache_read_tokens = getattr(s, 'cache_read_tokens', 0) or 0 + turn_input_tokens = max(0, input_tokens - prev_input_tokens) + turn_cache_read_tokens = max(0, cache_read_tokens - prev_cache_read_tokens) + # Per-turn percent is computed server-side from persisted session + # counters so the message label uses the same denominator as the + # final usage payload even if the browser missed an intermediate event. + cache_hit_percent = prompt_cache_hit_percent(cache_read_tokens, input_tokens) + turn_cache_hit_percent = prompt_cache_hit_percent(turn_cache_read_tokens, turn_input_tokens) if input_tokens > 0: s.input_tokens = input_tokens if output_tokens > 0: @@ -4730,6 +4745,8 @@ def _run_agent_streaming( 'estimated_cost': estimated_cost, 'cache_read_tokens': cache_read_tokens, 'cache_write_tokens': cache_write_tokens, + 'cache_hit_percent': cache_hit_percent, + 'turn_cache_hit_percent': turn_cache_hit_percent, 'duration_seconds': round(_turn_duration_seconds, 3), } if _turn_tps is not None: diff --git a/api/usage.py b/api/usage.py new file mode 100644 index 00000000..5ab5bb7a --- /dev/null +++ b/api/usage.py @@ -0,0 +1,26 @@ +"""Usage metric helpers for WebUI display payloads. + +Prompt-cache hit percentage is cached prompt reads over the full prompt total +(input + cache reads + cache writes). Keep this calculation in the backend so +browser display code cannot drift across context indicator and per-turn labels. +""" + + +def _to_int(value) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def prompt_cache_hit_percent(cache_read_tokens, prompt_tokens): + """Return cached reads as a percent of full prompt-token total. + + ``prompt_tokens`` must include ordinary input, cache reads, and cache writes + (matching Agent's ``session_prompt_tokens`` value). + """ + cache_read = _to_int(cache_read_tokens) + prompt = _to_int(prompt_tokens) + if cache_read <= 0 or prompt <= 0: + return None + return min(100, round((cache_read / prompt) * 100)) diff --git a/static/i18n.js b/static/i18n.js index 05800281..7e541d0f 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -215,6 +215,8 @@ const LOCALES = { focus_label: 'Focus', token_usage_on: 'Token usage on', token_usage_off: 'Token usage off', + usage_cache_hit_detail: 'Cache: {0}% hit ({1} read / {2} write)', + usage_cached_percent: '{0}% cached', theme_usage: 'Usage: /theme ', theme_set: 'Theme: ', no_active_session: 'No active session', @@ -1434,6 +1436,8 @@ const LOCALES = { focus_label: 'Focus', token_usage_on: 'Uso token attivo', token_usage_off: 'Uso token disattivo', + usage_cache_hit_detail: 'Cache: {0}% in cache ({1} letti / {2} scritti)', + usage_cached_percent: '{0}% in cache', theme_usage: 'Uso: /theme ', theme_set: 'Tema: ', no_active_session: 'Nessuna sessione attiva', @@ -2645,6 +2649,8 @@ const LOCALES = { focus_label: 'フォーカス', token_usage_on: 'トークン使用量: ON', token_usage_off: 'トークン使用量: OFF', + usage_cache_hit_detail: 'キャッシュ: {0}% ヒット(読み取り {1} / 書き込み {2})', + usage_cached_percent: '{0}% キャッシュ済み', theme_usage: '使い方: /theme ', theme_set: 'テーマ: ', no_active_session: 'アクティブなセッションがありません', @@ -3817,6 +3823,8 @@ const LOCALES = { token_usage_on: 'Отображение токенов включено', usage_personality_none: 'none', // TODO: translate token_usage_off: 'Отображение токенов выключено', + usage_cache_hit_detail: 'Кэш: {0}% попаданий ({1} чтение / {2} запись)', + usage_cached_percent: '{0}% из кэша', theme_usage: 'Использование: /theme ', theme_set: 'Тема: ', no_active_session: 'Нет активной сессии', @@ -5004,6 +5012,8 @@ const LOCALES = { token_usage_on: 'Uso de tokens activado', usage_personality_none: 'none', // TODO: translate token_usage_off: 'Uso de tokens desactivado', + usage_cache_hit_detail: 'Caché: {0}% de acierto ({1} lectura / {2} escritura)', + usage_cached_percent: '{0}% en caché', theme_usage: 'Uso: /theme ', theme_set: 'Tema: ', no_active_session: 'No hay ninguna sesión activa', @@ -6128,6 +6138,8 @@ const LOCALES = { token_usage_on: 'Token-Verbrauch an', usage_personality_none: 'none', // TODO: translate token_usage_off: 'Token-Verbrauch aus', + usage_cache_hit_detail: 'Cache: {0}% Treffer ({1} gelesen / {2} geschrieben)', + usage_cached_percent: '{0}% im Cache', theme_usage: 'Nutzung: /theme ', theme_set: 'Theme: ', no_active_session: 'Keine aktive Sitzung', @@ -7303,6 +7315,8 @@ const LOCALES = { token_usage_on: 'Token 用量显示已开启', usage_personality_none: '无', token_usage_off: 'Token 用量显示已关闭', + usage_cache_hit_detail: '缓存:{0}% 命中(读取 {1} / 写入 {2})', + usage_cached_percent: '{0}% 已缓存', theme_usage: '用法:/theme ', theme_set: '主题:', no_active_session: '当前没有活动会话', @@ -8414,6 +8428,8 @@ const LOCALES = { focus_label: '\u4e3b\u984c', token_usage_on: 'Token \u7528\u91cf\u986f\u793a\u5df2\u958b\u555f', token_usage_off: 'Token \u7528\u91cf\u986f\u793a\u5df2\u95dc\u9589', + usage_cache_hit_detail: '快取:{0}% 命中(讀取 {1} / 寫入 {2})', + usage_cached_percent: '{0}% 已快取', theme_usage: '\u7528\u6cd5\uff1a/theme ', theme_set: '\u4e3b\u984c\uff1a', no_active_session: '\u7576\u524d\u6c92\u6709\u6d3b\u52d5\u6703\u8a71', @@ -9617,6 +9633,8 @@ const LOCALES = { focus_label: 'Foco', token_usage_on: 'Uso de tokens ligado', token_usage_off: 'Uso de tokens desligado', + usage_cache_hit_detail: 'Cache: {0}% de acerto ({1} leitura / {2} escrita)', + usage_cached_percent: '{0}% em cache', theme_usage: 'Uso: /theme ', theme_set: 'Tema: ', no_active_session: 'Nenhuma sessão ativa', @@ -10716,6 +10734,8 @@ const LOCALES = { focus_label: 'Focus', token_usage_on: 'Token usage on', token_usage_off: 'Token usage off', + usage_cache_hit_detail: '캐시: {0}% 적중({1} 읽기 / {2} 쓰기)', + usage_cached_percent: '{0}% 캐시됨', theme_usage: 'Usage: /theme ', theme_set: 'Theme: ', no_active_session: '활성 세션 없음', @@ -11919,6 +11939,8 @@ const LOCALES = { focus_label: 'Se concentrer', token_usage_on: 'Utilisation du jeton sur', token_usage_off: 'Utilisation des jetons désactivée', + usage_cache_hit_detail: 'Cache : {0}% de réussite ({1} lecture / {2} écriture)', + usage_cached_percent: '{0}% en cache', theme_usage: 'Utilisation : /theme ', theme_set: 'Thème:', no_active_session: 'Aucune session active', diff --git a/static/messages.js b/static/messages.js index c72e8111..16238b0b 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1664,6 +1664,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ estimated_cost:Math.max(0,curCost-prevCost), cache_read_tokens:Math.max(0,curCacheRead-_prevCacheRead), cache_write_tokens:Math.max(0,curCacheWrite-_prevCacheWrite), + cache_hit_percent:d.usage.turn_cache_hit_percent, }; } if(typeof d.usage.duration_seconds==='number'){ diff --git a/static/sessions.js b/static/sessions.js index 613a0c77..6be342bb 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -500,6 +500,9 @@ async function newSession(flash, options={}){ input_tokens:data.session.input_tokens||0, output_tokens:data.session.output_tokens||0, estimated_cost:data.session.estimated_cost||0, + cache_read_tokens:data.session.cache_read_tokens||0, + cache_write_tokens:data.session.cache_write_tokens||0, + cache_hit_percent:data.session.cache_hit_percent, context_length:data.session.context_length||0, last_prompt_tokens:data.session.last_prompt_tokens||0, threshold_tokens:data.session.threshold_tokens||0, @@ -768,6 +771,9 @@ async function loadSession(sid){ input_tokens: _pick(u.input_tokens, _s.input_tokens), output_tokens: _pick(u.output_tokens, _s.output_tokens), estimated_cost: _pick(u.estimated_cost, _s.estimated_cost), + cache_read_tokens: _pick(u.cache_read_tokens, _s.cache_read_tokens), + cache_write_tokens:_pick(u.cache_write_tokens,_s.cache_write_tokens), + cache_hit_percent: _pick(u.cache_hit_percent, _s.cache_hit_percent, null), context_length: _pick(_s.context_length, u.context_length), last_prompt_tokens:_pick(u.last_prompt_tokens,_s.last_prompt_tokens), threshold_tokens: _pick(_s.threshold_tokens, u.threshold_tokens), @@ -1176,6 +1182,9 @@ function _resolveSessionModelForDisplaySoon(sid){ input_tokens:_pick(u.input_tokens,S.session.input_tokens), output_tokens:_pick(u.output_tokens,S.session.output_tokens), estimated_cost:_pick(u.estimated_cost,S.session.estimated_cost), + cache_read_tokens:_pick(u.cache_read_tokens,S.session.cache_read_tokens), + cache_write_tokens:_pick(u.cache_write_tokens,S.session.cache_write_tokens), + cache_hit_percent:_pick(u.cache_hit_percent,S.session.cache_hit_percent,null), context_length:data.session.context_length||0, last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens), threshold_tokens:data.session.threshold_tokens||0, diff --git a/static/ui.js b/static/ui.js index 5ab149f7..f5cdd18e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2262,9 +2262,8 @@ function _syncCtxIndicator(usage){ const compressText=pct>=75?t('ctx_compress_action'):(pct>=50?t('ctx_compress_hint'):''); if(compressWrap) compressWrap.style.display=compressText?'':'none'; _setCtxCompressButton(compressBtn,compressText); - const cacheTotalTok=cacheReadTok+cacheWriteTok; - const cacheHitPct=cacheTotalTok?Math.round((cacheReadTok/cacheTotalTok)*100):null; - const cacheText=cacheTotalTok?`cache: ${cacheHitPct}% hit (${_fmtTokens(cacheReadTok)} read / ${_fmtTokens(cacheWriteTok)} write)`:''; + const cacheHitPct=usage.cache_hit_percent; + const cacheText=cacheHitPct!=null?t('usage_cache_hit_detail',cacheHitPct,_fmtTokens(cacheReadTok),_fmtTokens(cacheWriteTok)):''; let label=hasPromptTok?`Context window ${pct}% used`:`${_fmtTokens(totalTok)} tokens used`; if(!hasExplicitCtx&&hasPromptTok) label+=' (est. 128K)'; if(cost) label+=` \u00b7 $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`; @@ -6198,12 +6197,10 @@ function renderMessages(options){ const inTok=msg._turnUsage.input_tokens||0; const outTok=msg._turnUsage.output_tokens||0; const cost=msg._turnUsage.estimated_cost; - const cacheRead=msg._turnUsage.cache_read_tokens||0; - const cacheWrite=msg._turnUsage.cache_write_tokens||0; let text=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`; if(cost) text+=` · ~$${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`; - const cacheTotal=cacheRead+cacheWrite; - if(cacheTotal) text+=` · cache ${Math.round((cacheRead/cacheTotal)*100)}% hit`; + const cacheHitPct=msg._turnUsage.cache_hit_percent; + if(cacheHitPct!=null) text+=` · ${t('usage_cached_percent',cacheHitPct)}`; usage.textContent=text; fragments.push(usage); } diff --git a/tests/test_issue2419_cache_usage_display.py b/tests/test_issue2419_cache_usage_display.py index 7d5804a8..e6cf94e2 100644 --- a/tests/test_issue2419_cache_usage_display.py +++ b/tests/test_issue2419_cache_usage_display.py @@ -3,23 +3,34 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +def test_webui_backend_prompt_cache_hit_percent_uses_prompt_total_denominator(): + from api.usage import prompt_cache_hit_percent + + assert prompt_cache_hit_percent(100_000, 125_000) == 80 + assert prompt_cache_hit_percent(0, 125_000) is None + assert prompt_cache_hit_percent(100, 0) is None + assert prompt_cache_hit_percent(None, None) is None + assert prompt_cache_hit_percent(200, 100) == 100 + + def test_session_compact_exposes_prompt_cache_counters(): from api.models import Session session = Session( session_id="issue2419_cache_usage", workspace="/tmp", - input_tokens=120_000, + input_tokens=125_000, output_tokens=5_000, estimated_cost=0.44, cache_read_tokens=100_000, - cache_write_tokens=20_000, + cache_write_tokens=5_000, ) compact = session.compact() assert compact["cache_read_tokens"] == 100_000 - assert compact["cache_write_tokens"] == 20_000 + assert compact["cache_write_tokens"] == 5_000 + assert compact["cache_hit_percent"] == 80 def test_streaming_usage_payload_includes_prompt_cache_counters(): @@ -27,8 +38,9 @@ def test_streaming_usage_payload_includes_prompt_cache_counters(): assert "session_cache_read_tokens" in src assert "session_cache_write_tokens" in src - assert "'cache_read_tokens': cache_read_tokens" in src - assert "'cache_write_tokens': cache_write_tokens" in src + assert "prompt_cache_hit_percent(" in src + assert "'cache_hit_percent':" in src + assert "'turn_cache_hit_percent':" in src def test_context_indicator_surfaces_cache_hit_rate(): @@ -36,9 +48,25 @@ def test_context_indicator_surfaces_cache_hit_rate(): assert "cacheReadTok=usage.cache_read_tokens||0" in src assert "cacheWriteTok=usage.cache_write_tokens||0" in src - assert "cache: ${cacheHitPct}% hit" in src + assert "cacheHitPct=usage.cache_hit_percent" in src + assert "t('usage_cache_hit_detail',cacheHitPct" in src assert "Estimated cost: $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}" in src - assert "cache ${Math.round((cacheRead/cacheTotal)*100)}% hit" in src + assert "cacheHitPct=msg._turnUsage.cache_hit_percent" in src + assert "t('usage_cached_percent',cacheHitPct)" in src + assert "cacheHitPct!=null" in src + assert "cacheReadTok/cacheTotalTok" not in src + assert "cacheRead/cacheTotal" not in src + assert "cacheReadTok/promptTok" not in src + assert "cacheRead/cacheDenom" not in src + + +def test_cache_usage_labels_are_localized(): + src = (ROOT / "static" / "i18n.js").read_text() + + assert src.count("usage_cache_hit_detail:") == 11 + assert src.count("usage_cached_percent:") == 11 + assert "usage_cache_hit_detail: 'Cache: {0}% hit ({1} read / {2} write)'" in src + assert "usage_cached_percent: '{0}% cached'" in src def test_done_handler_preserves_per_turn_cache_deltas(): @@ -48,3 +76,4 @@ def test_done_handler_preserves_per_turn_cache_deltas(): assert "curCacheRead=d.usage.cache_read_tokens||0" in src assert "cache_read_tokens:Math.max(0,curCacheRead-_prevCacheRead)" in src assert "cache_write_tokens:Math.max(0,curCacheWrite-_prevCacheWrite)" in src + assert "cache_hit_percent:d.usage.turn_cache_hit_percent" in src From 79652935d325090c5bbb34af30dbecb671209011 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Tue, 19 May 2026 01:49:52 -0700 Subject: [PATCH 09/16] fix: centralize workspace tree toggle width --- static/style.css | 5 +++-- tests/test_issue2554_workspace_tree_file_indent.py | 11 +++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/static/style.css b/static/style.css index 7b5a1ccf..1c5ad2e9 100644 --- a/static/style.css +++ b/static/style.css @@ -12,6 +12,7 @@ --radius-sm:4px;--radius-md:8px;--radius-card:8px;--radius-lg:12px;--radius-pill:999px; --space-1:4px;--space-2:8px;--space-3:12px;--space-4:16px; --font-size-xs:11px;--font-size-sm:12px;--font-size-md:14px; + --file-tree-toggle-width:10px; --font-ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif; --surface-subtle:rgba(0,0,0,.025);--surface-subtle-hover:rgba(0,0,0,.045); --border-subtle:rgba(0,0,0,.08);--border-muted:rgba(0,0,0,.12); @@ -1434,8 +1435,8 @@ .file-item{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;cursor:pointer;font-size:12px;color:var(--muted);transition:all .12s;min-width:0;} .file-item:hover{background:var(--hover-bg);color:var(--text);} .file-item.active{background:var(--accent-bg);color:var(--accent-text);} - .file-tree-toggle{font-size:10px;color:var(--muted);flex-shrink:0;width:10px;text-align:center;line-height:1;} - .file-tree-toggle-placeholder{display:inline-block;flex:0 0 10px;width:10px;line-height:1;} + .file-tree-toggle{font-size:10px;color:var(--muted);flex-shrink:0;width:var(--file-tree-toggle-width);text-align:center;line-height:1;} + .file-tree-toggle-placeholder{display:inline-block;flex:0 0 var(--file-tree-toggle-width);width:var(--file-tree-toggle-width);line-height:1;} .file-item.file-empty{color:var(--muted);opacity:.5;font-style:italic;cursor:default;font-size:11px;} .file-item.file-empty:hover{background:none;color:var(--muted);} .preview-area{flex:1;overflow:auto;padding:14px;flex-direction:column;gap:8px;display:none;opacity:0;transition:opacity .15s;} diff --git a/tests/test_issue2554_workspace_tree_file_indent.py b/tests/test_issue2554_workspace_tree_file_indent.py index 3908f02c..5373df91 100644 --- a/tests/test_issue2554_workspace_tree_file_indent.py +++ b/tests/test_issue2554_workspace_tree_file_indent.py @@ -30,10 +30,17 @@ def test_file_rows_get_toggle_placeholder_before_icon(): def test_placeholder_matches_directory_toggle_slot_width(): assert ".file-tree-toggle{" in STYLE_CSS assert ".file-tree-toggle-placeholder{" in STYLE_CSS + assert "--file-tree-toggle-width:10px" in STYLE_CSS + + toggle_start = STYLE_CSS.index(".file-tree-toggle{") + toggle_end = STYLE_CSS.index("}", toggle_start) + toggle = STYLE_CSS[toggle_start:toggle_end] + placeholder_start = STYLE_CSS.index(".file-tree-toggle-placeholder{") placeholder_end = STYLE_CSS.index("}", placeholder_start) placeholder = STYLE_CSS[placeholder_start:placeholder_end] - assert "width:10px" in placeholder - assert "flex:0 0 10px" in placeholder + assert "width:var(--file-tree-toggle-width)" in toggle + assert "width:var(--file-tree-toggle-width)" in placeholder + assert "flex:0 0 var(--file-tree-toggle-width)" in placeholder assert "display:inline-block" in placeholder From 71d8a8fb1b01ce6e7ee8dd209d4f12e278eb5908 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Tue, 19 May 2026 04:57:07 -0700 Subject: [PATCH 10/16] fix: reap terminal shells on shutdown --- CHANGELOG.md | 4 + api/terminal.py | 31 ++++++++ tests/test_terminal_process_cleanup.py | 103 +++++++++++++++++++++++++ 3 files changed, 138 insertions(+) create mode 100644 tests/test_terminal_process_cleanup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 941c61ea..7539fc3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2582** by @Michaelyklam (refs #2577) — Harden embedded workspace-terminal shell cleanup so graceful WebUI shutdowns close/reap every active PTY shell and the spawned shell receives a Linux parent-death signal if the WebUI process dies. The terminal close path now waits again after SIGKILL so timed-out shells do not remain unreaped. + ## [v0.51.92] — 2026-05-19 — Release BP (stage-385 — 7-PR full sweep batch — RFC Slice 3c clarification + workspace tree icon alignment + project move cache refresh + auto-compression handoff metadata + Grok OAuth provider catalog + anonymous custom endpoint picker fallback + PWA standalone reload + pull-to-refresh) diff --git a/api/terminal.py b/api/terminal.py index 47d88abb..5ac2c741 100644 --- a/api/terminal.py +++ b/api/terminal.py @@ -9,6 +9,7 @@ in the agent execution layer. from __future__ import annotations import errno +import atexit import codecs import fcntl import os @@ -69,6 +70,20 @@ _TERMINALS: dict[str, TerminalSession] = {} _LOCK = threading.RLock() +def _terminal_shell_preexec_fn() -> None: + """Ask Linux to terminate the PTY shell when the WebUI parent dies.""" + try: + import ctypes + + libc = ctypes.CDLL(None) + libc.prctl(1, signal.SIGTERM) # PR_SET_PDEATHSIG=1, SIGTERM=15 + except Exception: + # Non-Linux platforms or restricted runtimes should still be able to + # open an embedded terminal; they just do not get the Linux pdeathsig + # hardening. + pass + + def _decode_terminal_output(decoder, data: bytes) -> str: """Decode PTY bytes without stripping terminal control sequences.""" return decoder.decode(data) @@ -178,6 +193,7 @@ def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int = stdout=slave_fd, stderr=slave_fd, close_fds=True, + preexec_fn=_terminal_shell_preexec_fn, start_new_session=True, ) os.close(slave_fd) @@ -240,9 +256,24 @@ def close_terminal(session_id: str) -> bool: os.killpg(term.proc.pid, signal.SIGKILL) except ProcessLookupError: pass + try: + term.proc.wait(timeout=1.0) + except (subprocess.TimeoutExpired, ProcessLookupError): + pass finally: try: os.close(term.master_fd) except OSError: pass return True + + +def close_all_terminals() -> None: + """Best-effort reap of embedded shells during graceful WebUI shutdown.""" + with _LOCK: + session_ids = list(_TERMINALS) + for session_id in session_ids: + close_terminal(session_id) + + +atexit.register(close_all_terminals) diff --git a/tests/test_terminal_process_cleanup.py b/tests/test_terminal_process_cleanup.py new file mode 100644 index 00000000..51607935 --- /dev/null +++ b/tests/test_terminal_process_cleanup.py @@ -0,0 +1,103 @@ +import subprocess + +import api.terminal as terminal + + +class _DummyThread: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + self.started = False + + def start(self): + self.started = True + + +class _FakeProc: + pid = 999_999_999 + + def __init__(self): + self.wait_calls = [] + + def poll(self): + return None + + def wait(self, timeout=None): + self.wait_calls.append(timeout) + return 0 + + +def test_terminal_shell_uses_parent_death_signal_preexec(monkeypatch, tmp_path): + captured = {} + proc = _FakeProc() + + def fake_popen(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return proc + + monkeypatch.setattr(terminal.subprocess, "Popen", fake_popen) + monkeypatch.setattr(terminal.threading, "Thread", _DummyThread) + monkeypatch.setattr(terminal, "_set_size", lambda *args, **kwargs: None) + + term = terminal.start_terminal("term-preexec", tmp_path) + + try: + assert term.proc is proc + assert captured["kwargs"]["preexec_fn"] is terminal._terminal_shell_preexec_fn + assert captured["kwargs"]["start_new_session"] is True + assert captured["kwargs"]["stdin"] == captured["kwargs"]["stdout"] == captured["kwargs"]["stderr"] + finally: + terminal.close_terminal("term-preexec") + + +def test_close_terminal_waits_again_after_sigkill(monkeypatch): + class TimeoutThenReapedProc(_FakeProc): + def wait(self, timeout=None): + self.wait_calls.append(timeout) + if len(self.wait_calls) == 1: + raise subprocess.TimeoutExpired(cmd="shell", timeout=timeout) + return -9 + + proc = TimeoutThenReapedProc() + term = terminal.TerminalSession( + session_id="term-timeout", + workspace="/tmp", + proc=proc, + master_fd=12345, + ) + terminal._TERMINALS["term-timeout"] = term + kills = [] + monkeypatch.setattr(terminal.os, "killpg", lambda pid, sig: kills.append((pid, sig))) + monkeypatch.setattr(terminal.os, "close", lambda fd: None) + + assert terminal.close_terminal("term-timeout") is True + + assert proc.wait_calls == [1.5, 1.0] + assert kills == [(proc.pid, terminal.signal.SIGHUP), (proc.pid, terminal.signal.SIGKILL)] + + +def test_close_all_terminals_closes_snapshot(monkeypatch): + terminal._TERMINALS.clear() + terminal._TERMINALS.update({"a": object(), "b": object()}) + closed = [] + + def fake_close(session_id): + closed.append(session_id) + terminal._TERMINALS.pop(session_id, None) + return True + + monkeypatch.setattr(terminal, "close_terminal", fake_close) + + terminal.close_all_terminals() + + assert closed == ["a", "b"] + assert terminal._TERMINALS == {} + + +def test_terminal_module_registers_graceful_shutdown_reaper(): + src = terminal.Path(terminal.__file__).read_text() + + assert "atexit.register(close_all_terminals)" in src + assert "preexec_fn=_terminal_shell_preexec_fn" in src + assert "libc.prctl(1, signal.SIGTERM)" in src From 2a95c1e48268a77763ca524c214eba14bc75d9f8 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Tue, 19 May 2026 07:06:19 -0600 Subject: [PATCH 11/16] Fix profile-aware assistant display names --- api/streaming.py | 19 ++++-- static/boot.js | 15 ++--- static/i18n.js | 44 ++++++------- static/index.html | 4 +- static/messages.js | 4 +- static/sessions.js | 2 +- static/ui.js | 11 +++- tests/test_issue1116_composer_placeholder.py | 66 ++++++++++++++----- tests/test_issue1361_cancel_data_loss.py | 12 ++++ ...est_issue1771_session_model_switch_sync.py | 1 + tests/test_korean_locale.py | 2 +- tests/test_sprint36.py | 2 +- 12 files changed, 120 insertions(+), 62 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index 5998bdbc..4beead24 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -247,6 +247,13 @@ def _preferred_agent_display_name() -> str: return name or 'Hermes' +def _preferred_agent_display_name_for_session(session) -> str: + profile = str(getattr(session, 'profile', '') or '').strip() + if profile and profile != 'default': + return profile[:1].upper() + profile[1:] + return _preferred_agent_display_name() + + def _cancelled_turn_hint(agent_name: str | None = None) -> str: name = str(agent_name or _preferred_agent_display_name()).strip() or 'Hermes' return f'The run was cancelled by the user before {name} finished. No provider failure occurred.' @@ -398,14 +405,14 @@ def _session_has_cancel_marker(session) -> bool: return False -def _cancelled_turn_content(message: str = 'Task cancelled.') -> str: +def _cancelled_turn_content(message: str = 'Task cancelled.', agent_name: str | None = None) -> str: """Return cancelled-turn copy matching the verbose provider-error layout.""" _message = str(message or 'Task cancelled.').strip() if not _message.endswith('.'): _message += '.' return ( f"**Task cancelled:** {_message}\n\n" - f"*{_cancelled_turn_hint()}*" + f"*{_cancelled_turn_hint(agent_name)}*" ) @@ -422,9 +429,10 @@ def _persist_cancelled_turn(session, *, message: str = 'Task cancelled.') -> Non session.pending_attachments = [] session.pending_started_at = None if not _session_has_cancel_marker(session): + agent_name = _preferred_agent_display_name_for_session(session) session.messages.append({ 'role': 'assistant', - 'content': _cancelled_turn_content(message), + 'content': _cancelled_turn_content(message, agent_name), '_error': True, 'provider_details': str(message or 'Task cancelled.').strip(), 'provider_details_label': 'Cancellation details', @@ -5555,7 +5563,10 @@ def cancel_stream(stream_id: str) -> bool: if not _cancel_marker_exists: _cs.messages.append({ 'role': 'assistant', - 'content': _cancelled_turn_content('Task cancelled.'), + 'content': _cancelled_turn_content( + 'Task cancelled.', + _preferred_agent_display_name_for_session(_cs), + ), '_error': True, 'provider_details': 'Task cancelled.', 'provider_details_label': 'Cancellation details', diff --git a/static/boot.js b/static/boot.js index 1db3bc6a..9a30fe8e 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1380,15 +1380,9 @@ function _buildSkinPicker(activeSkin){ } function applyBotName(){ - // Prefer profile name over global bot_name for personalised placeholder. - // If activeProfile is set and not 'default', use it (capitalised). - // Falls back to window._botName (global bot_name setting) or 'Hermes'. - let name; - if(S.activeProfile && S.activeProfile!=='default'){ - name=S.activeProfile.charAt(0).toUpperCase()+S.activeProfile.slice(1); - }else{ - name=window._botName||'Hermes'; - } + // The saved assistant name applies to the default profile only. + // Non-default profiles use their own profile names. + const name=assistantDisplayName(); document.title=name; const sidebarH1=document.querySelector('.sidebar-header h1'); if(sidebarH1) sidebarH1.textContent=name; @@ -1469,7 +1463,6 @@ function applyBotName(){ setLocale(_lang); if(typeof applyLocaleToDOM==='function')applyLocaleToDOM(); } - applyBotName(); // TTS: apply enabled state on boot so buttons show/hide correctly (#499) if(typeof _applyTtsEnabled==='function') _applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true'); }catch(e){ @@ -1497,7 +1490,6 @@ function applyBotName(){ setLocale(_lang); if(typeof applyLocaleToDOM==='function')applyLocaleToDOM(); } - applyBotName(); if(typeof _applyTtsEnabled==='function') _applyTtsEnabled(localStorage.getItem('hermes-tts-enabled')==='true'); } // Non-blocking update check (fire-and-forget, once per tab session) @@ -1509,6 +1501,7 @@ function applyBotName(){ } // Fetch active profile try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';} + applyBotName(); // Update profile chip label immediately const profileLabel=$('profileChipLabel'); if(profileLabel) profileLabel.textContent=S.activeProfile||'default'; diff --git a/static/i18n.js b/static/i18n.js index 05800281..3a797d93 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -554,7 +554,7 @@ const LOCALES = { settings_label_sync_insights: 'Sync to insights', settings_label_check_updates: 'Check for updates', settings_label_whats_new_summary: "Summarize What's New with AI", - settings_label_bot_name: 'Assistant Name', + settings_label_bot_name: 'Default assistant name', settings_label_password: 'Access Password', settings_saved: 'Settings saved', settings_save_failed: 'Save failed: ', @@ -793,7 +793,7 @@ const LOCALES = { settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.', settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", - settings_desc_bot_name: 'Display name for the assistant throughout the UI. Defaults to Hermes.', + settings_desc_bot_name: 'Used for the default profile only. Other profiles use their own profile names.', settings_desc_password: 'Enter a new password to set or change it. Leave blank to keep current setting.', password_placeholder: 'Enter new password…', password_env_var_locked: 'The HERMES_WEBUI_PASSWORD environment variable is currently set and takes precedence. Unset it and restart the server to manage the password from here.', @@ -1773,7 +1773,7 @@ const LOCALES = { settings_label_sync_insights: 'Sincronizza con insights', settings_label_check_updates: 'Verifica aggiornamenti', settings_label_whats_new_summary: "Summarize What's New with AI", - settings_label_bot_name: 'Nome Assistente', + settings_label_bot_name: 'Nome assistente predefinito', settings_label_password: 'Password di Accesso', settings_saved: 'Impostazioni salvate', settings_save_failed: 'Salvataggio fallito: ', @@ -2004,7 +2004,7 @@ const LOCALES = { settings_desc_sync_insights: 'Rispecchia l\'uso token WebUI su state.db così hermes /insights include i dati delle sessioni browser. Disattivato per impostazione predefinita.', settings_desc_check_updates: 'Mostra un banner quando sono disponibili versioni più recenti della WebUI o dell\'Agente. Esegue un git fetch in background periodicamente.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", - settings_desc_bot_name: 'Nome visualizzato per l\'assistente in tutta l\'interfaccia. Predefinito: Hermes.', + settings_desc_bot_name: 'Usato solo per il profilo predefinito. Gli altri profili usano i propri nomi.', settings_desc_password: 'Inserisci una nuova password per impostarla o cambiarla. Lascia vuoto per mantenere l\'impostazione attuale.', password_placeholder: 'Inserisci nuova password…', password_env_var_locked: 'La variabile d\'ambiente HERMES_WEBUI_PASSWORD è attualmente impostata e ha la precedenza. Rimuovila e riavvia il server per gestire la password da qui.', @@ -2984,7 +2984,7 @@ const LOCALES = { settings_label_sync_insights: 'インサイトに同期', settings_label_check_updates: 'アップデートを確認', settings_label_whats_new_summary: "Summarize What's New with AI", - settings_label_bot_name: 'アシスタント名', + settings_label_bot_name: 'デフォルトのアシスタント名', settings_label_password: 'アクセスパスワード', settings_saved: '設定を保存しました', settings_save_failed: '保存失敗: ', @@ -3220,7 +3220,7 @@ const LOCALES = { settings_desc_sync_insights: 'WebUI のトークン使用量を state.db にミラーし、hermes /insights にブラウザセッションのデータを含めます。デフォルトはオフ。', settings_desc_check_updates: 'WebUI または Agent の新しいバージョンが利用可能な時にバナーを表示します。バックグラウンドで定期的に git fetch を実行します。', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", - settings_desc_bot_name: 'UI 全体で表示されるアシスタントの名前。デフォルトは Hermes。', + settings_desc_bot_name: 'デフォルトプロファイルでのみ使用されます。他のプロファイルはそれぞれのプロファイル名を使用します。', settings_desc_password: '新しいパスワードを入力すると設定または変更します。空欄なら現在の設定を維持。', password_placeholder: '新しいパスワードを入力…', password_env_var_locked: '現在 HERMES_WEBUI_PASSWORD 環境変数が設定されており優先されます。ここで管理するには変数を解除してサーバーを再起動してください。', @@ -4006,7 +4006,7 @@ const LOCALES = { settings_label_sync_insights: 'Синхронизировать с Insights', settings_label_check_updates: 'Проверять обновления', settings_label_whats_new_summary: "Summarize What's New with AI", - settings_label_bot_name: 'Имя помощника', + settings_label_bot_name: 'Имя помощника по умолчанию', settings_label_password: 'Пароль доступа', settings_saved: 'Настройки сохранены', settings_save_failed: 'Не удалось сохранить: ', @@ -4191,7 +4191,7 @@ const LOCALES = { settings_desc_sync_insights: 'Синхронизирует использование токенов WebUI в state.db, чтобы Hermes /insights включал данные браузерных сеансов. Выключено по умолчанию.', settings_desc_check_updates: 'Показывает баннер, когда доступны более новые версии WebUI или Agent. Периодически выполняет git fetch в фоне.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", - settings_desc_bot_name: 'Отображаемое имя помощника во всём интерфейсе. По умолчанию Hermes.', + settings_desc_bot_name: 'Используется только для профиля по умолчанию. Другие профили используют свои имена.', settings_desc_password: 'Введите новый пароль, чтобы задать или изменить его. Оставьте пустым, чтобы сохранить текущую настройку.', password_placeholder: 'Введите новый пароль…', password_env_var_locked: 'Переменная окружения HERMES_WEBUI_PASSWORD сейчас задана и имеет приоритет. Сбросьте её и перезапустите сервер, чтобы управлять паролем отсюда.', @@ -5146,7 +5146,7 @@ const LOCALES = { settings_label_sync_insights: 'Sincronizar con insights', settings_label_check_updates: 'Buscar actualizaciones', settings_label_whats_new_summary: "Summarize What's New with AI", - settings_label_bot_name: 'Nombre del asistente', + settings_label_bot_name: 'Nombre predeterminado del asistente', settings_label_password: 'Contraseña de acceso', settings_saved: 'Configuración guardada', settings_save_failed: 'Error al guardar: ', @@ -5342,7 +5342,7 @@ const LOCALES = { settings_desc_sync_insights: 'Refleja el uso de tokens de la WebUI en state.db para que hermes /insights incluya datos de sesiones del navegador. Desactivado por defecto.', settings_desc_check_updates: 'Muestra un banner cuando haya versiones más nuevas de la WebUI o del Agent. Ejecuta periódicamente un git fetch en segundo plano.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", - settings_desc_bot_name: 'Nombre visible del asistente en toda la UI. Por defecto es Hermes.', + settings_desc_bot_name: 'Solo se usa para el perfil predeterminado. Los otros perfiles usan sus propios nombres.', settings_desc_password: 'Introduce una nueva contraseña para establecerla o cambiarla. Déjalo en blanco para mantener la configuración actual.', password_placeholder: 'Introduce una contraseña nueva…', password_env_var_locked: 'La variable de entorno HERMES_WEBUI_PASSWORD está definida y tiene prioridad. Quítala y reinicia el servidor para gestionar la contraseña desde aquí.', @@ -6279,7 +6279,7 @@ const LOCALES = { settings_label_sync_insights: 'Mit Insights synchronisieren', settings_label_check_updates: 'Nach Updates suchen', settings_label_whats_new_summary: "Summarize What's New with AI", - settings_label_bot_name: 'Assistenten-Name', + settings_label_bot_name: 'Standard-Assistentenname', settings_label_password: 'Zugangspasswort', settings_saved: 'Einstellungen gespeichert', settings_save_failed: 'Speichern fehlgeschlagen: ', @@ -6465,7 +6465,7 @@ const LOCALES = { settings_desc_sync_insights: 'Spiegelt den WebUI-Token-Verbrauch in die state.db, sodass hermes /insights Browser-Sitzungsdaten enthält. Standardmäßig aus.', settings_desc_check_updates: 'Zeigt ein Banner an, wenn neuere Versionen der WebUI oder des Agenten verfügbar sind. Führt regelmäßig einen Git-Fetch im Hintergrund aus.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", - settings_desc_bot_name: 'Anzeigename für den Assistenten in der UI. Standardmäßig Hermes.', + settings_desc_bot_name: 'Wird nur für das Standardprofil verwendet. Andere Profile verwenden ihre eigenen Namen.', settings_desc_password: 'Geben Sie ein neues Passwort ein, um es zu setzen oder zu ändern. Leer lassen, um die aktuelle Einstellung beizubehalten.', password_placeholder: 'Neues Passwort eingeben…', password_env_var_locked: 'Die Umgebungsvariable HERMES_WEBUI_PASSWORD ist gesetzt und hat Vorrang. Entferne sie und starte den Server neu, um das Passwort hier zu verwalten.', @@ -7462,7 +7462,7 @@ const LOCALES = { settings_label_sync_insights: '同步到 insights', settings_label_check_updates: '检查更新', settings_label_whats_new_summary: "Summarize What's New with AI", - settings_label_bot_name: '助手名称', + settings_label_bot_name: '默认助手名称', settings_label_password: '访问密码', settings_saved: '设置已保存', settings_save_failed: '保存失败:', @@ -7721,7 +7721,7 @@ const LOCALES = { settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db,使 hermes /insights 包含浏览器会话数据。默认关闭。', settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", - settings_desc_bot_name: '助手在 UI 中的显示名称。默认为 Hermes。', + settings_desc_bot_name: '仅用于默认个人资料。其他个人资料会使用各自的名称。', settings_desc_password: '输入新密码以设置或更改。留空保持当前设置。', // onboarding onboarding_badge: '首次运行', @@ -8623,7 +8623,7 @@ const LOCALES = { settings_label_sync_insights: '\u540c\u6b65\u5230 insights', settings_label_check_updates: '\u6aa2\u67e5\u66f4\u65b0', settings_label_whats_new_summary: "Summarize What's New with AI", - settings_label_bot_name: '\u52a9\u624b\u540d\u7a31', + settings_label_bot_name: '預設助手名稱', settings_label_password: '\u8a2a\u554f\u5bc6\u78bc', settings_saved: '\u8a2d\u5b9a\u5df2\u5132\u5b58', settings_save_failed: '\u5132\u5b58\u5931\u6557\uff1a', @@ -8806,7 +8806,7 @@ const LOCALES = { settings_desc_sync_insights: '將 WebUI token 使用情況同步到 state.db,使 hermes /insights 包含瀏覽器會話數據。預設未啟用。', settings_desc_check_updates: '當有更新的 WebUI 或助手版本時顯示標記。將在後台正常執行 Git-Fetch。', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", - settings_desc_bot_name: '助手在 UI 中的顯示名稱。預設未更改。', + settings_desc_bot_name: '僅用於預設個人檔案。其他個人檔案會使用各自的名稱。', settings_desc_password: '\u8a2d\u5b9a WebUI \u767b\u5165\u5bc6\u78bc\u3002\u5047\u5982\u5df2\u8a2d\u7f6e\uff0c\u6bcf\u6b21\u52a0\u8f09\u90fd\u9700\u8981\u767b\u5165\u3002', onboarding_password_will_enable: '\u5c07\u6703\u555f\u7528', onboarding_password_will_replace: '\u5c07\u6703\u53d6\u4ee3', @@ -9922,7 +9922,7 @@ const LOCALES = { settings_label_sync_insights: 'Sincronizar para insights', settings_label_check_updates: 'Verificar atualizações', settings_label_whats_new_summary: "Summarize What's New with AI", - settings_label_bot_name: 'Nome do Assistente', + settings_label_bot_name: 'Nome padrão do assistente', settings_label_password: 'Senha de Acesso', settings_saved: 'Configurações salvas', settings_save_failed: 'Falha ao salvar: ', @@ -10111,7 +10111,7 @@ const LOCALES = { settings_desc_sync_insights: 'Espelha uso de tokens para state.db.', settings_desc_check_updates: 'Mostrar banner quando versões mais novas estiverem disponíveis.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", - settings_desc_bot_name: 'Nome de exibição do assistente. Padrão: Hermes.', + settings_desc_bot_name: 'Usado apenas para o perfil padrão. Outros perfis usam seus próprios nomes.', settings_desc_password: 'Digite nova senha para definir ou trocar. Deixe em branco para manter.', password_placeholder: 'Digite nova senha…', password_env_var_locked: 'A variável de ambiente HERMES_WEBUI_PASSWORD está definida e tem prioridade. Remova-a e reinicie o servidor para gerenciar a senha aqui.', @@ -11036,7 +11036,7 @@ const LOCALES = { settings_label_sync_insights: 'Insights에 동기화', settings_label_check_updates: '업데이트 확인', settings_label_whats_new_summary: "Summarize What's New with AI", - settings_label_bot_name: 'Assistant 이름', + settings_label_bot_name: '기본 Assistant 이름', settings_label_password: '접근 비밀번호', settings_saved: '설정 저장됨', settings_save_failed: '설정 저장 실패: ', @@ -11224,7 +11224,7 @@ const LOCALES = { settings_desc_sync_insights: 'WebUI 토큰 사용량을 state.db에 반영하여 hermes /insights에 브라우저 세션 데이터가 포함되도록 합니다. 기본값은 꺼짐입니다.', settings_desc_check_updates: 'WebUI 또는 Agent의 새 버전이 있으면 배너를 표시합니다. 백그라운드에서 주기적으로 git fetch를 실행합니다.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", - settings_desc_bot_name: 'UI 전체에 표시되는 Assistant 이름입니다. 기본값은 Hermes입니다.', + settings_desc_bot_name: '기본 프로필에만 사용됩니다. 다른 프로필은 각 프로필 이름을 사용합니다.', settings_desc_password: '새 비밀번호를 설정하거나 변경하려면 입력하세요. 현재 설정을 유지하려면 비워 두세요.', password_placeholder: '새 비밀번호 입력…', password_env_var_locked: '현재 HERMES_WEBUI_PASSWORD 환경 변수가 설정되어 있어 우선 적용됩니다. 변수를 해제하고 서버를 재시작해야 여기에서 비밀번호를 관리할 수 있습니다.', @@ -12167,7 +12167,7 @@ const LOCALES = { settings_label_sync_insights: 'Synchroniser avec les insights', settings_label_check_updates: 'Vérifier les mises à jour', settings_label_whats_new_summary: "Summarize What's New with AI", - settings_label_bot_name: 'Nom de l\'assistant', + settings_label_bot_name: 'Nom par défaut de l\'assistant', settings_label_password: 'Mot de passe d\'accès', settings_saved: 'Paramètres enregistrés', settings_save_failed: 'Échec de l\'enregistrement :', @@ -12365,7 +12365,7 @@ const LOCALES = { settings_desc_sync_insights: 'Met en miroir l\'utilisation du jeton WebUI dans state.db afin que Hermes /insights inclut les données de session du navigateur. Désactivé par défaut.', settings_desc_check_updates: 'Afficher une bannière lorsque des versions plus récentes de WebUI ou de l\'agent sont disponibles. Exécute périodiquement une récupération git en arrière-plan.', settings_desc_whats_new_summary: "Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.", - settings_desc_bot_name: 'Nom d’affichage de l’assistant dans l’interface utilisateur. Par défaut, Hermès.', + settings_desc_bot_name: 'Utilisé uniquement pour le profil par défaut. Les autres profils utilisent leurs propres noms.', settings_desc_password: 'Saisissez un nouveau mot de passe pour le définir ou le modifier. Laissez vide pour conserver le paramètre actuel.', password_placeholder: 'Entrez le nouveau mot de passe…', password_env_var_locked: 'La variable d\'environnement HERMES_WEBUI_PASSWORD est actuellement définie et est prioritaire. Désactivez-le et redémarrez le serveur pour gérer le mot de passe à partir d\'ici.', diff --git a/static/index.html b/static/index.html index fb72a251..5f1cca5a 100644 --- a/static/index.html +++ b/static/index.html @@ -1164,8 +1164,8 @@
Changes the What's New action from opening the raw diff first to generating a short, human-readable summary. The regular diff comparison stays available after the summary.
- -
Display name for the assistant throughout the UI. Defaults to Hermes.
+ +
Used for the default profile only. Other profiles use their own profile names.
diff --git a/static/messages.js b/static/messages.js index c72e8111..b53933ff 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1989,7 +1989,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // Fallback to local cancel message if API fails if(S.session&&S.session.session_id===activeSid){ clearLiveToolCards();if(!assistantText)removeThinking(); - const cancelAgentName=((window._botName||'Hermes')+'').trim()||'Hermes'; + const cancelAgentName=(assistantDisplayName()+'').trim()||'Hermes'; S.messages.push({role:'assistant',content:`**Task cancelled:** Task cancelled.\n\n*The run was cancelled by the user before ${cancelAgentName} finished. No provider failure occurred.*`,provider_details:'Task cancelled.',provider_details_label:'Cancellation details',_error:true});renderMessages({preserveScroll:true}); _markSessionViewed(activeSid, S.messages.length); } @@ -2855,7 +2855,7 @@ function playNotificationSound(){ function sendBrowserNotification(title,body){ if(!window._notificationsEnabled||!document.hidden) return; if(!('Notification' in window)) return; - const botName=window._botName||'Hermes'; + const botName=assistantDisplayName(); if(Notification.permission==='granted'){ new Notification(title||botName,{body:body}); }else if(Notification.permission!=='denied'){ diff --git a/static/sessions.js b/static/sessions.js index 613a0c77..4cb8da89 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3539,7 +3539,7 @@ async function deleteSession(sid){ if(remaining.sessions&&remaining.sessions.length){ await loadSession(remaining.sessions[0].session_id); }else{ - const _tt=$('topbarTitle');if(_tt)_tt.textContent=window._botName||'Hermes'; + const _tt=$('topbarTitle');if(_tt)_tt.textContent=assistantDisplayName(); const _tm=$('topbarMeta');if(_tm)_tm.textContent='Start a new conversation'; $('msgInner').innerHTML=''; $('emptyState').style.display=''; diff --git a/static/ui.js b/static/ui.js index 5ab149f7..260ddd9c 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1,4 +1,9 @@ const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default',showHiddenWorkspaceFiles:false}; + +function assistantDisplayName(){ + if(S.activeProfile&&S.activeProfile!=='default') return S.activeProfile.charAt(0).toUpperCase()+S.activeProfile.slice(1); + return window._botName||'Hermes'; +} const INFLIGHT={}; // keyed by session_id while request in-flight const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns const MAX_UPLOAD_BYTES=(window.__HERMES_CONFIG__&&window.__HERMES_CONFIG__.maxUploadBytes)||20*1024*1024; @@ -4663,7 +4668,7 @@ async function checkInflightOnBoot(sid) { function syncTopbar(){ if(!S.session){ - document.title=window._botName||'Hermes'; + document.title=assistantDisplayName(); if(typeof syncWorkspaceDisplays==='function') syncWorkspaceDisplays(); if(typeof _syncWorkspaceHeadingState==='function') _syncWorkspaceHeadingState(); if(typeof syncModelChip==='function') syncModelChip(); @@ -4683,7 +4688,7 @@ function syncTopbar(){ } const sessionTitle=S.session.title||t('untitled'); const _topbarTitle=$('topbarTitle');if(_topbarTitle)_topbarTitle.textContent=sessionTitle; - document.title=sessionTitle+' \u2014 '+(window._botName||'Hermes'); + document.title=sessionTitle+' \u2014 '+assistantDisplayName(); const vis=S.messages.filter(m=>m&&m.role&&m.role!=='tool'); const _topbarMeta=$('topbarMeta'); if(_topbarMeta){ @@ -4815,7 +4820,7 @@ function isTpsDisplayEnabled(){ return window._showTps===true; } function _assistantRoleHtml(tsTitle='', tpsText=''){ - const _bn=window._botName||'Hermes'; + const _bn=assistantDisplayName(); const tps=(isTpsDisplayEnabled()&&tpsText)?`${esc(tpsText)}`:''; return `
${esc(_bn.charAt(0).toUpperCase())}
${esc(_bn)}${tps}
`; } diff --git a/tests/test_issue1116_composer_placeholder.py b/tests/test_issue1116_composer_placeholder.py index d62d4a9a..10f77d41 100644 --- a/tests/test_issue1116_composer_placeholder.py +++ b/tests/test_issue1116_composer_placeholder.py @@ -11,31 +11,67 @@ class TestComposerPlaceholderProfile: """applyBotName() should use the profile name when activeProfile is set.""" def test_applyBotName_uses_profile_name(self): - """applyBotName must check S.activeProfile and prefer it over global bot_name.""" + """Non-default profiles must use the profile name instead of bot_name.""" src = _src("boot.js") - assert "S.activeProfile" in src, \ - "applyBotName must reference S.activeProfile" - # Should fall back to _botName when activeProfile is 'default' - assert "S.activeProfile!=='default'" in src, \ - "applyBotName must skip 'default' profile (use bot_name instead)" + ui_src = _src("ui.js") + assert "function assistantDisplayName()" in ui_src, \ + "assistant display name resolution should be shared" + assert "S.activeProfile&&S.activeProfile!=='default'" in ui_src, \ + "assistantDisplayName must only treat the literal default profile as renamed by bot_name" + assert "assistantDisplayName()" in src, \ + "applyBotName must use the shared profile-aware display name" def test_applyBotName_capitalises_profile_name(self): """Profile name should be capitalised (first letter uppercase).""" - src = _src("boot.js") - m = re.search(r'function applyBotName\(\)\{.*?\n\}', src, re.DOTALL) - assert m, "applyBotName function must exist" + src = _src("ui.js") + m = re.search(r'function assistantDisplayName\(\)\{.*?\n\}', src, re.DOTALL) + assert m, "assistantDisplayName function must exist" body = m.group(0) assert "charAt(0).toUpperCase()" in body, \ - "applyBotName must capitalise first letter of profile name" + "assistantDisplayName must capitalise first letter of profile name" def test_applyBotName_falls_back_to_bot_name(self): - """When no active profile, must fall back to window._botName.""" - src = _src("boot.js") - m = re.search(r'function applyBotName\(\)\{.*?\n\}', src, re.DOTALL) - assert m, "applyBotName function must exist" + """The saved assistant name applies to the default profile.""" + src = _src("ui.js") + m = re.search(r'function assistantDisplayName\(\)\{.*?\n\}', src, re.DOTALL) + assert m, "assistantDisplayName function must exist" body = m.group(0) assert "window._botName||'Hermes'" in body, \ - "applyBotName must fall back to window._botName or 'Hermes'" + "assistantDisplayName must use window._botName or 'Hermes' for the default profile" + + def test_chat_surfaces_use_shared_assistant_display_name(self): + """Chat rows, titles, notifications, and cancel copy must honor profile overrides.""" + ui_src = _src("ui.js") + messages_src = _src("messages.js") + sessions_src = _src("sessions.js") + assert "document.title=assistantDisplayName();" in ui_src + assert "document.title=sessionTitle+' \\u2014 '+assistantDisplayName();" in ui_src + assert "const _bn=assistantDisplayName();" in ui_src + assert "assistantDisplayName()" in messages_src + assert "assistantDisplayName()" in sessions_src + + def test_boot_applies_placeholder_after_active_profile_loads(self): + """Boot must set the composer placeholder after S.activeProfile is known.""" + src = _src("boot.js") + fetch_idx = src.find("api('/api/profile/active')") + assert fetch_idx >= 0, "boot.js should fetch the active profile during boot" + label_idx = src.find("const profileLabel=$('profileChipLabel');", fetch_idx) + assert label_idx >= 0, "profile chip sync should follow active profile fetch" + assert "applyBotName();" in src[fetch_idx:label_idx], ( + "boot should apply the profile-aware assistant name after active profile resolution" + ) + + def test_settings_copy_names_default_assistant_scope(self): + """The preference copy must say that only the default profile is renamed.""" + index_src = _src("index.html") + i18n_src = _src("i18n.js") + assert "Default assistant name" in index_src + assert "Used for the default profile only. Other profiles use their own profile names." in index_src + assert "settings_label_bot_name: 'Default assistant name'" in i18n_src + assert ( + "settings_desc_bot_name: 'Used for the default profile only. " + "Other profiles use their own profile names.'" + ) in i18n_src def test_switchToProfile_calls_applyBotName(self): """switchToProfile() must call applyBotName() after switching.""" diff --git a/tests/test_issue1361_cancel_data_loss.py b/tests/test_issue1361_cancel_data_loss.py index f6099236..dbd59cea 100644 --- a/tests/test_issue1361_cancel_data_loss.py +++ b/tests/test_issue1361_cancel_data_loss.py @@ -509,6 +509,18 @@ def test_cancel_copy_uses_configured_bot_name(monkeypatch): ) +def test_cancel_copy_uses_profile_name_for_non_default_profile(monkeypatch): + """Persisted cancellation copy should use profile names outside literal default.""" + import api.streaming as streaming + + monkeypatch.setattr(streaming, 'load_settings', lambda: {'bot_name': 'Obryn'}) + + session = type('Session', (), {'profile': 'research'})() + name = streaming._preferred_agent_display_name_for_session(session) + assert name == 'Research' + assert 'before Research finished' in streaming._cancelled_turn_content(agent_name=name) + + def test_cancel_copy_falls_back_to_hermes_for_blank_bot_name(monkeypatch): """Blank or missing bot_name should not leak old persona copy.""" import api.streaming as streaming diff --git a/tests/test_issue1771_session_model_switch_sync.py b/tests/test_issue1771_session_model_switch_sync.py index 4a028458..66a97b94 100644 --- a/tests/test_issue1771_session_model_switch_sync.py +++ b/tests/test_issue1771_session_model_switch_sync.py @@ -98,6 +98,7 @@ const window = { _botName: 'Hermes', _defaultModel: null, _activeProvider: null function fetch(url, opts) { calls.fetches.push({url: String(url), body: opts && opts.body || ''}); return Promise.resolve({ok: true}); } for (const name of [ + 'assistantDisplayName', '_getOptionProviderId', '_providerFromModelValue', '_modelStateForSelect', '_findModelInDropdown', '_refreshOpenModelDropdown', '_applyModelToDropdown', '_modelStateFromAppliedDropdown', '_persistSessionModelCorrection', diff --git a/tests/test_korean_locale.py b/tests/test_korean_locale.py index 750f05b3..35004d45 100644 --- a/tests/test_korean_locale.py +++ b/tests/test_korean_locale.py @@ -113,7 +113,7 @@ def test_korean_settings_detail_descriptions_are_translated(): "settings_desc_external_sessions: 'CLI, Telegram, Discord, Slack 및 기타 채널의 대화를 세션 목록에 표시합니다. 클릭하여 가져오고 계속하세요.'", "settings_desc_sync_insights: 'WebUI 토큰 사용량을 state.db에 반영하여 hermes /insights에 브라우저 세션 데이터가 포함되도록 합니다. 기본값은 꺼짐입니다.'", "settings_desc_check_updates: 'WebUI 또는 Agent의 새 버전이 있으면 배너를 표시합니다. 백그라운드에서 주기적으로 git fetch를 실행합니다.'", - "settings_desc_bot_name: 'UI 전체에 표시되는 Assistant 이름입니다. 기본값은 Hermes입니다.'", + "settings_desc_bot_name: '기본 프로필에만 사용됩니다. 다른 프로필은 각 프로필 이름을 사용합니다.'", "settings_desc_password: '새 비밀번호를 설정하거나 변경하려면 입력하세요. 현재 설정을 유지하려면 비워 두세요.'", ] for entry in expected: diff --git a/tests/test_sprint36.py b/tests/test_sprint36.py index e9317fad..97b5d6fe 100644 --- a/tests/test_sprint36.py +++ b/tests/test_sprint36.py @@ -218,7 +218,7 @@ def test_cancel_marker_flagged_as_error_to_skip_in_api_history(): persisting the marker to the session. """ src = read("api/streaming.py") - idx = src.find("'content': _cancelled_turn_content(message)") + idx = src.find("'content': _cancelled_turn_content(message") assert idx != -1, "cancel marker content writer not found in cancel_stream()" # Walk back to the start of the dict literal (opening brace) From 646f18c696c7dd76302ee64431ac15d66e0c2e57 Mon Sep 17 00:00:00 2001 From: Florian Krause Date: Tue, 19 May 2026 15:50:12 +0200 Subject: [PATCH 12/16] fix: prevent queued follow-up message from draining into wrong chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a queued message was waiting for the active stream to finish, the 120ms setTimeout drain in setBusy(false) would write the queued text to the shared #msg composer and call send(), which reads S.session.session_id at call time. If the user switched to a different chat during the 120ms window, the queued message was sent to the wrong session. Two fixes: 1. setBusy(false) drain: guard the setTimeout callback — if the currently viewed session no longer matches the drain session, put the message back into the original session's queue instead of sending it. 2. _sendInProgress re-queue: track _sendInProgressSid alongside _sendInProgress so that when a concurrent send() is caught by the guard, the re-queued message targets the in-flight session rather than the currently viewed one. --- static/messages.js | 17 +++++++++++------ static/ui.js | 10 ++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/static/messages.js b/static/messages.js index c72e8111..a4e7f076 100644 --- a/static/messages.js +++ b/static/messages.js @@ -187,6 +187,7 @@ if(typeof document!=='undefined'){ // (e.g. queue drain + user click) can both pass the S.busy check because // setBusy(true) is only called after the first await inside send(). let _sendInProgress = false; +let _sendInProgressSid = null; // session_id of the in-flight send const _sessionTitleProvisionalBySid = new Map(); function _sessionTitleLooksDefaultOrProvisional(titleText, provisionalText){ @@ -236,11 +237,14 @@ async function send(){ // instead of silently dropping it. if (_sendInProgress) { const _text=$('msg').value.trim(); - if(_text && S.session && S.session.session_id){ - queueSessionMessage(S.session.session_id,{text:_text,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',model_provider:S.session&&S.session.model_provider||null,profile:S.activeProfile||'default'}); + // Use the in-flight session's sid, not the currently viewed session, + // so the queued message goes to the chat that owns the active stream. + const _targetSid=_sendInProgressSid||(S.session&&S.session.session_id); + if(_text && _targetSid){ + queueSessionMessage(_targetSid,{text:_text,files:[...S.pendingFiles],model:S.session&&S.session.model||($('modelSelect')&&$('modelSelect').value)||'',model_provider:S.session&&S.session.model_provider||null,profile:S.activeProfile||'default'}); $('msg').value='';autoResize(); S.pendingFiles=[];renderTray(); - updateQueueBadge(S.session.session_id); + updateQueueBadge(_targetSid); showToast(`Queued: "${_text.slice(0,40)}${_text.length>40?'…':''}"`,2000); } return; @@ -248,9 +252,9 @@ async function send(){ _sendInProgress = true; try{ const text=$('msg').value.trim(); - if(!text&&!S.pendingFiles.length)return; + if(!text&&!S.pendingFiles.length){_sendInProgress=false;_sendInProgressSid=null;return;} // Don't send while an inline message edit is active - if(document.querySelector('.msg-edit-area'))return; + if(document.querySelector('.msg-edit-area')){_sendInProgress=false;_sendInProgressSid=null;return;} // Dismiss handoff hint when user sends a message (resets seen_at). if(S.session&&S.session.session_id&&typeof _dismissHandoffHint==='function'){ @@ -380,6 +384,7 @@ async function send(){ if(!S.session){await newSession();await renderSessionList();} const activeSid=S.session.session_id; + _sendInProgressSid=activeSid; setComposerStatus(S.pendingFiles&&S.pendingFiles.length?'Uploading…':''); let uploaded=[]; @@ -528,7 +533,7 @@ async function send(){ // Open SSE stream and render tokens live attachLiveStream(activeSid, streamId, uploadedNames); - }finally{ _sendInProgress=false; } + }finally{ _sendInProgress=false; _sendInProgressSid=null; } } const LIVE_STREAMS={}; diff --git a/static/ui.js b/static/ui.js index 5ab149f7..192fe7a6 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3266,6 +3266,16 @@ function setBusy(v){ if(next){ updateQueueBadge(sid); setTimeout(()=>{ + // Guard: if the user switched away from the drain session during + // the 120ms settle window, the queued message must NOT go to the + // wrong chat. Put it back into the original session's queue and + // skip sending — it will drain when the user returns to that session + // or when its next stream completes while it is the active view. + if(S.session&&S.session.session_id!==sid){ + queueSessionMessage(sid,next); + updateQueueBadge(sid); + return; + } $('msg').value=next.text||''; S.pendingFiles=Array.isArray(next.files)?[...next.files]:[]; // Restore model from queued item (sent in /api/chat/start payload) From f93e288214662288a62fccde99ee39eff5f0862d Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Tue, 19 May 2026 10:26:45 -0400 Subject: [PATCH 13/16] Fix stale stream recovery writeback race --- CHANGELOG.md | 3 ++ api/streaming.py | 68 +++++++++++++++++++++++++--- tests/test_session_sidecar_repair.py | 46 +++++++++++++++++++ 3 files changed, 110 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 941c61ea..4cd3ca8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Fixed + +- Allow a still-running stream that was mistakenly marked interrupted by stale-pending recovery to replace its own recovery marker when it later finishes, while continuing to block stale writeback after any newer turn appends transcript content. ## [v0.51.92] — 2026-05-19 — Release BP (stage-385 — 7-PR full sweep batch — RFC Slice 3c clarification + workspace tree icon alignment + project move cache refresh + auto-compression handoff metadata + Grok OAuth provider catalog + anonymous custom endpoint picker fallback + PWA standalone reload + pull-to-refresh) diff --git a/api/streaming.py b/api/streaming.py index 5998bdbc..e02d4abf 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2358,6 +2358,53 @@ def _stream_writeback_is_current(session, stream_id): return bool(stream_id) and getattr(session, 'active_stream_id', None) == stream_id +def _stream_writeback_can_supersede_recovery_marker(session, msg_text): + """Allow a finishing worker to replace its own stale-repair marker. + + The stale-pending repair path can occasionally run while the original worker + is still alive but temporarily missing from the in-memory stream registry. It + clears ``active_stream_id`` and appends a "Response interrupted" marker. If + the original worker later finishes, treating ``active_stream_id is None`` as + stale drops the real answer and leaves the misleading marker visible. + + This is intentionally narrow: only a session with no active/pending turn and + whose last visible row is the recovery marker for this exact user prompt may + be superseded. If a newer turn has appended anything after the marker, the + normal stale-writeback guard still wins. + """ + if getattr(session, 'active_stream_id', None): + return False + if getattr(session, 'pending_user_message', None): + return False + if getattr(session, 'pending_attachments', None): + return False + messages = list(getattr(session, 'messages', None) or []) + if len(messages) < 2: + return False + last = messages[-1] + if not isinstance(last, dict) or not last.get('_error'): + return False + if last.get('type') != 'interrupted': + return False + content = str(last.get('content') or '') + if 'Response interrupted' not in content or 'WebUI process restarted' not in content: + return False + + expected = ' '.join(str(msg_text or '').split()) + if not expected: + return False + for msg in reversed(messages[:-1]): + if not isinstance(msg, dict): + continue + if msg.get('_error'): + continue + if msg.get('role') != 'user': + continue + actual = ' '.join(str(msg.get('content') or '').split()) + return actual == expected + return False + + def _merge_display_messages_after_agent_result(previous_display, previous_context, result_messages, msg_text): """Keep UI transcript durable while allowing model context to compact. @@ -4083,13 +4130,20 @@ def _run_agent_streaming( return with _agent_lock: if not ephemeral and not _stream_writeback_is_current(s, stream_id): - logger.info( - "Skipping stale stream writeback for session %s stream %s; active_stream_id=%s", - getattr(s, 'session_id', session_id), - stream_id, - getattr(s, 'active_stream_id', None), - ) - return + if _stream_writeback_can_supersede_recovery_marker(s, msg_text): + logger.info( + "Superseding stale recovery marker for session %s stream %s", + getattr(s, 'session_id', session_id), + stream_id, + ) + else: + logger.info( + "Skipping stale stream writeback for session %s stream %s; active_stream_id=%s", + getattr(s, 'session_id', session_id), + stream_id, + getattr(s, 'active_stream_id', None), + ) + return _result_messages = result.get('messages') or _previous_context_messages if cancel_event.is_set(): _finalize_cancelled_turn(s, ephemeral=False) diff --git a/tests/test_session_sidecar_repair.py b/tests/test_session_sidecar_repair.py index 900990b3..ca2bda45 100644 --- a/tests/test_session_sidecar_repair.py +++ b/tests/test_session_sidecar_repair.py @@ -808,6 +808,52 @@ class TestNonEmptyMessagesPendingCleared: assert s.pending_user_message is None assert s.active_stream_id is None + def test_finished_worker_can_supersede_its_own_interrupted_marker(self): + """A live worker that finishes after stale repair should be allowed to + replace the recovery marker for the same user turn.""" + s = _make_session( + messages=[ + {"role": "user", "content": "deploy"}, + models._interrupted_recovery_marker(), + ] + ) + s.active_stream_id = None + s.pending_user_message = None + s.pending_attachments = [] + + assert streaming._stream_writeback_can_supersede_recovery_marker(s, "deploy") + + def test_finished_worker_does_not_supersede_after_newer_turn_appended(self): + """Once a follow-up turn changes the visible tail, stale writeback stays + blocked so old workers cannot overwrite newer transcript state.""" + s = _make_session( + messages=[ + {"role": "user", "content": "deploy"}, + models._interrupted_recovery_marker(), + {"role": "user", "content": "what happened?"}, + {"role": "assistant", "content": "I checked the deployment status."}, + ] + ) + s.active_stream_id = None + s.pending_user_message = None + s.pending_attachments = [] + + assert not streaming._stream_writeback_can_supersede_recovery_marker(s, "deploy") + + def test_finished_worker_does_not_supersede_different_user_turn(self): + """The supersede path is tied to the pending prompt that was repaired.""" + s = _make_session( + messages=[ + {"role": "user", "content": "deploy"}, + models._interrupted_recovery_marker(), + ] + ) + s.active_stream_id = None + s.pending_user_message = None + s.pending_attachments = [] + + assert not streaming._stream_writeback_can_supersede_recovery_marker(s, "ship it") + def test_core_sync_branch_does_not_duplicate_journal_output_already_in_core( self, hermes_home, monkeypatch ): From a8d429775c6aa12e63e5614b6a4e7ac27c94d9c6 Mon Sep 17 00:00:00 2001 From: Lumen Yang Date: Tue, 19 May 2026 14:32:36 +0000 Subject: [PATCH 14/16] fix(webui): preserve casual chat compaction guard --- api/streaming.py | 23 +++++------ tests/test_issue1217_transcript_compaction.py | 38 ++++++++++++++++++- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index a509261c..8556cda0 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2332,21 +2332,22 @@ def _has_task_resume_compaction_marker(messages): return False +def _new_turn_context_from_messages(messages, msg_text): + """Return provider-facing history for a new user turn from a message list.""" + history = _drop_checkpointed_current_user_from_context(messages, msg_text) + if _is_casual_fresh_chat_message(msg_text) and _has_task_resume_compaction_marker(history): + return [] + return history + + def _context_messages_for_new_turn(session, msg_text): """Return provider-facing history for a new user turn. Compacted agent sessions can carry a hidden "resume the active task" summary - long after the visible UI looks like normal chat. A short greeting should - not silently reactivate that old task; explicit continuation prompts still - keep the full compacted context. + in context_messages. If the user starts a fresh casual greeting in that old + session, do not feed that stale active-task summary back to the model. """ - history = _drop_checkpointed_current_user_from_context( - _session_context_messages(session), - msg_text, - ) - if _is_casual_fresh_chat_message(msg_text) and _has_task_resume_compaction_marker(history): - return [] - return history + return _new_turn_context_from_messages(_session_context_messages(session), msg_text) def _stream_writeback_is_current(session, stream_id): @@ -3965,7 +3966,7 @@ def _run_agent_streaming( state_messages=_external_state_messages, ) or [] ) - _previous_context_messages = _drop_checkpointed_current_user_from_context( + _previous_context_messages = _new_turn_context_from_messages( reconciled_state_db_messages_for_session( s, prefer_context=True, diff --git a/tests/test_issue1217_transcript_compaction.py b/tests/test_issue1217_transcript_compaction.py index 711d4131..c7ebe4bd 100644 --- a/tests/test_issue1217_transcript_compaction.py +++ b/tests/test_issue1217_transcript_compaction.py @@ -1,10 +1,12 @@ -from api.models import Session +from api.models import Session, reconciled_state_db_messages_for_session import contextlib +from types import SimpleNamespace from api.streaming import ( _assistant_reply_added_after_current_turn, _context_messages_for_new_turn, _merge_display_messages_after_agent_result, + _new_turn_context_from_messages, _sanitize_messages_for_api, _session_context_messages, ) @@ -314,6 +316,40 @@ def test_explicit_continue_keeps_compacted_active_task_context(tmp_path): assert _context_messages_for_new_turn(session, "继续") == compacted_task_context +def test_streaming_reconciled_context_keeps_casual_greeting_suppression(): + compacted_task_context = [ + { + "role": "user", + "content": ( + "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted. " + "Your current task is identified in the Active Task section — resume exactly from there." + ), + "timestamp": 1.0, + }, + {"role": "assistant", "content": "I will inspect api/config.py next.", "timestamp": 2.0}, + ] + session = SimpleNamespace( + session_id="issue2308-streaming", + messages=[{"role": "user", "content": "old task", "timestamp": 0.5}], + context_messages=compacted_task_context, + ) + external_state_messages = list(compacted_task_context) + + # Mirror the streaming pre-turn assembly for prefer_context=True: reconcile + # sidecar context with one state.db snapshot, then apply the normal new-turn + # context filter that suppresses casual greetings from resuming stale tasks. + previous_context_messages = _new_turn_context_from_messages( + reconciled_state_db_messages_for_session( + session, + prefer_context=True, + state_messages=external_state_messages, + ), + "你好", + ) + + assert previous_context_messages == [] + + def test_all_cjk_greetings_drop_stale_compaction_context(tmp_path): """Pin every CJK greeting in the casual-fresh-chat set against a stale compaction context. Catches typos like \\u5616 (嘖, "click of tongue") From bc7648271f90730bbdd984ca838fec001d18d835 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Tue, 19 May 2026 08:05:09 -0700 Subject: [PATCH 15/16] fix: preserve provider for configured model picker selections --- CHANGELOG.md | 4 ++++ static/ui.js | 3 +++ tests/test_issue2569_model_provider_picker.py | 21 +++++++++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 tests/test_issue2569_model_provider_picker.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 941c61ea..b6628958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Preserve the configured provider when choosing a configured model from the composer picker, so selecting a model owned by another provider does not send it through the previous provider. (PR #2588) + ## [v0.51.92] — 2026-05-19 — Release BP (stage-385 — 7-PR full sweep batch — RFC Slice 3c clarification + workspace tree icon alignment + project move cache refresh + auto-compression handoff metadata + Grok OAuth provider catalog + anonymous custom endpoint picker fallback + PWA standalone reload + pull-to-refresh) diff --git a/static/ui.js b/static/ui.js index 5ab149f7..a20b5221 100644 --- a/static/ui.js +++ b/static/ui.js @@ -732,6 +732,7 @@ const MODEL_STATE_KEY='hermes-webui-model-state'; // first colliding entry. function _getOptionProviderId(opt){ if(!opt) return ''; + if(opt.dataset && opt.dataset.provider) return opt.dataset.provider; const group=opt.parentElement; if(group && group.tagName==='OPTGROUP' && group.dataset && group.dataset.provider){ return group.dataset.provider; @@ -1416,6 +1417,8 @@ async function selectModelFromDropdown(value){ opt.value=value; opt.textContent=getModelLabel(value); opt.dataset.custom='1'; + const badge=(window._configuredModelBadges||{})[value]; + if(badge&&badge.provider) opt.dataset.provider=badge.provider; // Remove any previous custom option before adding new one sel.querySelectorAll('option[data-custom]').forEach(o=>o.remove()); sel.appendChild(opt); diff --git a/tests/test_issue2569_model_provider_picker.py b/tests/test_issue2569_model_provider_picker.py new file mode 100644 index 00000000..f46ebce5 --- /dev/null +++ b/tests/test_issue2569_model_provider_picker.py @@ -0,0 +1,21 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text() + + +def test_temporary_configured_model_option_carries_provider_badge(): + """Configured picker rows that are not already