From 7c7cd24d00b281d25b4cb9292fbc6acf0a45478a Mon Sep 17 00:00:00 2001 From: Keith Ammon Date: Thu, 2 Jul 2026 02:35:00 +0000 Subject: [PATCH 001/666] fix(auth): use SameSite=None for PKCE cookie over HTTPS to fix cross-site redirect dropping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chromium intermittently drops SameSite=Lax cookies set on a 302 redirect in a cross-site redirect chain (crbug 40508226). The OAuth flow sets the PKCE cookie on /auth/login (302 → IDP), and the callback returns from the cross-site IDP — exactly the scenario where Chromium drops the cookie, causing 'Missing PKCE state cookie' on /auth/callback. SameSite=None + Secure is the correct attribute for cookies that must survive cross-site redirect delivery. Chromium processes these reliably. Loopback HTTP (dev) stays SameSite=Lax since SameSite=None requires Secure. Fixes #56750 --- hermes_cli/dashboard_auth/cookies.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/hermes_cli/dashboard_auth/cookies.py b/hermes_cli/dashboard_auth/cookies.py index 8bcd9db78eb6..72c64fe1e42b 100644 --- a/hermes_cli/dashboard_auth/cookies.py +++ b/hermes_cli/dashboard_auth/cookies.py @@ -240,11 +240,23 @@ def clear_session_cookies(response: Response, *, prefix: str = "") -> None: def set_pkce_cookie( response: Response, *, payload: str, use_https: bool, prefix: str = "", ) -> None: + # SameSite=None when HTTPS: the PKCE cookie is set on the /auth/login + # 302 response (redirecting to the IDP) and must survive the cross-site + # redirect chain (same-site → IDP → same-site callback). Chromium has a + # long-standing bug (crbug 40508226) where SameSite=Lax cookies set on a + # 302 in a cross-site redirect chain are intermittently dropped, causing + # "Missing PKCE state cookie" on the callback. SameSite=None + Secure + # sidesteps the bug — these cookies are explicitly designed for cross-site + # delivery and Chromium processes them reliably during redirects. + # Loopback HTTP degrades to Lax (SameSite=None requires Secure). + attrs = _common_attrs(use_https=use_https, prefix=prefix) + if use_https: + attrs["samesite"] = "none" response.set_cookie( _resolved_name(PKCE_COOKIE, use_https=use_https, prefix=prefix), payload, max_age=_PKCE_MAX_AGE, - **_common_attrs(use_https=use_https, prefix=prefix), + **attrs, ) @@ -253,7 +265,8 @@ def clear_pkce_cookie(response: Response, *, prefix: str = "") -> None: for variant in _NAME_VARIANTS: response.set_cookie( f"{variant}{PKCE_COOKIE}", "", max_age=0, - path=path, httponly=True, samesite="lax", + path=path, httponly=True, samesite="none", + secure=True, ) From 5e57bf19a2d82b87f957332fcdeda655091ee6b6 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Tue, 11 Aug 2026 10:03:51 +1000 Subject: [PATCH 002/666] fix(auth): mirror the PKCE setter's cookie shape in clear_pkce_cookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SameSite=None change left clear_pkce_cookie() deleting every name variant with `SameSite=None; Secure` unconditionally. Over loopback HTTP the setter emits a bare-name cookie with `SameSite=Lax` and no `Secure` — a Secure deletion on a plain-HTTP origin can be ignored by the browser, leaving a stale PKCE cookie behind after callback/logout. Thread use_https from the request into clear_pkce_cookie() and derive both the set and clear attribute shapes from one helper (_pkce_attrs) so they cannot drift apart again. The __Host-/__Secure- variants keep `Secure; SameSite=None` regardless of origin — those names require Secure to be valid at all and only ever exist on HTTPS origins. Adds contract tests pinning the full Set-Cookie shape on both origins for set and clear, and updates the dashboard cookie documentation (which still claimed all cookies are SameSite=Lax). --- hermes_cli/dashboard_auth/cookies.py | 53 +++++++++++--- hermes_cli/dashboard_auth/routes.py | 6 +- .../hermes_cli/test_dashboard_auth_cookies.py | 73 ++++++++++++++++++- .../docs/user-guide/features/web-dashboard.md | 4 +- 4 files changed, 120 insertions(+), 16 deletions(-) diff --git a/hermes_cli/dashboard_auth/cookies.py b/hermes_cli/dashboard_auth/cookies.py index 72c64fe1e42b..f212a99250bc 100644 --- a/hermes_cli/dashboard_auth/cookies.py +++ b/hermes_cli/dashboard_auth/cookies.py @@ -237,6 +237,19 @@ def clear_session_cookies(response: Response, *, prefix: str = "") -> None: ) +def _pkce_attrs(*, use_https: bool, prefix: str) -> dict: + """Cookie attributes for the PKCE cookie's set AND clear paths. + + Single source of truth so a deletion always matches the shape the + setter emitted for the same origin — a shape mismatch means the + browser silently keeps the stale cookie. + """ + attrs = _common_attrs(use_https=use_https, prefix=prefix) + if use_https: + attrs["samesite"] = "none" + return attrs + + def set_pkce_cookie( response: Response, *, payload: str, use_https: bool, prefix: str = "", ) -> None: @@ -249,25 +262,45 @@ def set_pkce_cookie( # sidesteps the bug — these cookies are explicitly designed for cross-site # delivery and Chromium processes them reliably during redirects. # Loopback HTTP degrades to Lax (SameSite=None requires Secure). - attrs = _common_attrs(use_https=use_https, prefix=prefix) - if use_https: - attrs["samesite"] = "none" response.set_cookie( _resolved_name(PKCE_COOKIE, use_https=use_https, prefix=prefix), payload, max_age=_PKCE_MAX_AGE, - **attrs, + **_pkce_attrs(use_https=use_https, prefix=prefix), ) -def clear_pkce_cookie(response: Response, *, prefix: str = "") -> None: +def clear_pkce_cookie( + response: Response, *, use_https: bool, prefix: str = "", +) -> None: + """Emit Max-Age=0 deletions for every plausible PKCE cookie variant. + + A deletion is only honoured when its shape is acceptable to the + browser on the current origin: a ``Secure`` deletion can be dropped + on a plain-HTTP origin, while the ``__Host-``/``__Secure-`` name + variants REQUIRE ``Secure`` to be valid at all. So the bare-name + deletion mirrors the setter's shape for the active origin (Lax + without ``Secure`` over HTTP; ``SameSite=None; Secure`` over HTTPS, + matching :func:`set_pkce_cookie`), and the prefixed variants — which + can only ever have been set on an HTTPS origin — always carry + ``Secure; SameSite=None``. + """ path = _cookie_path(prefix) for variant in _NAME_VARIANTS: - response.set_cookie( - f"{variant}{PKCE_COOKIE}", "", max_age=0, - path=path, httponly=True, samesite="none", - secure=True, - ) + if variant: + # __Host-/__Secure- cookies only exist over HTTPS, and a + # deletion for a prefixed name must itself be Secure or the + # browser rejects the header outright. + response.set_cookie( + f"{variant}{PKCE_COOKIE}", "", max_age=0, + path=path, httponly=True, samesite="none", + secure=True, + ) + else: + response.set_cookie( + f"{variant}{PKCE_COOKIE}", "", max_age=0, + **_pkce_attrs(use_https=use_https, prefix=prefix), + ) def _read_with_fallback( diff --git a/hermes_cli/dashboard_auth/routes.py b/hermes_cli/dashboard_auth/routes.py index 0c142963bcc8..c9086179acd3 100644 --- a/hermes_cli/dashboard_auth/routes.py +++ b/hermes_cli/dashboard_auth/routes.py @@ -530,7 +530,7 @@ async def auth_callback( # Clear the PKCE cookie (its job is done) but set NO session cookies: # the desktop is not a browser session, it redeems the code for a # bearer token it stores itself. - clear_pkce_cookie(resp, prefix=_prefix(request)) + clear_pkce_cookie(resp, use_https=detect_https(request), prefix=_prefix(request)) clear_sso_attempt_cookie(resp, prefix=_prefix(request)) return resp @@ -551,7 +551,7 @@ async def auth_callback( prefix=_prefix(request), provider=session.provider, ) - clear_pkce_cookie(resp, prefix=_prefix(request)) + clear_pkce_cookie(resp, use_https=detect_https(request), prefix=_prefix(request)) # Clear the one-shot auto-SSO loop-guard marker now that login succeeded, # so it never lingers to suppress a future silent attempt after logout. clear_sso_attempt_cookie(resp, prefix=_prefix(request)) @@ -766,7 +766,7 @@ async def auth_logout(request: Request): prefix = _prefix(request) resp = RedirectResponse(url=f"{prefix}/login", status_code=302) clear_session_cookies(resp, prefix=prefix) - clear_pkce_cookie(resp, prefix=prefix) + clear_pkce_cookie(resp, use_https=detect_https(request), prefix=prefix) return resp diff --git a/tests/hermes_cli/test_dashboard_auth_cookies.py b/tests/hermes_cli/test_dashboard_auth_cookies.py index 2cd48f4c4d31..04f91e7b25a0 100644 --- a/tests/hermes_cli/test_dashboard_auth_cookies.py +++ b/tests/hermes_cli/test_dashboard_auth_cookies.py @@ -45,7 +45,7 @@ def set_pkce(): def clear(): r = Response("ok") clear_session_cookies(r, prefix=prefix) - clear_pkce_cookie(r, prefix=prefix) + clear_pkce_cookie(r, use_https=use_https, prefix=prefix) return r return app @@ -135,3 +135,74 @@ def test_read_session_cookies_from_request_secure_prefix(): + +# --------------------------------------------------------------------------- +# PKCE cookie set/clear contract — the OAuth round trip crosses sites, so the +# attribute shape (SameSite / Secure) is load-bearing, not cosmetic. These +# tests pin the full Set-Cookie header shape for both origins so a regression +# in either direction (cookie dropped by Chromium mid-redirect, or a stale +# cookie surviving a clear) fails loudly. +# --------------------------------------------------------------------------- + + +def test_pkce_cookie_https_is_samesite_none_secure(): + """HTTPS: the PKCE cookie must be SameSite=None + Secure. + + The cookie is set on the /auth/login 302 and must survive the + cross-site redirect chain through the IDP back to /auth/callback. + Chromium intermittently drops SameSite=Lax cookies set on a 302 in a + cross-site chain (crbug 40508226); SameSite=None is the fix. + """ + client = TestClient(_build_app(use_https=True, prefix="")) + r = client.get("/set-pkce") + cookies = r.headers.get_list("set-cookie") + pkce = next(c for c in cookies if c.startswith(f"__Host-{PKCE_COOKIE}=")) + assert "samesite=none" in pkce.lower() + assert "Secure" in pkce + assert "HttpOnly" in pkce + + +def test_pkce_cookie_http_stays_lax_without_secure(): + """Loopback HTTP dev: SameSite=None requires Secure, which HTTP can't + carry — so the setter degrades to bare-name Lax without Secure.""" + client = TestClient(_build_app(use_https=False, prefix="")) + r = client.get("/set-pkce") + cookies = r.headers.get_list("set-cookie") + pkce = next(c for c in cookies if c.startswith(f"{PKCE_COOKIE}=")) + assert "samesite=lax" in pkce.lower() + assert "Secure" not in pkce + + +def test_clear_pkce_cookie_https_matches_set_shape(): + """HTTPS clear: every name variant is deleted with SameSite=None + + Secure — matching the HTTPS setter so the browser honours the + deletion for whichever variant was actually set.""" + client = TestClient(_build_app(use_https=True, prefix="")) + cookies = client.get("/clear").headers.get_list("set-cookie") + for name in (f"__Host-{PKCE_COOKIE}", f"__Secure-{PKCE_COOKIE}", PKCE_COOKIE): + deletion = next(c for c in cookies if c.startswith(f'{name}="')) + assert "Max-Age=0" in deletion + assert "samesite=none" in deletion.lower() + assert "Secure" in deletion + + +def test_clear_pkce_cookie_http_bare_deletion_is_insecure_lax(): + """HTTP clear: the bare-name deletion must mirror the HTTP setter's + shape (Lax, no Secure). A Secure deletion can be ignored by browsers + on a plain-HTTP origin, leaving a stale PKCE cookie behind. The + __Host-/__Secure- variants require Secure to be valid at all, so + those deletions keep it regardless of origin.""" + client = TestClient(_build_app(use_https=False, prefix="")) + cookies = client.get("/clear").headers.get_list("set-cookie") + bare = next( + c for c in cookies + if c.startswith(f'{PKCE_COOKIE}="') + and not c.startswith("__") + ) + assert "Max-Age=0" in bare + assert "samesite=lax" in bare.lower() + assert "Secure" not in bare + for name in (f"__Host-{PKCE_COOKIE}", f"__Secure-{PKCE_COOKIE}"): + deletion = next(c for c in cookies if c.startswith(f'{name}="')) + assert "Max-Age=0" in deletion + assert "Secure" in deletion diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index 30c55a9ae672..67c413d547ee 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -926,10 +926,10 @@ Access tokens have a 15-minute TTL. **There is no refresh token in contract v1** | Name | Lifetime | Notes | |------|----------|-------| | `hermes_session_at` | Token TTL (15 min) | HttpOnly, SameSite=Lax, Secure-when-HTTPS | -| `hermes_session_pkce` | 10 min | HttpOnly; holds the PKCE verifier + provider hint during the round trip | +| `hermes_session_pkce` | 10 min | HttpOnly; holds the PKCE verifier + provider hint during the round trip. SameSite=None + Secure over HTTPS (must survive the cross-site IDP redirect chain — Chromium drops SameSite=Lax cookies set on a 302 in a cross-site chain); SameSite=Lax on loopback HTTP | | `hermes_session_rt` | unused in v1 | Reserved for forward-compat; not written when `refresh_token` is empty | -All three are `Path=/` and `SameSite=Lax`. The `Secure` flag is set when the dashboard is reached over HTTPS (detected via the request URL scheme — honours `X-Forwarded-Proto` from an upstream TLS terminator under `proxy_headers=True`). +All three are `Path=/`. The session cookies are `SameSite=Lax`; the PKCE cookie is `SameSite=None` when set over HTTPS (see table). The `Secure` flag is set when the dashboard is reached over HTTPS (detected via the request URL scheme — honours `X-Forwarded-Proto` from an upstream TLS terminator under `proxy_headers=True`). ### Logout From 15dbe772b38272e05a132a20d0c5566deb14102f Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Tue, 11 Aug 2026 10:06:58 +1000 Subject: [PATCH 003/666] chore: map contributor email for repfigit --- contributors/emails/breakout@protonmail.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/breakout@protonmail.com diff --git a/contributors/emails/breakout@protonmail.com b/contributors/emails/breakout@protonmail.com new file mode 100644 index 000000000000..27c9235471be --- /dev/null +++ b/contributors/emails/breakout@protonmail.com @@ -0,0 +1 @@ +repfigit From 52629a5de6561ff348e8e25819abd7be533d07d8 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Tue, 11 Aug 2026 10:11:28 +1000 Subject: [PATCH 004/666] fix(auth): make prefixed cookie deletions valid per cookie-prefix rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same bug class as the PKCE clear-shape mismatch, sibling call paths: clear_session_cookies() and clear_sso_attempt_cookie() emitted __Host-/__Secure- name deletions without the Secure attribute. RFC 6265bis prefix rules make such a Set-Cookie invalid — browsers reject the header outright — so the __Host- session cookie deletions on logout were silently ignored on HTTPS origins (masked in practice by the 15-minute access-token TTL, but logout did not actually delete the cookies it claimed to). Extract one _clear_cookie_variants() helper used by all three clear functions: prefixed variants always carry the attributes their name demands (Secure, and Path=/ for __Host-), while the bare-name deletion mirrors the corresponding setter's shape so it works on plain-HTTP origins too. Adds a contract test pinning the deletion shapes. --- hermes_cli/dashboard_auth/cookies.py | 96 ++++++++++++------- .../hermes_cli/test_dashboard_auth_cookies.py | 23 +++++ 2 files changed, 83 insertions(+), 36 deletions(-) diff --git a/hermes_cli/dashboard_auth/cookies.py b/hermes_cli/dashboard_auth/cookies.py index f212a99250bc..5d41bc31d237 100644 --- a/hermes_cli/dashboard_auth/cookies.py +++ b/hermes_cli/dashboard_auth/cookies.py @@ -56,7 +56,7 @@ """ from __future__ import annotations -from typing import Optional, Tuple +from typing import Literal, Optional, Tuple from fastapi import Request from fastapi.responses import Response @@ -212,6 +212,46 @@ def set_session_cookies( ) +def _clear_cookie_variants( + response: Response, + bare_name: str, + *, + prefix: str, + https_samesite: Literal["lax", "strict", "none"], + bare_attrs: dict, +) -> None: + """Emit Max-Age=0 deletions for every plausible name variant of a cookie. + + Cookie-prefix rules make the deletion shape load-bearing: a Set-Cookie + for a ``__Host-``/``__Secure-`` name is rejected outright by the + browser unless it carries ``Secure`` (and ``__Host-`` additionally + requires ``Path=/``), so those deletions always carry the attributes + their name demands. The bare-name deletion mirrors the shape the + setter uses (``bare_attrs``) — under RFC 6265bis a deletion sent from + a secure origin may omit ``Secure`` and still delete a Secure cookie, + while a ``Secure`` deletion on a plain-HTTP origin can be ignored, so + matching the setter is the shape that works on both origins. + """ + for variant in _NAME_VARIANTS: + if variant == "__Host-": + # __Host- demands Secure AND Path=/ or the header is invalid. + response.set_cookie( + f"{variant}{bare_name}", "", max_age=0, + path="/", httponly=True, samesite=https_samesite, + secure=True, + ) + elif variant == "__Secure-": + response.set_cookie( + f"{variant}{bare_name}", "", max_age=0, + path=_cookie_path(prefix), httponly=True, + samesite=https_samesite, secure=True, + ) + else: + response.set_cookie( + bare_name, "", max_age=0, **bare_attrs, + ) + + def clear_session_cookies(response: Response, *, prefix: str = "") -> None: """Emit Max-Age=0 deletions for both session cookies. @@ -221,19 +261,13 @@ def clear_session_cookies(response: Response, *, prefix: str = "") -> None: depends on the request that set it), so we emit deletions for every plausible variant under the active path. """ - path = _cookie_path(prefix) - for variant in _NAME_VARIANTS: - response.set_cookie( - f"{variant}{SESSION_AT_COOKIE}", "", max_age=0, - path=path, httponly=True, samesite="lax", - ) - response.set_cookie( - f"{variant}{SESSION_RT_COOKIE}", "", max_age=0, - path=path, httponly=True, samesite="lax", - ) - response.set_cookie( - f"{variant}{SESSION_PROVIDER_COOKIE}", "", max_age=0, - path=path, httponly=True, samesite="lax", + bare_attrs = { + "path": _cookie_path(prefix), "httponly": True, "samesite": "lax", + } + for name in (SESSION_AT_COOKIE, SESSION_RT_COOKIE, SESSION_PROVIDER_COOKIE): + _clear_cookie_variants( + response, name, + prefix=prefix, https_samesite="lax", bare_attrs=bare_attrs, ) @@ -285,22 +319,11 @@ def clear_pkce_cookie( can only ever have been set on an HTTPS origin — always carry ``Secure; SameSite=None``. """ - path = _cookie_path(prefix) - for variant in _NAME_VARIANTS: - if variant: - # __Host-/__Secure- cookies only exist over HTTPS, and a - # deletion for a prefixed name must itself be Secure or the - # browser rejects the header outright. - response.set_cookie( - f"{variant}{PKCE_COOKIE}", "", max_age=0, - path=path, httponly=True, samesite="none", - secure=True, - ) - else: - response.set_cookie( - f"{variant}{PKCE_COOKIE}", "", max_age=0, - **_pkce_attrs(use_https=use_https, prefix=prefix), - ) + _clear_cookie_variants( + response, PKCE_COOKIE, + prefix=prefix, https_samesite="none", + bare_attrs=_pkce_attrs(use_https=use_https, prefix=prefix), + ) def _read_with_fallback( @@ -365,12 +388,13 @@ def clear_sso_attempt_cookie(response: Response, *, prefix: str = "") -> None: Called on a successful callback and whenever the gate falls back to /login, so the marker never lingers to suppress a later silent attempt. """ - path = _cookie_path(prefix) - for variant in _NAME_VARIANTS: - response.set_cookie( - f"{variant}{SSO_ATTEMPT_COOKIE}", "", max_age=0, - path=path, httponly=True, samesite="lax", - ) + _clear_cookie_variants( + response, SSO_ATTEMPT_COOKIE, + prefix=prefix, https_samesite="lax", + bare_attrs={ + "path": _cookie_path(prefix), "httponly": True, "samesite": "lax", + }, + ) def detect_https(request: Request) -> bool: diff --git a/tests/hermes_cli/test_dashboard_auth_cookies.py b/tests/hermes_cli/test_dashboard_auth_cookies.py index 04f91e7b25a0..0d362b76c347 100644 --- a/tests/hermes_cli/test_dashboard_auth_cookies.py +++ b/tests/hermes_cli/test_dashboard_auth_cookies.py @@ -206,3 +206,26 @@ def test_clear_pkce_cookie_http_bare_deletion_is_insecure_lax(): deletion = next(c for c in cookies if c.startswith(f'{name}="')) assert "Max-Age=0" in deletion assert "Secure" in deletion + + +def test_clear_session_cookies_prefixed_deletions_carry_secure(): + """__Host-/__Secure- deletions must carry Secure (and __Host- Path=/): + browsers reject a prefixed Set-Cookie that violates its prefix rules, + so an insecure deletion for __Host-hermes_session_at is silently + ignored and the session cookie survives logout on HTTPS origins.""" + client = TestClient(_build_app(use_https=True, prefix="")) + cookies = client.get("/clear").headers.get_list("set-cookie") + for name in (SESSION_AT_COOKIE, SESSION_RT_COOKIE, SESSION_PROVIDER_COOKIE): + host = next(c for c in cookies if c.startswith(f'__Host-{name}="')) + assert "Secure" in host + assert "Path=/;" in host or host.rstrip().endswith("Path=/") + secure = next(c for c in cookies if c.startswith(f'__Secure-{name}="')) + assert "Secure" in secure + bare = next( + c for c in cookies + if c.startswith(f'{name}="') and not c.startswith("__") + ) + # Bare-name deletion mirrors the bare setter (Lax, no Secure) so + # it still works on plain-HTTP origins. + assert "Secure" not in bare + assert "Max-Age=0" in bare From a75e828b1a42f9b8fd37b8df197aeae30c78ee11 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Tue, 11 Aug 2026 10:13:34 +1000 Subject: [PATCH 005/666] test(auth): assert the Secure ATTRIBUTE, not the Secure substring --- .../hermes_cli/test_dashboard_auth_cookies.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/hermes_cli/test_dashboard_auth_cookies.py b/tests/hermes_cli/test_dashboard_auth_cookies.py index 0d362b76c347..794690a0af7e 100644 --- a/tests/hermes_cli/test_dashboard_auth_cookies.py +++ b/tests/hermes_cli/test_dashboard_auth_cookies.py @@ -103,7 +103,7 @@ def test_session_cookies_use_bare_name_on_http(): ) # No Secure flag (HTTP). at = next(c for c in cookies if c.startswith(f"{SESSION_AT_COOKIE}=")) - assert "Secure" not in at + assert "; Secure" not in at @@ -158,7 +158,7 @@ def test_pkce_cookie_https_is_samesite_none_secure(): cookies = r.headers.get_list("set-cookie") pkce = next(c for c in cookies if c.startswith(f"__Host-{PKCE_COOKIE}=")) assert "samesite=none" in pkce.lower() - assert "Secure" in pkce + assert "; Secure" in pkce assert "HttpOnly" in pkce @@ -170,7 +170,7 @@ def test_pkce_cookie_http_stays_lax_without_secure(): cookies = r.headers.get_list("set-cookie") pkce = next(c for c in cookies if c.startswith(f"{PKCE_COOKIE}=")) assert "samesite=lax" in pkce.lower() - assert "Secure" not in pkce + assert "; Secure" not in pkce def test_clear_pkce_cookie_https_matches_set_shape(): @@ -183,7 +183,7 @@ def test_clear_pkce_cookie_https_matches_set_shape(): deletion = next(c for c in cookies if c.startswith(f'{name}="')) assert "Max-Age=0" in deletion assert "samesite=none" in deletion.lower() - assert "Secure" in deletion + assert "; Secure" in deletion def test_clear_pkce_cookie_http_bare_deletion_is_insecure_lax(): @@ -201,11 +201,11 @@ def test_clear_pkce_cookie_http_bare_deletion_is_insecure_lax(): ) assert "Max-Age=0" in bare assert "samesite=lax" in bare.lower() - assert "Secure" not in bare + assert "; Secure" not in bare for name in (f"__Host-{PKCE_COOKIE}", f"__Secure-{PKCE_COOKIE}"): deletion = next(c for c in cookies if c.startswith(f'{name}="')) assert "Max-Age=0" in deletion - assert "Secure" in deletion + assert "; Secure" in deletion def test_clear_session_cookies_prefixed_deletions_carry_secure(): @@ -217,15 +217,15 @@ def test_clear_session_cookies_prefixed_deletions_carry_secure(): cookies = client.get("/clear").headers.get_list("set-cookie") for name in (SESSION_AT_COOKIE, SESSION_RT_COOKIE, SESSION_PROVIDER_COOKIE): host = next(c for c in cookies if c.startswith(f'__Host-{name}="')) - assert "Secure" in host + assert "; Secure" in host assert "Path=/;" in host or host.rstrip().endswith("Path=/") secure = next(c for c in cookies if c.startswith(f'__Secure-{name}="')) - assert "Secure" in secure + assert "; Secure" in secure bare = next( c for c in cookies if c.startswith(f'{name}="') and not c.startswith("__") ) # Bare-name deletion mirrors the bare setter (Lax, no Secure) so # it still works on plain-HTTP origins. - assert "Secure" not in bare + assert "; Secure" not in bare assert "Max-Age=0" in bare From 3e5e4c5d20006e0f29f5389224ee0265e444dd3f Mon Sep 17 00:00:00 2001 From: poisdahl <4091911+poisdahl@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:38:30 +0200 Subject: [PATCH 006/666] fix(agent): preserve live turns in compaction carriers --- agent/agent_runtime_helpers.py | 18 +- agent/context_compressor.py | 247 +++++++- agent/turn_context.py | 12 +- cli.py | 268 ++++++-- gateway/session.py | 91 ++- gateway/slash_commands.py | 71 ++- hermes_state.py | 157 ++++- run_agent.py | 2 + .../test_reference_handoff_active_turn.py | 109 ++++ tests/cli/test_cli_retry.py | 331 +++++++++- tests/gateway/test_retry_replacement.py | 203 +++++- tests/gateway/test_undo_rewind_session.py | 29 + .../test_composite_carrier_rewind.py | 238 +++++++ tests/run_agent/test_identity_flush.py | 49 ++ .../run_agent/test_thinking_only_sanitizer.py | 11 + tests/test_tui_gateway_server.py | 82 +++ .../test_composite_carrier_rewind.py | 587 ++++++++++++++++++ tui_gateway/methods_prompt.py | 147 +++-- tui_gateway/methods_session.py | 33 +- tui_gateway/methods_tools.py | 194 +++--- tui_gateway/server.py | 148 +++++ 21 files changed, 2691 insertions(+), 336 deletions(-) create mode 100644 tests/hermes_state/test_composite_carrier_rewind.py create mode 100644 tests/tui_gateway/test_composite_carrier_rewind.py diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index a2248e9b634f..94694db1585f 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -739,6 +739,19 @@ def _is_verification_candidate(m: Dict) -> bool: and merged[-1].get("role") == "user" ): prev = merged[-1] + # A summary carrier followed by a new user row is a deliberate + # durable shape after retry/rewind. Do not absorb the fresh ask + # into the already-persisted carrier: mutating that dict can make + # the only in-memory copy diverge from its durable row. Provider + # sanitizers merge copies later when strict alternation requires + # it, without rewriting either durable message. + from agent.context_compressor import split_user_originated_turn + + handoff, _ = split_user_originated_turn(prev) + if handoff is not None: + merged.append(msg) + continue + prev_content = prev.get("content", "") new_content = msg.get("content", "") # Only merge plain-text content; leave multimodal (list) @@ -1395,8 +1408,6 @@ def drop_thinking_only_and_merge_users( ) ] dropped = len(messages) - len(kept) - if dropped == 0: - return messages # Pass 2: merge any newly-adjacent user messages. merged: List[Dict[str, Any]] = [] @@ -1446,6 +1457,9 @@ def drop_thinking_only_and_merge_users( else: merged.append(m) + if dropped == 0 and merges == 0: + return messages + _ra().logger.debug( "Pre-call sanitizer: dropped %d thinking-only assistant turn(s), " "merged %d adjacent user message(s)", diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 3eb05b6b9713..290016d19329 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -16,6 +16,7 @@ - Richer tool call/result detail in summarizer input """ +import copy import hashlib import json import logging @@ -7294,6 +7295,218 @@ def is_compaction_summary_message(message: Any) -> bool: return ContextCompressor._is_context_summary_content(content) +# Display metadata that describes the durable message independently of the +# compaction wrapper. Other metadata may describe a synthetic timeline event +# and must not make that event look human after projection. +SUMMARY_CARRIER_DURABLE_DISPLAY_METADATA_KEYS = ("reactions",) + + +def _handoff_only_content(content: Any) -> Any: + """Project summary-bearing content to the synthetic handoff alone. + + The compressor has two composite layouts. Ordinary merge-into-tail keeps + the live content before ``_MERGED_SUMMARY_DELIMITER``; the force-user- + leading layout keeps it after ``_SUMMARY_END_MARKER``. This is the inverse + of ``_strip_context_summary_handoff_message`` and deliberately never keeps + live media blocks. + """ + if isinstance(content, str): + if _MERGED_SUMMARY_DELIMITER in content: + suffix = content.split(_MERGED_SUMMARY_DELIMITER, 1)[1].lstrip() + marker_idx = suffix.find(_SUMMARY_END_MARKER) + if marker_idx >= 0: + return suffix[: marker_idx + len(_SUMMARY_END_MARKER)] + return suffix + marker_idx = content.find(_SUMMARY_END_MARKER) + if marker_idx >= 0: + return content[: marker_idx + len(_SUMMARY_END_MARKER)] + return content + + if not isinstance(content, list): + return content + + # Ordinary merge: the summary suffix begins in the delimiter-bearing text + # part. Do not retain later parts: malformed/legacy rows may carry live + # media there rather than synthetic scaffold content. + for item in content: + text = ( + item + if isinstance(item, str) + else item.get("text") + if isinstance(item, dict) + else None + ) + if not isinstance(text, str) or _MERGED_SUMMARY_DELIMITER not in text: + continue + suffix = text.split(_MERGED_SUMMARY_DELIMITER, 1)[1].lstrip() + marker_idx = suffix.find(_SUMMARY_END_MARKER) + if marker_idx >= 0: + suffix = suffix[: marker_idx + len(_SUMMARY_END_MARKER)] + if not suffix: + return [] + if isinstance(item, dict): + copied = item.copy() + copied["text"] = suffix + return [copied] + return [suffix] + + # Force-user-leading merge: keep textual parts through the end marker and + # truncate the marker-bearing part before the live ask. + projected: list[Any] = [] + for item in content: + text = ( + item + if isinstance(item, str) + else item.get("text") + if isinstance(item, dict) + else None + ) + if isinstance(text, str) and _SUMMARY_END_MARKER in text: + prefix = text.split(_SUMMARY_END_MARKER, 1)[0] + _SUMMARY_END_MARKER + if isinstance(item, dict): + copied = item.copy() + copied["text"] = prefix + projected.append(copied) + else: + projected.append(prefix) + return projected + if isinstance(text, str): + projected.append(item.copy() if isinstance(item, dict) else item) + return projected + + +def split_user_originated_turn( + message: Any, +) -> tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: + """Split a user row into hidden handoff scaffold and canonical live view. + + Returns ``(handoff_only, live_view)``. A normal human row has no + handoff; a pure compaction handoff has no live view; a composite carrier + has both. Rewritten projections are fresh dictionaries and never retain + stale API-content or physical persistence identity. + """ + if not isinstance(message, dict) or message.get("role") != "user": + return None, None + + is_summary = is_compaction_summary_message(message) + handoff: Optional[Dict[str, Any]] = None + if is_summary: + handoff = { + "role": "user", + "content": _handoff_only_content(message.get("content")), + COMPRESSED_SUMMARY_METADATA_KEY: True, + "display_kind": "hidden", + } + if COMPRESSED_SUMMARY_HAS_USER_TURN_KEY in message: + handoff[COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] = bool( + message.get(COMPRESSED_SUMMARY_HAS_USER_TURN_KEY) + ) + if message.get(MICRO_COMPACT_MARKER_KEY): + handoff[MICRO_COMPACT_MARKER_KEY] = True + if message.get("timestamp") is not None: + handoff["timestamp"] = message["timestamp"] + drop_stale_api_content(handoff) + + # Hidden is the legacy physical wrapper used for compaction rows and + # does not hide a successfully unwrapped human payload. Other typed + # display rows are synthetic timeline events, never user input. + display_kind = message.get("display_kind") + if display_kind and display_kind != "hidden": + return handoff, None + candidate = ContextCompressor._strip_context_summary_handoff_message(message) + if candidate is None: + return handoff, None + else: + if message.get("display_kind"): + return None, None + candidate = message.copy() + + candidate.pop(COMPRESSED_SUMMARY_METADATA_KEY, None) + candidate.pop(COMPRESSED_SUMMARY_HAS_USER_TURN_KEY, None) + candidate.pop(MICRO_COMPACT_MARKER_KEY, None) + candidate.pop(_DB_PERSISTED_MARKER, None) + if is_summary: + candidate.pop("_row_id", None) + candidate.pop("display_kind", None) + candidate.pop("display_metadata", None) + carrier_metadata = message.get("display_metadata") + if isinstance(carrier_metadata, dict): + durable_metadata = { + key: copy.deepcopy(carrier_metadata[key]) + for key in SUMMARY_CARRIER_DURABLE_DISPLAY_METADATA_KEYS + if key in carrier_metadata + } + if durable_metadata: + candidate["display_metadata"] = durable_metadata + drop_stale_api_content(candidate) + if ContextCompressor._is_synthetic_compression_user_turn(candidate): + return handoff, None + if not ContextCompressor._is_actionable_user_turn(candidate): + return handoff, None + return handoff, candidate + + +def user_originated_turn_view(message: Any) -> Optional[Dict[str, Any]]: + """Return the live human-authored projection of a user row, if any.""" + return split_user_originated_turn(message)[1] + + +def history_before_user_originated_turn( + messages: List[Dict[str, Any]], + index: int, +) -> tuple[List[Dict[str, Any]], Dict[str, Any]]: + """Return a rewind prefix and canonical live view for ``index``. + + When the selected row is a composite carrier, the hidden handoff scaffold + remains at the new history head while the live ask and later rows are + removed. This retains the only representation of already-compacted turns. + """ + if index < 0 or index >= len(messages): + raise IndexError("user turn index is outside the transcript") + handoff, live_view = split_user_originated_turn(messages[index]) + if live_view is None: + raise ValueError("selected row is not a user-originated turn") + prefix = [message.copy() for message in messages[:index]] + if handoff is not None: + prefix.append(handoff) + return prefix, live_view + + +def retryable_user_text(content: Any) -> str: + """Return lossless retry text or raise before destructive mutation. + + Retry has no attachment replay protocol. Media and unknown structured + parts therefore fail closed; already-persisted strings are replayed as + text, including any textual degradation labels. Structured content is + flattened only when every part is plain text. + """ + if isinstance(content, str): + text = content + elif isinstance(content, list): + chunks: list[str] = [] + for part in content: + if isinstance(part, str): + chunks.append(part) + continue + if not isinstance(part, dict): + raise ValueError("retry does not support non-text content") + if part.get("type") not in {"text", "input_text", "output_text"}: + raise ValueError("retry does not support media or unknown content parts") + if set(part) - {"type", "text"}: + raise ValueError("retry cannot losslessly flatten annotated text parts") + part_text = part.get("text") + if not isinstance(part_text, str): + raise ValueError("retry text parts must contain text") + chunks.append(part_text) + text = "".join(chunks) + else: + raise ValueError("retry does not support non-text content") + + if not text.strip(): + raise ValueError("retry found no text to send") + return text + + def _handoff_carries_live_user_content(message: Any) -> bool: """Return True when a summary-bearing row still carries a live user ask. @@ -7302,21 +7515,16 @@ def _handoff_carries_live_user_content(message: Any) -> bool: ask, leaving a non-empty remainder after ``_SUMMARY_END_MARKER``. Either shape must remain actionable (#80622 must not treat them as sole-handoff). - Delegates to ``_strip_context_summary_handoff_message`` — the canonical - "does anything survive once the handoff is removed" logic (it also - handles multimodal list content and returns ``None`` for a merged-shaped - row whose preserved prior tail is EMPTY, which a bare - ``classify_summary_content(...) == "merged"`` check would wrongly treat - as live). Callers must pre-filter with ``is_compaction_summary_message``: - for non-summary rows the strip helper returns the message unchanged, - which would read as "carries live content" here. + Delegates to the canonical live-user projection. That projection uses + ``_strip_context_summary_handoff_message`` for string and multimodal + carriers, then excludes typed synthetic rows and empty/non-actionable + remainders. Callers must still pre-filter with + ``is_compaction_summary_message`` because an ordinary human row naturally + has a live-user projection too. """ if not isinstance(message, dict): return False - return ( - ContextCompressor._strip_context_summary_handoff_message(message) - is not None - ) + return user_originated_turn_view(message) is not None def reference_handoff_would_drive_next_model_call( @@ -7372,15 +7580,8 @@ def is_user_originated_turn(message: Any) -> bool: this instead of ``role == "user" and not display_kind`` — standalone handoffs with ``_compressed_summary_has_user_turn`` were previously left without ``display_kind=hidden`` and could be mistaken for real asks (#80622). - Summary-bearing rows are never user-originated, even when they embed a - live ask after the end marker (callers that need that text should unwrap). + Summary-bearing rows count only when their canonical live-user projection + recovers an actionable ask. Pure handoffs and typed synthetic rows never + count. """ - if not isinstance(message, dict) or message.get("role") != "user": - return False - if message.get("display_kind"): - return False - if is_compaction_summary_message(message): - return False - if ContextCompressor._is_synthetic_compression_user_turn(message): - return False - return ContextCompressor._is_actionable_user_turn(message) + return user_originated_turn_view(message) is not None diff --git a/agent/turn_context.py b/agent/turn_context.py index dfa5fbbd8e27..eef1ecc8d8c0 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -273,18 +273,26 @@ def reanchor_current_turn_user_idx(messages: List[Any], user_message: Any) -> in scaffolding, not the active ask. Returns -1 when the list has no user-originated message at all. """ - from agent.context_compressor import is_user_originated_turn + from agent.context_compressor import user_originated_turn_view fallback = -1 for i in range(len(messages) - 1, -1, -1): msg = messages[i] if not (isinstance(msg, dict) and msg.get("role") == "user"): continue + # Typed synthetic current events still need their physical persistence + # anchor when their raw content is unchanged. They are not eligible + # for the human-only fallback below. if msg.get("content") == user_message: return i + live_view = user_originated_turn_view(msg) + if live_view is None: + continue + if live_view.get("content") == user_message: + return i # Prefer a real human turn over a synthetic handoff / continuation # marker when the exact content was rewritten by merge-into-tail. - if fallback < 0 and is_user_originated_turn(msg): + if fallback < 0: fallback = i return fallback diff --git a/cli.py b/cli.py index 2e822be3746a..cd218ab1c8a6 100644 --- a/cli.py +++ b/cli.py @@ -8710,6 +8710,121 @@ def save_conversation(self): print(f" Resume the live session with: hermes --resume {self.session_id}") except Exception as e: print(f"(x_x) Failed to save: {e}") + + def _rewind_persisted_user_turn( + self, + *, + warm_history: List[Dict[str, Any]], + user_ordinal: int, + warm_live_view: Dict[str, Any], + ) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any]]: + """Bind one warm user ordinal to a durable row and rewind it atomically.""" + if self._session_db is None or not self.session_id: + raise RuntimeError("session database is unavailable") + + from agent.context_compressor import ( + history_before_user_originated_turn, + split_user_originated_turn, + user_originated_turn_view, + ) + from agent.memory_manager import sanitize_context + from agent.tool_dispatch_helpers import ( + _is_multimodal_tool_result, + _multimodal_text_summary, + ) + from run_agent import _is_ephemeral_scaffolding + + def _persistence_content(content: Any) -> Any: + """Project warm content exactly as the session DB flush does.""" + if _is_multimodal_tool_result(content): + return _multimodal_text_summary(content) + if isinstance(content, list): + text_parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + text_parts.append(str(part.get("text", ""))) + elif isinstance(part, dict) and part.get("type") in { + "image", + "image_url", + "input_image", + }: + text_parts.append("[screenshot]") + return "\n".join(text_parts) if text_parts else None + return content + + def _comparison_content(message: Dict[str, Any]) -> Any: + content = _persistence_content(message.get("content")) + if message.get("role") in {"user", "assistant"} and isinstance( + content, str + ): + return sanitize_context(content).strip() + return content + + durable = self._session_db.get_messages_as_conversation( + self.session_id, + include_row_ids=True, + ) + warm_persistence_history = [ + message + for message in warm_history + if not _is_ephemeral_scaffolding(message) + ] + warm_user_indices = [ + index + for index, message in enumerate(warm_persistence_history) + if user_originated_turn_view(message) is not None + ] + durable_user_indices = [ + index + for index, message in enumerate(durable) + if user_originated_turn_view(message) is not None + ] + if len(durable_user_indices) != len(warm_user_indices): + raise RuntimeError( + "session history changed before the rewind could be persisted" + ) + if user_ordinal < 0 or user_ordinal >= len(durable_user_indices): + raise RuntimeError("persisted rewind target is no longer available") + + warm_prefix, _ = history_before_user_originated_turn( + warm_persistence_history, warm_user_indices[user_ordinal] + ) + durable_target_index = durable_user_indices[user_ordinal] + durable_target = durable[durable_target_index] + durable_prefix, durable_live_view = history_before_user_originated_turn( + durable, durable_target_index + ) + if _comparison_content(durable_live_view) != _comparison_content( + warm_live_view + ): + raise RuntimeError( + "session history changed before the rewind could be persisted" + ) + target_row_id = durable_target.get("_row_id") + if not isinstance(target_row_id, int): + raise RuntimeError("persisted rewind target has no row identity") + expected_active_ids = [ + int(message["_row_id"]) + for message in durable + if isinstance(message.get("_row_id"), int) + ] + + scaffold, _ = split_user_originated_turn(durable_target) + result = self._session_db.rewind_to_message( + self.session_id, + target_row_id, + preserve_compaction_handoff=scaffold is not None, + expected_active_ids=expected_active_ids, + expected_target_content=durable_live_view.get("content"), + ) + if scaffold is not None: + replacement_id = result.get("replacement_message_id") + if not isinstance(replacement_id, int) or not durable_prefix: + raise RuntimeError("rewind did not retain its compaction handoff") + durable_prefix[-1]["_row_id"] = replacement_id + durable_prefix[-1]["_db_persisted"] = True + warm_prefix[-1] = durable_prefix[-1] + return warm_prefix, durable_live_view, result def retry_last(self): """Retry the last user message by removing the last exchange and re-sending. @@ -8727,22 +8842,68 @@ def retry_last(self): # CLI resume counting and list_recent_user_messages. Compaction # handoffs are excluded too (durable role=user, sometimes without # display_kind on legacy sessions; #80622). - from agent.context_compressor import is_user_originated_turn + from agent.context_compressor import ( + history_before_user_originated_turn, + retryable_user_text, + user_originated_turn_view, + ) + from agent.memory_manager import sanitize_context + from run_agent import _is_ephemeral_scaffolding - last_user_idx = None - for i in range(len(self.conversation_history) - 1, -1, -1): - msg = self.conversation_history[i] - if is_user_originated_turn(msg): - last_user_idx = i - break + warm_history = list(self.conversation_history) + + user_indices = [ + index + for index, message in enumerate(warm_history) + if not _is_ephemeral_scaffolding(message) + and user_originated_turn_view(message) is not None + ] - if last_user_idx is None: + if not user_indices: print("(._.) No user message found to retry.") return None + last_user_idx = user_indices[-1] - # Extract the message text and remove everything from that point forward - last_message = self.conversation_history[last_user_idx].get("content", "") - self.conversation_history = self.conversation_history[:last_user_idx] + # Resolve a lossless live payload before touching either persistence or + # memory. A force-user-leading compaction row is one physical carrier: + # its historical handoff remains in the prefix while only the embedded + # human ask is retried. Media cannot be replayed by /retry, so fail + # closed before archiving anything. + try: + truncated, live_view = history_before_user_originated_turn( + warm_history, last_user_idx + ) + live_content = live_view.get("content") + if isinstance(live_content, str): + live_content = sanitize_context(live_content).strip() + last_message = retryable_user_text(live_content) + except ValueError as exc: + print(f"(._.) Cannot retry that message safely: {exc}") + return None + + # Persist the rewind before publishing the shorter in-memory view. + # The DB owns the physical carrier split so the archived original and + # retained scaffold are committed atomically. A plain user row keeps + # the legacy rewind shape (no replacement scaffold). + if self._session_db is not None and self.session_id: + try: + truncated, _, _ = self._rewind_persisted_user_turn( + warm_history=warm_history, + user_ordinal=len(user_indices) - 1, + warm_live_view=live_view, + ) + except Exception as exc: + print(f"(x_x) Retry rewind failed; history was not changed: {exc}") + return None + + self.conversation_history = truncated + if self.agent is not None: + if hasattr(self.agent, "_session_messages"): + self.agent._session_messages = self.conversation_history + if hasattr(self.agent, "_last_flushed_db_idx"): + self.agent._last_flushed_db_idx = len(self.conversation_history) + if hasattr(self.agent, "_db_flush_scan_prefix"): + self.agent._db_flush_scan_prefix = self.conversation_history[:] print(f"(^_^)b Retrying: \"{last_message[:60]}{'...' if len(last_message) > 60 else ''}\"") return last_message @@ -8781,59 +8942,62 @@ def undo_last(self, n: int = 1, prefill: bool = True): # messages (exclude display_kind timeline rows and compaction # handoffs — same predicate as list_recent_user_messages, resume # turn counting, and /retry; #80622). - from agent.context_compressor import is_user_originated_turn - - user_indices = [] - for i in range(len(self.conversation_history) - 1, -1, -1): - msg = self.conversation_history[i] - if is_user_originated_turn(msg): - user_indices.append(i) - if len(user_indices) >= n: - break + from agent.context_compressor import ( + history_before_user_originated_turn, + user_originated_turn_view, + ) + from run_agent import _is_ephemeral_scaffolding + + warm_history = list(self.conversation_history) + + user_indices = [ + index + for index, message in enumerate(warm_history) + if not _is_ephemeral_scaffolding(message) + and user_originated_turn_view(message) is not None + ] if not user_indices: print("(._.) No user message found to undo.") return - # The oldest of the collected user messages is our truncation point. - cut_idx = user_indices[-1] - turns_undone = len(user_indices) - - removed_count = len(self.conversation_history) - cut_idx - removed_msg = self.conversation_history[cut_idx].get("content", "") - removed_text = self._undo_content_to_text(removed_msg) + turns_undone = min(n, len(user_indices)) + target_ordinal = len(user_indices) - turns_undone + cut_idx = user_indices[target_ordinal] - # Truncate the in-memory history to before that user message. - self.conversation_history = self.conversation_history[:cut_idx] + removed_count = len(warm_history) - cut_idx + truncated, live_view = history_before_user_originated_turn( + warm_history, cut_idx + ) + removed_text = self._undo_content_to_text(live_view.get("content")) # Soft-delete the truncated rows on disk so re-prompts and search # see the clean transcript while the rows survive for audit. rewound_rows = 0 if self._session_db is not None and self.session_id: try: - recents = self._session_db.list_recent_user_messages( - self.session_id, limit=max(turns_undone, 10) - ) - if recents: - target_idx = min(turns_undone - 1, len(recents) - 1) - target_id = recents[target_idx]["id"] - result = self._session_db.rewind_to_message( - self.session_id, target_id - ) - rewound_rows = result.get("rewound_count", 0) - # Prefer the DB's decoded target text for the prefill — - # it's the canonical persisted copy. - db_text = self._undo_content_to_text( - (result.get("target_message") or {}).get("content") + truncated, durable_live_view, result = ( + self._rewind_persisted_user_turn( + warm_history=warm_history, + user_ordinal=target_ordinal, + warm_live_view=live_view, ) - if db_text: - removed_text = db_text - except ValueError as e: - # Non-user target / cross-session — keep the in-memory undo - # but skip the soft-delete; surface a debug-level note. - logger.debug("undo: soft-delete skipped: %s", e) + ) + # Canonicalize the editable prefill before mutation. The raw + # physical carrier contains the reference summary wrapper. + durable_text = self._undo_content_to_text( + durable_live_view.get("content") + ) + if durable_text: + removed_text = durable_text + rewound_rows = result.get("rewound_count", 0) except Exception as e: - logger.debug("undo: soft-delete failed: %s", e) + logger.debug("undo: durable rewind failed: %s", e) + print(f"(x_x) Undo failed; history was not changed: {e}") + return + + # Publish only after the durable rewind succeeds (or no store exists). + self.conversation_history = truncated # Agent surgery: invalidate the system-prompt cache and reset the # flush index so the next turn re-flushes from the truncated head. @@ -8848,6 +9012,10 @@ def undo_last(self, n: int = 1, prefill: bool = True): self.agent._last_flushed_db_idx = len(self.conversation_history) except Exception: pass + if hasattr(self.agent, "_session_messages"): + self.agent._session_messages = self.conversation_history + if hasattr(self.agent, "_db_flush_scan_prefix"): + self.agent._db_flush_scan_prefix = self.conversation_history[:] # Notify memory providers — same hook /branch fires, with the # rewound flag so per-turn document caches invalidate (#6672, #21910). try: diff --git a/gateway/session.py b/gateway/session.py index 1d4db2f5acc1..21228473e6f6 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -3874,7 +3874,13 @@ def load_transcript(self, session_id: str) -> List[Dict[str, Any]]: ) return [] - def rewind_session(self, session_id: str, n: int = 1) -> Optional[Dict[str, Any]]: + def rewind_session( + self, + session_id: str, + n: int = 1, + *, + require_retryable_composite: bool = False, + ) -> Optional[Dict[str, Any]]: """Back up ``n`` user turns via soft-delete, keeping rows for audit. Unlike :meth:`rewrite_transcript` (a hard replace used by /retry), @@ -3885,45 +3891,86 @@ def rewind_session(self, session_id: str, n: int = 1) -> Optional[Dict[str, Any] Returns a dict ``{"rewound_count", "turns_undone", "target_text"}`` on success, or ``None`` if there's no DB or no user message to back up to. ``n`` clamps to the oldest user turn when it exceeds the turn count. + ``require_retryable_composite`` is the gateway ``/retry`` guard: the + selected current turn must still be a composite carrier, and its live + payload must be losslessly replayable as text before anything changes. """ if not self._db: return None self._clear_dirty_transcript(session_id) if n < 1: n = 1 + from agent.context_compressor import ( + retryable_user_text, + split_user_originated_turn, + user_originated_turn_view, + ) + try: - recents = self._db.list_recent_user_messages(session_id, limit=max(n, 10)) + durable = self._db.get_messages_as_conversation( + session_id, + include_row_ids=True, + ) + expected_active_ids = [ + int(message["_row_id"]) + for message in durable + if isinstance(message.get("_row_id"), int) + ] + user_indices = [ + index + for index, message in enumerate(durable) + if user_originated_turn_view(message) is not None + ] + if not user_indices: + return None + turns_undone = min(n, len(user_indices)) + target = durable[user_indices[-turns_undone]] + target_id = target.get("_row_id") + if not isinstance(target_id, int): + return None + handoff, target_view = split_user_originated_turn(target) + if target_view is None: + return None + if require_retryable_composite: + if handoff is None: + return None + target_text = retryable_user_text(target_view.get("content")) except Exception as e: - logger.debug("rewind_session: failed to list user messages: %s", e) + logger.debug("rewind_session: failed to resolve canonical target: %s", e) return None - if not recents: - return None - target_idx = min(n - 1, len(recents) - 1) - target_id = recents[target_idx]["id"] try: - result = self._db.rewind_to_message(session_id, target_id) + result = self._db.rewind_to_message( + session_id, + target_id, + preserve_compaction_handoff=handoff is not None, + expected_active_ids=expected_active_ids, + expected_target_content=target_view.get("content"), + ) except ValueError as e: logger.debug("rewind_session: %s", e) return None except Exception as e: logger.debug("rewind_session: rewind_to_message failed: %s", e) return None - target_msg = result.get("target_message") or {} - content = target_msg.get("content") or "" - if isinstance(content, list): - parts = [ - p.get("text", "") - for p in content - if isinstance(p, dict) and p.get("type") == "text" - ] - target_text = "\n".join(t for t in parts if t) - elif isinstance(content, str): - target_text = content - else: - target_text = "" + # ``target_view`` is the canonical live projection of the physical DB + # row. For a composite carrier, the raw target contains the historical + # summary wrapper and must never be echoed back as the editable prompt. + if not require_retryable_composite: + content = target_view.get("content") or "" + if isinstance(content, list): + parts = [ + p.get("text", "") + for p in content + if isinstance(p, dict) and p.get("type") == "text" + ] + target_text = "\n".join(t for t in parts if t) + elif isinstance(content, str): + target_text = content + else: + target_text = "" return { "rewound_count": result.get("rewound_count", 0), - "turns_undone": target_idx + 1, + "turns_undone": turns_undone, "target_text": target_text, } diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index e49891749b5f..7f72f0df1f11 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2597,34 +2597,63 @@ async def _handle_retry_command(self, event: MessageEvent) -> str: # auto_continue / hidden); clients never count them as user turns. # Without this filter /retry rewrote the transcript around a marker # and re-sent opaque bookkeeping text (same class as the TUI ordinal). - last_user_msg = None last_user_idx = None - # is_user_originated_turn: excludes display_kind bookkeeping AND - # compaction handoffs (durable role=user, sometimes without - # display_kind on legacy sessions; #80622) — /retry must never - # re-send a reference-only summary as if the user asked it. - from agent.context_compressor import is_user_originated_turn + # The canonical projection excludes bookkeeping and pure handoffs while + # still recognizing a real ask embedded in a compaction carrier. + from agent.context_compressor import ( + history_before_user_originated_turn, + retryable_user_text, + split_user_originated_turn, + user_originated_turn_view, + ) for i in range(len(history) - 1, -1, -1): msg = history[i] - if is_user_originated_turn(msg): - last_user_msg = msg.get("content", "") + if user_originated_turn_view(msg) is not None: last_user_idx = i break - - if not last_user_msg: + + if last_user_idx is None: return t("gateway.retry.no_previous") - - # Truncate history to before the last user message and persist only the - # live view. After in-place compaction the pre-compaction transcript - # lives on as active=0/compacted=1 rows under this same session id, and - # a bare rewrite (active_only=False) would DELETE them (same class as - # #61145). /retry never intends to purge archived history, so avoid a - # separate existence probe: it could fail open or race with the write. - truncated = history[:last_user_idx] - await self.async_session_store.rewrite_transcript( - session_entry.session_id, truncated, active_only=True - ) + + # Resolve the live text and the scaffold-preserving prefix before any + # transcript write. Messaging retries cannot reconstruct attachments; + # reject media/unknown content without truncating the session. + try: + truncated, live_view = history_before_user_originated_turn( + history, last_user_idx + ) + last_user_msg = retryable_user_text(live_view.get("content")) + handoff, _ = split_user_originated_turn(history[last_user_idx]) + except ValueError as exc: + return f"Cannot retry that message safely: {exc}" + + if handoff is not None: + # A composite carrier is one physical row containing both the + # retained summary and the live ask. Let the carrier-aware rewind + # archive that row/tail and insert its pure scaffold atomically. + # Plain turns keep the existing rewrite path below; #84078 owns + # its separate archive_dropped/prefix-CAS semantics. + rewind_result = await self.async_session_store.rewind_session( + session_entry.session_id, + 1, + require_retryable_composite=True, + ) + if rewind_result is None: + return "Retry failed; transcript was not changed." + # The store reselects and validates the latest carrier on the same + # snapshot used by the atomic rewind. A concurrent newer turn can + # therefore never be removed while this handler resends stale text. + last_user_msg = rewind_result["target_text"] + else: + # After in-place compaction the pre-compaction transcript lives on + # as active=0/compacted=1 rows under this session id. active_only + # preserves that archive; a separate existence probe could fail + # open or race with the write. + if not await self.async_session_store.rewrite_transcript( + session_entry.session_id, truncated, active_only=True + ): + return "Retry failed; transcript was not changed." # Reset stored token count — transcript was truncated session_entry.last_prompt_tokens = 0 diff --git a/hermes_state.py b/hermes_state.py index 800a7828e186..9891014d6f55 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -9473,8 +9473,37 @@ def _is_duplicate_replayed_user_message(messages: List[Dict[str, Any]], msg: Dic # Rewind (soft-delete) — see /rewind slash command + issue #21910 # ========================================================================= + @staticmethod + def _active_transcript_counts(conn, session_id: str) -> tuple[int, int]: + """Return active message/tool-call counts inside the caller's txn.""" + rows = conn.execute( + "SELECT tool_calls FROM messages " + "WHERE session_id = ? AND active = 1", + (session_id,), + ).fetchall() + tool_call_count = 0 + for row in rows: + raw = row[0] + if not raw: + continue + try: + decoded = json.loads(raw) if isinstance(raw, str) else raw + except (json.JSONDecodeError, TypeError): + continue + if isinstance(decoded, list): + tool_call_count += len(decoded) + elif decoded: + tool_call_count += 1 + return len(rows), tool_call_count + def rewind_to_message( - self, session_id: str, target_message_id: int + self, + session_id: str, + target_message_id: int, + *, + preserve_compaction_handoff: bool = False, + expected_active_ids: Optional[List[int]] = None, + expected_target_content: Any = None, ) -> Dict[str, Any]: """Soft-delete all messages with id >= ``target_message_id`` in *session_id*. @@ -9493,7 +9522,17 @@ def rewind_to_message( } Raises ``ValueError`` if the target message does not exist in - *session_id* or if its role is not ``"user"``. + *session_id* or if its role is not ``"user"``. With + ``preserve_compaction_handoff=True``, a composite summary carrier is + split inside the same write transaction: its original row is archived + and its canonical hidden handoff scaffold is inserted as the new head. + That opt-in result also contains ``replacement_message_id``. + + ``expected_active_ids`` optionally pins the ordered active row set. + ``expected_target_content`` additionally pins the selected canonical + live-user payload. Both checks run inside the write transaction before + any row or counter mutation. Presentation-only metadata changes (for + example Desktop reactions) deliberately do not invalidate a rewind. Always increments ``sessions.rewind_count`` — even when the target is already inactive — so the counter accurately reflects @@ -9502,29 +9541,71 @@ def rewind_to_message( target is a no-op on row state but still bumps the counter. """ - # 1) Validate target up-front (read-only, outside the write txn). - with self._lock: - row = self._conn.execute( + def _do(conn): + # Rewind changes the active transcript and must honor the same + # compression ownership/closed-parent guards as append writers. + self._check_transcript_write_guards(conn, session_id, None) + + if expected_active_ids is not None: + active_rows = conn.execute( + "SELECT id FROM messages " + "WHERE session_id = ? AND active = 1 ORDER BY id", + (session_id,), + ).fetchall() + active_ids = [int(active_row[0]) for active_row in active_rows] + if active_ids != expected_active_ids: + raise RuntimeError( + "active transcript changed before the rewind could be persisted" + ) + + row = conn.execute( "SELECT * FROM messages WHERE id = ? AND session_id = ?", (target_message_id, session_id), ).fetchone() - if row is None: - raise ValueError( - f"message {target_message_id} not found in session {session_id}" - ) - target_row = dict(row) - if target_row.get("role") != "user": - raise ValueError( - f"rewind target must be a 'user' message (got role=" - f"{target_row.get('role')!r}, id={target_message_id})" - ) + if row is None: + raise ValueError( + f"message {target_message_id} not found in session {session_id}" + ) + target_row = dict(row) + if target_row.get("role") != "user": + raise ValueError( + f"rewind target must be a 'user' message (got role=" + f"{target_row.get('role')!r}, id={target_message_id})" + ) - # Decode content for callers (prefill the prompt buffer). - target_row["content"] = self._decode_content(target_row.get("content")) + replacement_message_id: Optional[int] = None + replacement: Optional[Dict[str, Any]] = None + if preserve_compaction_handoff or expected_target_content is not None: + if not target_row.get("active"): + raise ValueError("rewind target is not active") + from agent.context_compressor import split_user_originated_turn - rewound: List[int] = [] + split_target = target_row.copy() + split_target["content"] = self._decode_content( + split_target.get("content") + ) + split_target["display_metadata"] = self._decode_display_metadata( + split_target.get("display_metadata") + ) + handoff, live_view = split_user_originated_turn(split_target) + if live_view is None: + raise ValueError("rewind target is not a user-originated turn") + live_content = live_view.get("content") + if isinstance(live_content, str): + live_content = sanitize_context(live_content).strip() + if ( + expected_target_content is not None + and live_content != expected_target_content + ): + raise RuntimeError( + "rewind target changed before it could be persisted" + ) + if preserve_compaction_handoff and handoff is None: + raise ValueError( + "preserve_compaction_handoff requires an active composite carrier" + ) + replacement = handoff if preserve_compaction_handoff else None - def _do(conn): cursor = conn.execute( "SELECT id FROM messages " "WHERE session_id = ? AND id >= ? AND active = 1", @@ -9537,28 +9618,48 @@ def _do(conn): f"UPDATE messages SET active = 0 WHERE id IN ({placeholders})", ids, ) + if replacement is not None: + self._insert_message_rows(conn, session_id, [replacement]) + inserted = conn.execute("SELECT last_insert_rowid()").fetchone() + replacement_message_id = int(inserted[0]) conn.execute( "UPDATE sessions SET rewind_count = COALESCE(rewind_count, 0) + 1 " "WHERE id = ?", (session_id,), ) - return ids - - rewound = self._execute_write(_do) - - # 2) Compute new head id (largest still-active row id in session). - with self._lock: - head_row = self._conn.execute( + message_count, tool_call_count = self._active_transcript_counts( + conn, session_id + ) + conn.execute( + "UPDATE sessions SET message_count = ?, tool_call_count = ? " + "WHERE id = ?", + (message_count, tool_call_count, session_id), + ) + head_row = conn.execute( "SELECT MAX(id) FROM messages WHERE session_id = ? AND active = 1", (session_id,), ).fetchone() - new_head_id = head_row[0] if head_row and head_row[0] is not None else None + new_head_id = ( + head_row[0] if head_row and head_row[0] is not None else None + ) + return target_row, ids, new_head_id, replacement_message_id - return { + target_row, rewound, new_head_id, replacement_message_id = ( + self._execute_write(_do) + ) + + # Decode content for callers (prefill the prompt buffer) without a + # second fallible database operation after the transaction commits. + target_row["content"] = self._decode_content(target_row.get("content")) + + result = { "rewound_count": len(rewound), "target_message": target_row, "new_head_id": new_head_id, } + if preserve_compaction_handoff: + result["replacement_message_id"] = replacement_message_id + return result def restore_rewound(self, session_id: str, since_message_id: int) -> int: """Mark inactive messages with id >= *since_message_id* active again. diff --git a/run_agent.py b/run_agent.py index 12e7da647ebb..0f3bd873ab0a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -162,6 +162,7 @@ def _session_source_for_agent(platform: Optional[str]) -> str: from agent.context_compressor import ( # noqa: F401 COMPRESSED_SUMMARY_METADATA_KEY, ContextCompressor, + user_originated_turn_view, ) from agent.retry_utils import jittered_backoff # noqa: F401 from agent.prompt_builder import ( # noqa: F401 # re-exported via _ra() / mock.patch("run_agent.") / from run_agent import @@ -2261,6 +2262,7 @@ def _flush_messages_to_session_db_unlocked( "hidden" if ( msg.get(COMPRESSED_SUMMARY_METADATA_KEY) + and user_originated_turn_view(msg) is None and ( ContextCompressor.classify_summary_content( msg.get("content") diff --git a/tests/agent/test_reference_handoff_active_turn.py b/tests/agent/test_reference_handoff_active_turn.py index 4e62b0f9b83f..45df7c76b621 100644 --- a/tests/agent/test_reference_handoff_active_turn.py +++ b/tests/agent/test_reference_handoff_active_turn.py @@ -10,6 +10,8 @@ from __future__ import annotations +import pytest + from agent.context_compressor import ( COMPRESSED_SUMMARY_HAS_USER_TURN_KEY, COMPRESSED_SUMMARY_METADATA_KEY, @@ -17,13 +19,18 @@ HISTORICAL_TASK_HEADING, SUMMARY_PREFIX, _SUMMARY_END_MARKER, + history_before_user_originated_turn, is_compaction_summary_message, is_user_originated_turn, reference_handoff_would_drive_next_model_call, + retryable_user_text, + split_user_originated_turn, + user_originated_turn_view, ) from agent.conversation_loop import ( _should_skip_model_call_for_reference_handoff, ) +from agent.agent_runtime_helpers import repair_message_sequence from agent.turn_context import reanchor_current_turn_user_idx @@ -39,6 +46,12 @@ def _standalone_handoff(task: str = "finish the already-done refactor") -> dict: } +def _composite_handoff(ask: str = "REAL ASK") -> dict: + handoff = _standalone_handoff() + handoff["content"] += f"\n\n{ask}" + return handoff + + class TestReferenceHandoffWouldDriveNextModelCall: def test_standalone_handoff_alone_drives(self): messages = [_standalone_handoff()] @@ -136,6 +149,52 @@ def test_standalone_handoff_not_user_originated_even_with_has_user_turn(self): def test_plain_user_is_originated(self): assert is_user_originated_turn({"role": "user", "content": "hello"}) is True + def test_force_user_leading_carrier_is_originated_via_live_view(self): + carrier = _composite_handoff() + + handoff, live = split_user_originated_turn(carrier) + + assert is_user_originated_turn(carrier) is True + assert user_originated_turn_view(carrier)["content"] == "REAL ASK" + assert live["content"] == "REAL ASK" + assert handoff["display_kind"] == "hidden" + assert "REAL ASK" not in handoff["content"] + + def test_hidden_legacy_carrier_still_projects_but_typed_carrier_does_not(self): + hidden = {**_composite_handoff(), "display_kind": "hidden"} + typed = {**_composite_handoff(), "display_kind": "auto_continue"} + + assert user_originated_turn_view(hidden)["content"] == "REAL ASK" + assert user_originated_turn_view(typed) is None + + def test_rewind_prefix_preserves_only_the_carrier_scaffold(self): + messages = [ + {"role": "assistant", "content": "prefix"}, + _composite_handoff(), + {"role": "assistant", "content": "failed"}, + ] + + prefix, live = history_before_user_originated_turn(messages, 1) + + assert live["content"] == "REAL ASK" + assert prefix[0] == messages[0] + assert prefix[1]["display_kind"] == "hidden" + assert "REAL ASK" not in prefix[1]["content"] + assert messages[1]["content"].endswith("REAL ASK") + + def test_live_projection_keeps_reactions_but_drops_wrapper_metadata(self): + carrier = _composite_handoff() + carrier["display_metadata"] = { + "reactions": [{"emoji": "👍", "author": "user"}], + "synthetic_only": "do not project", + } + + live = user_originated_turn_view(carrier) + + assert live["display_metadata"] == { + "reactions": [{"emoji": "👍", "author": "user"}] + } + def test_display_kind_hidden_not_originated(self): assert ( is_user_originated_turn( @@ -172,6 +231,56 @@ def test_fallback_prefers_real_user_over_handoff(self): # Exact content rewritten by merge — fall back must not land on handoff. assert reanchor_current_turn_user_idx(messages, "rewritten ask") == 0 + def test_exact_live_projection_reanchors_to_composite_carrier(self): + messages = [ + {"role": "user", "content": "older ask"}, + _composite_handoff("rewritten ask"), + ] + + assert reanchor_current_turn_user_idx(messages, "rewritten ask") == 1 + + +class TestRetryableUserText: + def test_losslessly_flattens_text_only_parts(self): + assert retryable_user_text( + [ + {"type": "text", "text": "hello "}, + {"type": "input_text", "text": "world"}, + ] + ) == "hello world" + + def test_rejects_structured_media_parts(self): + with pytest.raises(ValueError, match="media or unknown"): + retryable_user_text( + [{"type": "image_url", "image_url": {"url": "data:image/png;base64,x"}}] + ) + + @pytest.mark.parametrize( + "text", + [ + "Explain why ![alt](https://example.test/a.png) renders", + "Review @file:README.md and this data:image/png example", + "Explain the literal [screenshot] marker", + "Compare [file: README.md] with [image|ybres:RID]", + ], + ) + def test_keeps_ordinary_text_that_uses_media_like_syntax(self, text): + assert retryable_user_text(text) == text + + +class TestCarrierAlternationRepair: + def test_fresh_user_does_not_mutate_persisted_carrier_dict(self): + carrier = _composite_handoff() + original = carrier.copy() + fresh = {"role": "user", "content": "NEXT ASK"} + messages = [carrier, fresh] + + assert repair_message_sequence(None, messages) == 0 + + assert messages == [carrier, fresh] + assert messages[0] is carrier + assert carrier == original + class TestNoToolCallsWithoutLaterRealUser: def test_historical_snapshot_alone_is_not_actionable(self): diff --git a/tests/cli/test_cli_retry.py b/tests/cli/test_cli_retry.py index b287b45754f6..8386184345fb 100644 --- a/tests/cli/test_cli_retry.py +++ b/tests/cli/test_cli_retry.py @@ -1,10 +1,42 @@ -"""Regression tests for CLI /retry history replacement semantics.""" +"""Regression tests for CLI /retry and carrier-aware rewind semantics.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from agent.context_compressor import ( + HISTORICAL_TASK_HEADING, + SUMMARY_PREFIX, + _SUMMARY_END_MARKER, +) +from hermes_state import SessionDB from tests.cli.test_cli_init import _make_cli +def _composite_carrier(ask="REAL ASK"): + return { + "role": "user", + "content": ( + f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nold task\n\n" + f"{_SUMMARY_END_MARKER}\n\n{ask}" + ), + } + + +def _message_rows(db, session_id): + rows = db._conn.execute( + "SELECT id, content, active FROM messages " + "WHERE session_id = ? ORDER BY id", + (session_id,), + ).fetchall() + return [tuple(row) for row in rows] + + def test_retry_last_truncates_history_before_requeueing_message(): cli = _make_cli() + cli._session_db = None cli.conversation_history = [ {"role": "user", "content": "first"}, {"role": "assistant", "content": "one"}, @@ -31,6 +63,7 @@ def test_retry_last_truncates_history_before_requeueing_message(): def test_process_command_retry_requeues_original_message_not_retry_command(): cli = _make_cli() + cli._session_db = None queued = [] class _Queue: @@ -47,3 +80,299 @@ def put(self, value): assert queued == ["retry me"] assert cli.conversation_history == [] + + +def test_retry_fails_closed_when_warm_and_durable_targets_differ(tmp_path): + cli = _make_cli() + cli._session_db.close() + db = SessionDB(db_path=tmp_path / "state.db") + cli._session_db = db + cli.session_id = "cli-target-mismatch" + db.create_session(cli.session_id, source="cli") + db.append_message(cli.session_id, "user", "DURABLE ASK") + db.append_message(cli.session_id, "assistant", "old answer") + + history = [ + {"role": "user", "content": "WARM ASK"}, + {"role": "assistant", "content": "old answer"}, + ] + cli.conversation_history = history + cli._pending_input = MagicMock() + before_rows = _message_rows(db, cli.session_id) + + cli.process_command("/retry") + + cli._pending_input.put.assert_not_called() + assert cli.conversation_history is history + assert _message_rows(db, cli.session_id) == before_rows + db.close() + + +def test_retry_fails_closed_when_transcript_changes_after_snapshot( + tmp_path, monkeypatch +): + cli = _make_cli() + cli._session_db.close() + db = SessionDB(db_path=tmp_path / "state.db") + sibling = SessionDB(db_path=db.db_path) + cli._session_db = db + cli.session_id = "cli-cas-race" + db.create_session(cli.session_id, source="cli") + db.append_message(cli.session_id, "user", "RETRY ME") + db.append_message(cli.session_id, "assistant", "failed answer") + history = db.get_messages_as_conversation(cli.session_id) + cli.conversation_history = history + original_rewind = db.rewind_to_message + + def _append_then_rewind(*args, **kwargs): + sibling.append_message(cli.session_id, "assistant", "concurrent tail") + return original_rewind(*args, **kwargs) + + monkeypatch.setattr(db, "rewind_to_message", _append_then_rewind) + + assert cli.retry_last() is None + + assert cli.conversation_history is history + rows = db._conn.execute( + "SELECT content, active FROM messages " + "WHERE session_id = ? ORDER BY id", + (cli.session_id,), + ).fetchall() + assert [tuple(row) for row in rows] == [ + ("RETRY ME", 1), + ("failed answer", 1), + ("concurrent tail", 1), + ] + sibling.close() + db.close() + + +@pytest.mark.parametrize("command", ["retry", "undo"]) +def test_rewind_matches_warm_raw_carrier_to_durable_sanitized_sidecar( + tmp_path, command +): + from agent.memory_manager import sanitize_context + + cli = _make_cli() + cli._session_db.close() + db = SessionDB(db_path=tmp_path / "state.db") + cli._session_db = db + cli.session_id = f"cli-sanitized-{command}" + db.create_session(cli.session_id, source="cli") + raw_carrier = _composite_carrier( + " REAL ASK\n\n\nprivate\n " + )["content"] + db.append_message( + cli.session_id, + "user", + sanitize_context(raw_carrier).strip(), + api_content=raw_carrier, + ) + db.append_message(cli.session_id, "assistant", "failed answer") + durable = db.get_messages_as_conversation(cli.session_id) + cli.conversation_history = [ + {"role": "user", "content": raw_carrier}, + durable[1], + ] + cli._pending_input = MagicMock() + cli._prefill_input_buffer = MagicMock() + + if command == "retry": + cli.process_command("/retry") + cli._pending_input.put.assert_called_once_with("REAL ASK") + else: + cli.undo_last() + cli._prefill_input_buffer.assert_called_once_with("REAL ASK") + + assert len(cli.conversation_history) == 1 + scaffold = cli.conversation_history[0] + assert scaffold["display_kind"] == "hidden" + assert "REAL ASK" not in scaffold["content"] + active = db.get_messages_as_conversation(cli.session_id, include_row_ids=True) + assert active[0]["_row_id"] == scaffold["_row_id"] + assert active[0]["content"] == scaffold["content"] + db.close() + + +@pytest.mark.parametrize("command", ["retry", "undo"]) +@pytest.mark.parametrize("prefix_kind", ["buried_ephemeral", "old_media"]) +def test_rewind_keeps_the_richer_warm_prefix_after_validating_the_target( + tmp_path, command, prefix_kind +): + cli = _make_cli() + cli._session_db.close() + db = SessionDB(db_path=tmp_path / "state.db") + cli._session_db = db + cli.session_id = f"cli-projection-{prefix_kind}-{command}" + db.create_session(cli.session_id, source="cli") + + if prefix_kind == "buried_ephemeral": + history = [ + {"role": "user", "content": "OLDER ASK"}, + {"role": "assistant", "content": "candidate answer"}, + { + "role": "user", + "content": "[System: verify before stopping]", + "_verification_stop_synthetic": True, + }, + {"role": "assistant", "content": "verified answer"}, + {"role": "user", "content": "PLAIN TARGET"}, + {"role": "assistant", "content": "failed answer"}, + ] + durable_prefix = [ + ("user", "OLDER ASK"), + ("assistant", "candidate answer"), + ("assistant", "verified answer"), + ] + expected_prefix = ["OLDER ASK", "candidate answer", "verified answer"] + expected_active = [1, 1, 1, 0, 0] + else: + media_content = [ + {"type": "text", "text": "OLDER ASK"}, + {"type": "image_url", "image_url": {"url": "data:image/png,AA"}}, + ] + history = [ + {"role": "user", "content": media_content}, + {"role": "assistant", "content": "older answer"}, + {"role": "user", "content": "PLAIN TARGET"}, + {"role": "assistant", "content": "failed answer"}, + ] + durable_prefix = [ + ("user", "OLDER ASK\n[screenshot]"), + ("assistant", "older answer"), + ] + expected_prefix = [media_content, "older answer"] + expected_active = [1, 1, 0, 0] + + for role, content in durable_prefix: + db.append_message(cli.session_id, role, content) + db.append_message(cli.session_id, "user", "PLAIN TARGET") + db.append_message(cli.session_id, "assistant", "failed answer") + cli.conversation_history = history + cli._pending_input = MagicMock() + cli._prefill_input_buffer = MagicMock() + + if command == "retry": + cli.process_command("/retry") + cli._pending_input.put.assert_called_once_with("PLAIN TARGET") + else: + cli.undo_last() + cli._prefill_input_buffer.assert_called_once_with("PLAIN TARGET") + + assert [message.get("content") for message in cli.conversation_history] == ( + expected_prefix + ) + assert [row[2] for row in _message_rows(db, cli.session_id)] == expected_active + db.close() + + +def test_retry_last_durably_preserves_composite_carrier_scaffold(tmp_path): + cli = _make_cli() + cli._session_db.close() + db = SessionDB(db_path=tmp_path / "state.db") + cli._session_db = db + cli.session_id = "cli-carrier-retry" + db.create_session(cli.session_id, source="cli") + db.append_message(cli.session_id, "user", _composite_carrier()["content"]) + db.append_message(cli.session_id, "assistant", "failed answer") + cli.conversation_history = db.get_messages_as_conversation(cli.session_id) + old_history = cli.conversation_history + cli.agent = SimpleNamespace( + _session_messages=old_history, + _last_flushed_db_idx=len(old_history), + _db_flush_scan_prefix=list(old_history), + ) + + retry_msg = cli.retry_last() + + assert retry_msg == "REAL ASK" + assert len(cli.conversation_history) == 1 + scaffold = cli.conversation_history[0] + assert scaffold["display_kind"] == "hidden" + assert "REAL ASK" not in scaffold["content"] + assert scaffold["_db_persisted"] is True + active = db.get_messages_as_conversation(cli.session_id, include_row_ids=True) + assert len(active) == 1 + assert active[0]["content"] == scaffold["content"] + assert active[0]["_row_id"] == scaffold["_row_id"] + assert cli.agent._session_messages is cli.conversation_history + assert cli.agent._last_flushed_db_idx == 1 + assert cli.agent._db_flush_scan_prefix == cli.conversation_history + db.close() + + +def test_retry_last_rejects_media_before_db_or_memory_mutation(): + cli = _make_cli() + db = MagicMock() + cli._session_db = db + history = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "look again"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}}, + ], + }, + {"role": "assistant", "content": "old answer"}, + ] + cli.conversation_history = history + + assert cli.retry_last() is None + assert cli.conversation_history is history + db.get_messages_as_conversation.assert_not_called() + db.rewind_to_message.assert_not_called() + + +def test_retry_last_db_failure_leaves_warm_history_unchanged(): + cli = _make_cli() + db = MagicMock() + db.get_messages_as_conversation.side_effect = OSError("db unavailable") + cli._session_db = db + history = [ + {"role": "user", "content": "retry me"}, + {"role": "assistant", "content": "old answer"}, + ] + cli.conversation_history = history + + assert cli.retry_last() is None + assert cli.conversation_history is history + + +def test_undo_last_prefills_live_text_and_retains_durable_scaffold(tmp_path): + cli = _make_cli() + cli._session_db.close() + db = SessionDB(db_path=tmp_path / "state.db") + cli._session_db = db + cli.session_id = "cli-carrier-undo" + db.create_session(cli.session_id, source="cli") + db.append_message(cli.session_id, "user", "older ask") + db.append_message(cli.session_id, "assistant", "older answer") + db.append_message(cli.session_id, "user", _composite_carrier()["content"]) + db.append_message(cli.session_id, "assistant", "failed answer") + cli.conversation_history = db.get_messages_as_conversation(cli.session_id) + cli._prefill_input_buffer = MagicMock() + cli.agent = SimpleNamespace( + _session_messages=cli.conversation_history, + _last_flushed_db_idx=len(cli.conversation_history), + _db_flush_scan_prefix=list(cli.conversation_history), + _invalidate_system_prompt=MagicMock(), + _memory_manager=None, + ) + + cli.undo_last() + + cli._prefill_input_buffer.assert_called_once_with("REAL ASK") + assert [m.get("content") for m in cli.conversation_history[:2]] == [ + "older ask", + "older answer", + ] + scaffold = cli.conversation_history[2] + assert scaffold["display_kind"] == "hidden" + assert "REAL ASK" not in scaffold["content"] + assert scaffold["_db_persisted"] is True + active = db.get_messages_as_conversation(cli.session_id, include_row_ids=True) + assert active[2]["_row_id"] == scaffold["_row_id"] + assert active[2]["content"] == scaffold["content"] + assert cli.agent._session_messages is cli.conversation_history + assert cli.agent._last_flushed_db_idx == 3 + db.close() diff --git a/tests/gateway/test_retry_replacement.py b/tests/gateway/test_retry_replacement.py index c83fafbe46da..657c89d37634 100644 --- a/tests/gateway/test_retry_replacement.py +++ b/tests/gateway/test_retry_replacement.py @@ -1,15 +1,31 @@ -"""Regression tests for /retry replacement semantics.""" +"""Regression tests for /retry replacement and carrier-aware undo semantics.""" +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest +from agent.context_compressor import ( + HISTORICAL_TASK_HEADING, + SUMMARY_PREFIX, + _SUMMARY_END_MARKER, +) from gateway.config import GatewayConfig from gateway.platforms.base import MessageEvent, MessageType from gateway.run import GatewayRunner from gateway.session import SessionStore +def _composite_carrier(ask="REAL ASK"): + return { + "role": "user", + "content": ( + f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nold task\n\n" + f"{_SUMMARY_END_MARKER}\n\n{ask}" + ), + } + + @pytest.mark.asyncio async def test_gateway_retry_replaces_last_user_turn_in_transcript(tmp_path, monkeypatch): # Pin DEFAULT_DB_PATH so SessionDB() doesn't write to the real ~/.hermes/state.db. @@ -67,6 +83,191 @@ async def fake_handle_message(event): ] +@pytest.mark.asyncio +async def test_gateway_retry_redispatches_live_carrier_text_and_keeps_scaffold( + tmp_path, monkeypatch +): + import hermes_state + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db") + + config = GatewayConfig() + store = SessionStore(sessions_dir=tmp_path, config=config) + session_id = "retry-carrier-session" + store._db.create_session(session_id=session_id, source="test") + store._db.append_message(session_id, "user", "older ask") + store._db.append_message(session_id, "assistant", "older answer") + store._db.append_message(session_id, "user", _composite_carrier()["content"]) + store._db.append_message(session_id, "assistant", "failed answer") + + gw = GatewayRunner.__new__(GatewayRunner) + gw.config = config + gw.session_store = store + session_entry = MagicMock(session_id=session_id, last_prompt_tokens=123) + gw.session_store.get_or_create_session = MagicMock(return_value=session_entry) + + async def fake_handle_message(event): + assert event.text == "REAL ASK" + active = store.load_transcript(session_id) + assert [m.get("content") for m in active[:2]] == ["older ask", "older answer"] + scaffold = active[2] + assert scaffold["display_kind"] == "hidden" + assert "REAL ASK" not in scaffold["content"] + return "new answer" + + gw._handle_message = AsyncMock(side_effect=fake_handle_message) + + result = await gw._handle_retry_command( + MessageEvent(text="/retry", message_type=MessageType.TEXT, source=MagicMock()) + ) + + assert result == "new answer" + assert session_entry.last_prompt_tokens == 0 + gw._handle_message.assert_awaited_once() + archived = [ + row + for row in store._db.get_messages(session_id, include_inactive=True) + if not row["active"] + ] + assert [row["content"] for row in archived] == [ + _composite_carrier()["content"], + "failed answer", + ] + + +@pytest.mark.asyncio +async def test_gateway_retry_does_not_rewind_a_newer_plain_turn( + tmp_path, monkeypatch +): + """The carrier selected for retry must still be latest at commit time.""" + import hermes_state + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db") + + config = GatewayConfig() + store = SessionStore(sessions_dir=tmp_path, config=config) + session_id = "retry-carrier-race-session" + store._db.create_session(session_id=session_id, source="test") + store._db.append_message(session_id, "user", _composite_carrier()["content"]) + store._db.append_message(session_id, "assistant", "failed answer") + + gw = GatewayRunner.__new__(GatewayRunner) + gw.config = config + gw.session_store = store + session_entry = MagicMock(session_id=session_id, last_prompt_tokens=123) + gw.session_store.get_or_create_session = MagicMock(return_value=session_entry) + original_rewind = store.rewind_session + + def append_newer_turn_then_rewind(*args, **kwargs): + store._db.append_message(session_id, "user", "newer ask") + store._db.append_message(session_id, "assistant", "newer answer") + return original_rewind(*args, **kwargs) + + monkeypatch.setattr(store, "rewind_session", append_newer_turn_then_rewind) + gw._handle_message = AsyncMock() + + result = await gw._handle_retry_command( + MessageEvent(text="/retry", message_type=MessageType.TEXT, source=MagicMock()) + ) + + assert result.startswith("Retry failed;") + assert session_entry.last_prompt_tokens == 123 + gw._handle_message.assert_not_awaited() + assert [ + message.get("content") + for message in store.load_transcript(session_id) + if message.get("role") == "user" + ] == [_composite_carrier()["content"], "newer ask"] + + +@pytest.mark.asyncio +async def test_gateway_retry_rejects_media_before_redispatch_or_token_reset(): + gw = GatewayRunner.__new__(GatewayRunner) + backing_store = MagicMock() + gw.session_store = backing_store + session_entry = SimpleNamespace(session_id="sid", last_prompt_tokens=123) + facade = SimpleNamespace( + _store=backing_store, + get_or_create_session=AsyncMock(return_value=session_entry), + load_transcript=AsyncMock( + return_value=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "look again"}, + {"type": "image_url", "image_url": {"url": "image"}}, + ], + }, + {"role": "assistant", "content": "old answer"}, + ] + ), + rewrite_transcript=AsyncMock(return_value=True), + ) + gw._async_session_store = facade + gw._handle_message = AsyncMock() + + result = await gw._handle_retry_command( + MessageEvent(text="/retry", message_type=MessageType.TEXT, source=MagicMock()) + ) + + assert result.startswith("Cannot retry that message safely:") + assert session_entry.last_prompt_tokens == 123 + gw._handle_message.assert_not_awaited() + facade.rewrite_transcript.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_gateway_retry_stops_when_transcript_rewrite_fails(): + gw = GatewayRunner.__new__(GatewayRunner) + backing_store = MagicMock() + gw.session_store = backing_store + session_entry = SimpleNamespace(session_id="sid", last_prompt_tokens=123) + facade = SimpleNamespace( + _store=backing_store, + get_or_create_session=AsyncMock(return_value=session_entry), + load_transcript=AsyncMock( + return_value=[ + {"role": "user", "content": "retry me"}, + {"role": "assistant", "content": "old answer"}, + ] + ), + rewrite_transcript=AsyncMock(return_value=False), + ) + gw._async_session_store = facade + gw._handle_message = AsyncMock() + + result = await gw._handle_retry_command( + MessageEvent(text="/retry", message_type=MessageType.TEXT, source=MagicMock()) + ) + + assert result.startswith("Retry failed;") + assert session_entry.last_prompt_tokens == 123 + gw._handle_message.assert_not_awaited() + facade.rewrite_transcript.assert_awaited_once() + + +def test_gateway_undo_prefills_live_carrier_text_and_keeps_scaffold( + tmp_path, monkeypatch +): + import hermes_state + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db") + + store = SessionStore(sessions_dir=tmp_path, config=GatewayConfig()) + session_id = "undo-carrier-session" + store._db.create_session(session_id=session_id, source="test") + store._db.append_message(session_id, "user", _composite_carrier()["content"]) + store._db.append_message(session_id, "assistant", "failed answer") + + result = store.rewind_session(session_id) + + assert result["target_text"] == "REAL ASK" + assert result["rewound_count"] == 2 + active = store._db.get_messages_as_conversation( + session_id, include_row_ids=True + ) + assert len(active) == 1 + assert active[0]["display_kind"] == "hidden" + assert "REAL ASK" not in active[0]["content"] + + @pytest.mark.asyncio async def test_gateway_retry_preserves_archived_compaction_rows_when_probe_fails( tmp_path, monkeypatch diff --git a/tests/gateway/test_undo_rewind_session.py b/tests/gateway/test_undo_rewind_session.py index b95f34622a27..10df30a4319a 100644 --- a/tests/gateway/test_undo_rewind_session.py +++ b/tests/gateway/test_undo_rewind_session.py @@ -54,3 +54,32 @@ def test_rewind_n_turns(store): assert len(store.load_transcript(sid)) == 2 # q1,a1 +def test_rewind_fails_closed_when_transcript_changes_after_snapshot( + store, monkeypatch +): + sid = _seed(store, "gw-cas", turns=2) + sibling = SessionDB(db_path=store._db.db_path) + original_rewind = store._db.rewind_to_message + + def _append_then_rewind(*args, **kwargs): + sibling.append_message(sid, "assistant", "concurrent tail") + return original_rewind(*args, **kwargs) + + monkeypatch.setattr(store._db, "rewind_to_message", _append_then_rewind) + + assert store.rewind_session(sid) is None + + rows = store._db._conn.execute( + "SELECT content, active FROM messages " + "WHERE session_id = ? ORDER BY id", + (sid,), + ).fetchall() + assert [tuple(row) for row in rows] == [ + ("q1", 1), + ("a1", 1), + ("q2", 1), + ("a2", 1), + ("concurrent tail", 1), + ] + sibling.close() + diff --git a/tests/hermes_state/test_composite_carrier_rewind.py b/tests/hermes_state/test_composite_carrier_rewind.py new file mode 100644 index 000000000000..444f58495f97 --- /dev/null +++ b/tests/hermes_state/test_composite_carrier_rewind.py @@ -0,0 +1,238 @@ +"""Transactional persistence contracts for composite compaction carriers.""" + +from __future__ import annotations + +import pytest + +from agent.context_compressor import ( + HISTORICAL_TASK_HEADING, + SUMMARY_PREFIX, + _MERGED_SUMMARY_DELIMITER, + _SUMMARY_END_MARKER, +) +from hermes_state import ( + CompressionSessionClosedError, + SessionCompressionInProgressError, + SessionDB, +) + + +def _carrier(ask: str = "REAL ASK") -> str: + return ( + f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nold task\n\n" + f"{_SUMMARY_END_MARKER}\n\n{ask}" + ) + + +@pytest.fixture() +def db(tmp_path): + state = SessionDB(db_path=tmp_path / "state.db") + yield state + state.close() + + +def _session_counts(db: SessionDB, session_id: str) -> tuple[int, int, int]: + row = db._conn.execute( + "SELECT message_count, tool_call_count, rewind_count " + "FROM sessions WHERE id = ?", + (session_id,), + ).fetchone() + return row["message_count"], row["tool_call_count"], row["rewind_count"] + + +def _row_state(db: SessionDB, session_id: str) -> list[tuple]: + return [ + tuple(row) + for row in db._conn.execute( + "SELECT id, role, content, active, display_kind " + "FROM messages WHERE session_id = ? ORDER BY id", + (session_id,), + ).fetchall() + ] + + +def _active_ids(db: SessionDB, session_id: str) -> list[int]: + return [ + int(message["_row_id"]) + for message in db.get_messages_as_conversation( + session_id, include_row_ids=True + ) + ] + + +def test_composite_rewind_archives_tail_and_inserts_its_hidden_scaffold(db): + sid = "carrier-rewind" + db.create_session(sid, source="tui") + db.append_message(sid, "user", "older ask") + db.append_message( + sid, + "assistant", + None, + tool_calls=[{"id": "call-1", "function": {"name": "terminal"}}], + ) + db.append_message(sid, "tool", "ok", tool_call_id="call-1") + target_id = db.append_message(sid, "user", _carrier()) + db.append_message(sid, "assistant", "failed") + expected_active_ids = _active_ids(db, sid) + + result = db.rewind_to_message( + sid, + target_id, + preserve_compaction_handoff=True, + expected_active_ids=expected_active_ids, + expected_target_content="REAL ASK", + ) + + assert result["rewound_count"] == 2 + assert result["replacement_message_id"] == result["new_head_id"] + active = db.get_messages_as_conversation(sid, include_row_ids=True) + assert len(active) == 4 + assert active[-1]["_row_id"] == result["replacement_message_id"] + assert active[-1]["display_kind"] == "hidden" + assert SUMMARY_PREFIX in active[-1]["content"] + assert "REAL ASK" not in active[-1]["content"] + archived = db._conn.execute( + "SELECT active FROM messages WHERE id IN (?, ?) ORDER BY id", + (target_id, target_id + 1), + ).fetchall() + assert [row[0] for row in archived] == [0, 0] + assert _session_counts(db, sid) == (4, 1, 1) + + +def test_default_rewind_return_shape_and_active_counters_remain_compatible(db): + sid = "default-rewind" + db.create_session(sid, source="cli") + db.append_message(sid, "user", "first") + db.append_message(sid, "assistant", "answer") + target_id = db.append_message(sid, "user", "second") + db.append_message( + sid, + "assistant", + None, + tool_calls=[{"id": "call-2", "function": {"name": "terminal"}}], + ) + + result = db.rewind_to_message(sid, target_id) + + assert set(result) == {"rewound_count", "target_message", "new_head_id"} + assert result["rewound_count"] == 2 + assert _session_counts(db, sid) == (2, 0, 1) + + +def test_guarded_composite_rewind_rejects_append_without_inserting_scaffold(db): + sid = "guarded-rewind-append" + db.create_session(sid, source="cli") + db.append_message(sid, "user", "first") + db.append_message(sid, "assistant", "answer") + target_id = db.append_message(sid, "user", _carrier()) + db.append_message(sid, "assistant", "failed") + snapshot = db.get_messages_as_conversation(sid, include_row_ids=True) + expected_active_ids = [int(message["_row_id"]) for message in snapshot] + assert snapshot[-2]["_row_id"] == target_id + + # Deterministic validation -> write race: a sibling writer commits after + # the snapshot but before rewind_to_message begins its write transaction. + sibling = SessionDB(db_path=db.db_path) + sibling.append_message(sid, "assistant", "concurrent append") + sibling.close() + before_rows = _row_state(db, sid) + before_counts = _session_counts(db, sid) + + with pytest.raises(RuntimeError, match="active transcript changed"): + db.rewind_to_message( + sid, + target_id, + preserve_compaction_handoff=True, + expected_active_ids=expected_active_ids, + expected_target_content=_carrier(), + ) + + assert _row_state(db, sid) == before_rows + assert _session_counts(db, sid) == before_counts + +def test_guarded_rewind_rejects_selected_target_content_change(db): + sid = "guarded-rewind-in-place" + db.create_session(sid, source="cli") + db.append_message(sid, "user", "first") + db.append_message(sid, "assistant", "answer") + target_id = db.append_message(sid, "user", "second") + db.append_message(sid, "assistant", "failed") + expected_active_ids = _active_ids(db, sid) + + sibling = SessionDB(db_path=db.db_path) + sibling._execute_write( + lambda conn: conn.execute( + "UPDATE messages SET content = ? WHERE id = ?", + ("changed second", target_id), + ) + ) + sibling.close() + before_rows = _row_state(db, sid) + before_counts = _session_counts(db, sid) + + with pytest.raises(RuntimeError, match="rewind target changed"): + db.rewind_to_message( + sid, + target_id, + expected_active_ids=expected_active_ids, + expected_target_content="second", + ) + + assert _row_state(db, sid) == before_rows + assert _session_counts(db, sid) == before_counts + + +def test_guarded_rewind_ignores_reaction_metadata_change(db): + sid = "guarded-rewind-reaction" + db.create_session(sid, source="cli") + target_id = db.append_message(sid, "user", "second") + db.append_message(sid, "assistant", "failed") + expected_active_ids = _active_ids(db, sid) + + assert db.set_message_reaction(sid, target_id + 1, "👍", author="user") + + result = db.rewind_to_message( + sid, + target_id, + expected_active_ids=expected_active_ids, + expected_target_content="second", + ) + + assert result["rewound_count"] == 2 + assert db.get_messages_as_conversation(sid) == [] + + +def test_rewind_guard_rejects_foreign_live_compression_without_any_change(db): + sid = "locked-rewind" + db.create_session(sid, source="tui") + target_id = db.append_message(sid, "user", _carrier()) + db.append_message(sid, "assistant", "failed") + assert db.try_acquire_compression_lock(sid, "foreign-writer", ttl_seconds=60) + before_rows = _row_state(db, sid) + before_counts = _session_counts(db, sid) + + with pytest.raises(SessionCompressionInProgressError): + db.rewind_to_message( + sid, target_id, preserve_compaction_handoff=True + ) + + assert _row_state(db, sid) == before_rows + assert _session_counts(db, sid) == before_counts + + +def test_rewind_guard_rejects_compression_ended_parent_without_any_change(db): + sid = "closed-rewind" + db.create_session(sid, source="tui") + target_id = db.append_message(sid, "user", _carrier()) + db.append_message(sid, "assistant", "failed") + db.end_session(sid, "compression") + before_rows = _row_state(db, sid) + before_counts = _session_counts(db, sid) + + with pytest.raises(CompressionSessionClosedError): + db.rewind_to_message( + sid, target_id, preserve_compaction_handoff=True + ) + + assert _row_state(db, sid) == before_rows + assert _session_counts(db, sid) == before_counts diff --git a/tests/run_agent/test_identity_flush.py b/tests/run_agent/test_identity_flush.py index 5c8a699ead82..8cb1e54d6d56 100644 --- a/tests/run_agent/test_identity_flush.py +++ b/tests/run_agent/test_identity_flush.py @@ -31,6 +31,55 @@ def _contents(db, session_id=SESSION_ID): class TestIdentityFlush: + def test_summary_flush_hides_pure_handoff_but_not_composite_live_ask(self): + from agent.context_compressor import ( + COMPRESSED_SUMMARY_METADATA_KEY, + HISTORICAL_TASK_HEADING, + SUMMARY_PREFIX, + _SUMMARY_END_MARKER, + ) + from hermes_state import SessionDB + + with tempfile.TemporaryDirectory() as tmpdir: + db = SessionDB(db_path=Path(tmpdir) / "t.db") + try: + agent = _make_agent(db) + scaffold = ( + f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nold\n\n" + f"{_SUMMARY_END_MARKER}" + ) + messages = [ + { + "role": "user", + "content": scaffold, + COMPRESSED_SUMMARY_METADATA_KEY: True, + }, + { + "role": "user", + "content": scaffold + "\n\nREAL ASK", + COMPRESSED_SUMMARY_METADATA_KEY: True, + }, + { + "role": "assistant", + "content": scaffold, + COMPRESSED_SUMMARY_METADATA_KEY: True, + }, + ] + + agent._flush_messages_to_session_db(messages, []) + + rows = db._conn.execute( + "SELECT content, display_kind FROM messages " + "WHERE session_id = ? ORDER BY id", + (SESSION_ID,), + ).fetchall() + assert rows[0]["display_kind"] == "hidden" + assert rows[1]["display_kind"] is None + assert rows[1]["content"].endswith("REAL ASK") + assert rows[2]["display_kind"] == "hidden" + finally: + db.close() + def test_repair_shrunk_messages_below_history_length_still_persists_assistant(self): """When repair shortens messages below conversation_history, don't slice empty.""" from hermes_state import SessionDB diff --git a/tests/run_agent/test_thinking_only_sanitizer.py b/tests/run_agent/test_thinking_only_sanitizer.py index d6875084ea62..f6190fab722a 100644 --- a/tests/run_agent/test_thinking_only_sanitizer.py +++ b/tests/run_agent/test_thinking_only_sanitizer.py @@ -93,6 +93,17 @@ def test_no_thinking_only_messages_is_noop_identity(self): # Should return the original list untouched (identity) when no changes. assert out is msgs + def test_adjacent_users_merge_even_when_no_thinking_row_was_dropped(self): + scaffold = {"role": "user", "content": "SUMMARY SCAFFOLD"} + live_ask = {"role": "user", "content": "REAL ASK"} + msgs = [scaffold, live_ask] + + out = AIAgent._drop_thinking_only_and_merge_users(msgs) + + assert out == [{"role": "user", "content": "SUMMARY SCAFFOLD\n\nREAL ASK"}] + assert scaffold["content"] == "SUMMARY SCAFFOLD" + assert live_ask["content"] == "REAL ASK" + def test_preserves_alternation_after_drop(self): msgs = [ diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 1407c9586fb6..384abd04a0d5 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -9140,6 +9140,7 @@ def restore(self, cwd, target, file_path=None): server._sessions["sid"] = _session( agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=list(history), + session_key="", ) try: resp = server.handle_request( @@ -9200,6 +9201,7 @@ def restore(self, cwd, target, file_path=None): server._sessions["sid"] = _session( agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=list(history), + session_key="", ) try: resp = server.handle_request( @@ -9222,6 +9224,85 @@ def restore(self, cwd, target, file_path=None): # ── session.steer ──────────────────────────────────────────────────── +def test_rollback_restore_preserves_composite_carrier_scaffold(monkeypatch, tmp_path): + """A checkpoint restore drops the live ask but keeps compacted context.""" + from agent.context_compressor import ( + HISTORICAL_TASK_HEADING, + SUMMARY_PREFIX, + _SUMMARY_END_MARKER, + ) + from hermes_state import SessionDB + + class _Mgr: + enabled = True + + def list_checkpoints(self, cwd): + return [{"hash": "abc123"}] + + def restore(self, cwd, target, file_path=None): + return {"success": True, "message": "restored"} + + carrier = { + "role": "user", + "content": ( + f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nold task\n\n" + f"{_SUMMARY_END_MARKER}\n\nREAL ASK" + ), + } + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("rollback-carrier", source="tui") + db.append_message("rollback-carrier", "user", carrier["content"]) + db.append_message("rollback-carrier", "assistant", "answer") + durable = db.get_messages_as_conversation("rollback-carrier") + agent = types.SimpleNamespace( + _checkpoint_mgr=_Mgr(), + _session_messages=list(durable), + _last_flushed_db_idx=len(durable), + _db_flush_scan_prefix=list(durable), + ) + server._sessions["sid"] = _session( + agent=agent, + history=list(durable), + session_key="rollback-carrier", + ) + monkeypatch.setattr(server, "_get_db", lambda: db) + try: + resp = server.handle_request( + { + "id": "1", + "method": "rollback.restore", + "params": {"session_id": "sid", "hash": "abc123"}, + } + ) + + assert "result" in resp, resp + assert resp["result"]["success"] is True + assert resp["result"]["history_removed"] == 2 + remaining = server._sessions["sid"]["history"] + assert len(remaining) == 1 + assert remaining[0]["display_kind"] == "hidden" + assert SUMMARY_PREFIX in remaining[0]["content"] + assert "REAL ASK" not in remaining[0]["content"] + cold = db.get_messages_as_conversation( + "rollback-carrier", include_row_ids=True + ) + assert len(cold) == 1 + assert cold[0]["content"] == remaining[0]["content"] + assert cold[0]["display_kind"] == "hidden" + assert cold[0]["_row_id"] == remaining[0]["_row_id"] + assert agent._session_messages == remaining + assert agent._last_flushed_db_idx == 1 + assert agent._db_flush_scan_prefix == remaining + inactive = db.get_messages_as_conversation( + "rollback-carrier", include_inactive=True + ) + assert any("REAL ASK" in str(message.get("content")) for message in inactive) + assert any(message.get("content") == "answer" for message in inactive) + finally: + server._sessions.pop("sid", None) + db.close() + + def test_session_steer_calls_agent_steer_when_agent_supports_it(): """The TUI RPC method must call agent.steer(text) and return a queued status without touching interrupt state. @@ -9502,6 +9583,7 @@ def test_session_undo_allowed_when_idle(): """Regression guard: when not running, /undo still works.""" server._sessions["sid"] = _session( running=False, + session_key="", history=[ {"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}, diff --git a/tests/tui_gateway/test_composite_carrier_rewind.py b/tests/tui_gateway/test_composite_carrier_rewind.py new file mode 100644 index 000000000000..6af08745f86f --- /dev/null +++ b/tests/tui_gateway/test_composite_carrier_rewind.py @@ -0,0 +1,587 @@ +"""Regression contracts for TUI rewind of live compaction carriers.""" + +from __future__ import annotations + +import threading +from types import SimpleNamespace + +import pytest + +from agent.context_compressor import ( + HISTORICAL_TASK_HEADING, + SUMMARY_PREFIX, + _SUMMARY_END_MARKER, +) +from hermes_state import SessionDB +from tui_gateway import server + + +def _composite_carrier() -> dict: + return { + "role": "user", + "content": ( + f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\nold task\n\n" + f"{_SUMMARY_END_MARKER}\n\nREAL ASK" + ), + } + + +@pytest.fixture() +def carrier_session(tmp_path): + old_db = server._db + db = SessionDB(db_path=tmp_path / "state.db") + installed_ids: list[str] = [] + + def install(history: list[dict]): + sid = f"carrier-sid-{len(installed_ids)}" + session_key = f"carrier-session-{len(installed_ids)}" + installed_ids.append(sid) + db.create_session(session_key, source="tui") + for message in history: + db.append_message( + session_key, + message["role"], + message.get("content"), + ) + durable = db.get_messages_as_conversation(session_key) + agent = SimpleNamespace( + _session_messages=list(durable), + _last_flushed_db_idx=len(durable), + _db_flush_scan_prefix=list(durable), + ) + session = { + "agent": agent, + "attached_images": [], + "history": list(durable), + "history_lock": threading.Lock(), + "history_version": 0, + "running": False, + "session_key": session_key, + } + server._sessions[sid] = session + return sid, session_key, session + + server._db = db + yield db, install + for sid in installed_ids: + server._sessions.pop(sid, None) + server._db = old_db + db.close() + + +def _dispatch(sid: str, name: str) -> dict: + return server._methods["command.dispatch"]( + "request-id", + {"session_id": sid, "name": name, "arg": ""}, + ) + + +def _session_undo(sid: str) -> dict: + return server._methods["session.undo"]( + "request-id", + {"session_id": sid}, + ) + + +def _assert_scaffold_preserved( + db: SessionDB, + session_key: str, + session: dict, + *, + prefix_len: int = 0, +) -> None: + active = db.get_messages_as_conversation(session_key, include_row_ids=True) + assert len(active) == prefix_len + 1 + scaffold = active[prefix_len] + assert scaffold["role"] == "user" + assert scaffold["display_kind"] == "hidden" + assert SUMMARY_PREFIX in scaffold["content"] + assert "REAL ASK" not in scaffold["content"] + assert session["history"][prefix_len]["content"] == scaffold["content"] + assert session["history"][prefix_len]["display_kind"] == "hidden" + + +def test_retry_selects_the_live_ask_inside_a_force_user_leading_carrier( + carrier_session, +): + db, install = carrier_session + sid, session_key, session = install( + [_composite_carrier(), {"role": "assistant", "content": "failed"}] + ) + + response = _dispatch(sid, "retry") + + assert response["result"] == {"type": "send", "message": "REAL ASK"} + _assert_scaffold_preserved(db, session_key, session) + + +@pytest.mark.parametrize("command", ["retry", "undo"]) +def test_rewind_matches_cold_sanitized_carrier_to_unchanged_warm_ask( + carrier_session, command +): + db, install = carrier_session + carrier = _composite_carrier() + carrier["content"] = carrier["content"].replace( + "REAL ASK", + " REAL ASK\n\n\nprivate\n ", + ) + sid, session_key, session = install( + [carrier, {"role": "assistant", "content": "failed"}] + ) + db._conn.execute( + "UPDATE messages SET api_content = ? " + "WHERE session_id = ? AND role = 'user'", + (carrier["content"], session_key), + ) + db._conn.commit() + # A live agent still has the raw wire form while a cold DB projection has + # already applied the role-aware sanitize_context(...).strip() rule and + # retained the raw provider wire in api_content. + session["history"][0] = carrier.copy() + session["agent"]._session_messages = list(session["history"]) + + response = _dispatch(sid, command) + + assert response["result"]["message"] == "REAL ASK" + _assert_scaffold_preserved(db, session_key, session) + + +def test_retry_fails_closed_when_transcript_changes_after_snapshot( + carrier_session, monkeypatch +): + db, install = carrier_session + sid, session_key, session = install( + [_composite_carrier(), {"role": "assistant", "content": "failed"}] + ) + sibling = SessionDB(db_path=db.db_path) + original_rewind = db.rewind_to_message + + def _append_then_rewind(*args, **kwargs): + sibling.append_message(session_key, "assistant", "concurrent tail") + return original_rewind(*args, **kwargs) + + monkeypatch.setattr(db, "rewind_to_message", _append_then_rewind) + before_history = [dict(message) for message in session["history"]] + + response = _dispatch(sid, "retry") + + assert response["error"]["code"] == 5008 + assert "active transcript changed" in response["error"]["message"] + assert session["history"] == before_history + rows = db._conn.execute( + "SELECT content, active, display_kind FROM messages " + "WHERE session_id = ? ORDER BY id", + (session_key,), + ).fetchall() + assert [tuple(row) for row in rows] == [ + (_composite_carrier()["content"], 1, None), + ("failed", 1, None), + ("concurrent tail", 1, None), + ] + sibling.close() + + +@pytest.mark.parametrize("command", ["retry", "undo"]) +def test_rewind_allows_database_only_reaction_metadata_change( + carrier_session, command +): + db, install = carrier_session + sid, session_key, session = install( + [ + {"role": "user", "content": "OLDER ASK"}, + {"role": "assistant", "content": "older answer"}, + _composite_carrier(), + {"role": "assistant", "content": "failed"}, + ] + ) + older_answer = next( + row + for row in db.get_messages(session_key) + if row["role"] == "assistant" and row["content"] == "older answer" + ) + assert db.set_message_reaction( + session_key, older_answer["id"], "👍", author="user" + ) + + response = _dispatch(sid, command) + + assert response["result"]["message"] == "REAL ASK" + _assert_scaffold_preserved(db, session_key, session, prefix_len=2) + + +def test_retry_ignores_buried_ephemeral_scaffolding_missing_from_db( + carrier_session, +): + db, install = carrier_session + sid, session_key, session = install( + [ + {"role": "user", "content": "OLDER ASK"}, + {"role": "assistant", "content": "older answer"}, + _composite_carrier(), + {"role": "assistant", "content": "failed"}, + ] + ) + session["history"].insert( + 2, + { + "role": "user", + "content": "internal recovery nudge", + "_dropped_toolcall_nudge": True, + }, + ) + session["agent"]._session_messages = list(session["history"]) + + response = _dispatch(sid, "retry") + + assert response["result"] == {"type": "send", "message": "REAL ASK"} + _assert_scaffold_preserved(db, session_key, session, prefix_len=2) + + +def test_retry_drops_buried_ephemeral_scaffolding_from_the_warm_prefix( + carrier_session, +): + db, install = carrier_session + sid, session_key, session = install( + [ + {"role": "user", "content": "OLDER ASK"}, + {"role": "assistant", "content": "candidate answer"}, + {"role": "assistant", "content": "verified answer"}, + _composite_carrier(), + {"role": "assistant", "content": "failed"}, + ] + ) + session["history"].insert( + 2, + { + "role": "user", + "content": "[System: verify before stopping]", + "_verification_stop_synthetic": True, + }, + ) + session["agent"]._session_messages = list(session["history"]) + + response = _dispatch(sid, "retry") + + assert response["result"] == {"type": "send", "message": "REAL ASK"} + assert [message.get("content") for message in session["history"][:3]] == [ + "OLDER ASK", + "candidate answer", + "verified answer", + ] + active = db.get_messages_as_conversation(session_key, include_row_ids=True) + # Alternation repair is a model/memory projection; the two durable source + # rows remain independently recoverable ahead of the inserted scaffold. + assert [message.get("content") for message in active[:3]] == [ + "OLDER ASK", + "candidate answer", + "verified answer", + ] + assert active[3]["display_kind"] == "hidden" + assert "REAL ASK" not in active[3]["content"] + assert session["history"][3]["display_kind"] == "hidden" + assert "REAL ASK" not in session["history"][3]["content"] + + +def test_retry_preserves_older_warm_media_while_targeting_plain_ask( + carrier_session, +): + db, install = carrier_session + sid, session_key, session = install( + [ + {"role": "user", "content": "look\n[screenshot]"}, + {"role": "assistant", "content": "seen"}, + _composite_carrier(), + {"role": "assistant", "content": "failed"}, + ] + ) + session["history"][0]["content"] = [ + {"type": "text", "text": "look"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,x"}}, + ] + session["agent"]._session_messages = list(session["history"]) + + response = _dispatch(sid, "retry") + + assert response["result"] == {"type": "send", "message": "REAL ASK"} + assert isinstance(session["history"][0]["content"], list) + _assert_scaffold_preserved(db, session_key, session, prefix_len=2) + + +def test_undo_targets_the_composite_carrier_not_an_older_user_turn( + carrier_session, +): + db, install = carrier_session + sid, session_key, session = install( + [ + {"role": "user", "content": "OLDER ASK"}, + {"role": "assistant", "content": "older answer"}, + _composite_carrier(), + {"role": "assistant", "content": "failed"}, + ] + ) + + response = _dispatch(sid, "undo") + + assert response["result"]["type"] == "prefill" + assert response["result"]["message"] == "REAL ASK" + active = db.get_messages_as_conversation(session_key, include_row_ids=True) + assert [message.get("content") for message in active[:2]] == [ + "OLDER ASK", + "older answer", + ] + _assert_scaffold_preserved(db, session_key, session, prefix_len=2) + + +def test_undo_rewinds_media_placeholder_without_treating_it_as_retry( + carrier_session, +): + db, install = carrier_session + carrier = _composite_carrier() + carrier["content"] = carrier["content"].replace( + "REAL ASK", "look\n[screenshot]" + ) + sid, session_key, session = install( + [carrier, {"role": "assistant", "content": "seen"}] + ) + + response = _dispatch(sid, "undo") + + assert response["result"]["type"] == "prefill" + assert response["result"]["message"] == "look\n[screenshot]" + active = db.get_messages_as_conversation(session_key) + assert len(active) == 1 + assert active[0]["display_kind"] == "hidden" + assert "look\n[screenshot]" not in active[0]["content"] + assert len(session["history"]) == 1 + assert session["history"][0]["content"] == active[0]["content"] + assert session["history"][0]["display_kind"] == "hidden" + + +def test_session_undo_preserves_the_composite_carriers_scaffold(carrier_session): + db, install = carrier_session + sid, session_key, session = install( + [_composite_carrier(), {"role": "assistant", "content": "answer"}] + ) + + response = _session_undo(sid) + + assert response["result"]["removed"] == 2 + _assert_scaffold_preserved(db, session_key, session) + + +def test_history_projection_unwraps_composite_and_hides_sole_handoff(): + composite = {**_composite_carrier(), "_row_id": 7} + sole_handoff = { + **composite, + "content": composite["content"].split("\n\nREAL ASK", 1)[0], + } + + assert server._history_to_messages( + [composite, {"role": "user", "content": "newer ask", "_row_id": 9}] + ) == [ + {"role": "user", "text": "REAL ASK", "row_id": 7}, + {"role": "user", "text": "newer ask", "row_id": 9}, + ] + assert server._history_to_messages([sole_handoff]) == [] + + +def test_retry_preserves_literal_media_like_text(carrier_session): + db, install = carrier_session + carrier = _composite_carrier() + carrier["content"] = carrier["content"].replace( + "REAL ASK", "inspect [image|ybres:RID]" + ) + sid, session_key, session = install( + [carrier, {"role": "assistant", "content": "failed"}] + ) + response = _dispatch(sid, "retry") + + assert response["result"] == { + "type": "send", + "message": "inspect [image|ybres:RID]", + } + _assert_scaffold_preserved(db, session_key, session) + + +def test_retry_rejects_pending_attachments_before_mutating_history(carrier_session): + db, install = carrier_session + sid, session_key, session = install( + [_composite_carrier(), {"role": "assistant", "content": "failed"}] + ) + session["attached_images"] = ["/tmp/pending.png"] + before_memory = list(session["history"]) + + response = _dispatch(sid, "retry") + + assert response["error"]["code"] == 4018 + assert session["history"] == before_memory + assert len(db.get_messages_as_conversation(session_key)) == 2 + +def test_prompt_ordinal_rewind_preserves_scaffold_before_regeneration( + carrier_session, monkeypatch +): + db, install = carrier_session + sid, session_key, session = install( + [_composite_carrier(), {"role": "assistant", "content": "failed"}] + ) + seen = {} + + class _Agent: + _session_messages = list(session["history"]) + _last_flushed_db_idx = len(_session_messages) + _db_flush_scan_prefix = list(_session_messages) + + def run_conversation( + self, prompt, conversation_history=None, stream_callback=None, **_kwargs + ): + seen["prompt"] = prompt + seen["history"] = list(conversation_history or []) + return { + "final_response": "regenerated", + "messages": [ + *(conversation_history or []), + {"role": "user", "content": prompt}, + {"role": "assistant", "content": "regenerated"}, + ], + } + + class _ImmediateThread: + def __init__(self, target=None, daemon=None): + self._target = target + + def start(self): + self._target() + + session["agent"] = _Agent() + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(server, "_get_usage", lambda _agent: {}) + monkeypatch.setattr(server, "render_message", lambda *_args: "") + monkeypatch.setattr(server, "_emit", lambda *_args: None) + + response = server._methods["prompt.submit"]( + "request-id", + { + "session_id": sid, + "text": "EDITED ASK", + "truncate_before_user_ordinal": 0, + "confirm_truncate": True, + }, + ) + + assert response["result"]["status"] == "streaming" + assert seen["prompt"] == "EDITED ASK" + assert len(seen["history"]) == 1 + assert seen["history"][0]["display_kind"] == "hidden" + assert "REAL ASK" not in seen["history"][0]["content"] + active = db.get_messages_as_conversation(session_key, include_row_ids=True) + assert len(active) == 1 + assert active[0]["display_kind"] == "hidden" + + +def test_prompt_row_id_rewind_uses_profile_db_and_rebinds_survivors( + monkeypatch, tmp_path +): + profile_home = tmp_path / "profiles" / "work" + profile_home.mkdir(parents=True) + profile_db = SessionDB(db_path=profile_home / "state.db") + launch_db = SessionDB(db_path=tmp_path / "launch.db") + session_key = "profile-carrier-row-id" + profile_db.create_session(session_key, source="tui") + launch_db.create_session(session_key, source="tui") + launch_db.append_message(session_key, "user", "launch profile must stay untouched") + + carrier = _composite_carrier() + carrier["content"] = carrier["content"].replace( + "REAL ASK", + " REAL ASK\n\nprivate ", + ) + persisted = [ + {"role": "user", "content": "OLDER ASK"}, + {"role": "assistant", "content": "older answer"}, + carrier, + {"role": "assistant", "content": "failed"}, + ] + for message in persisted: + profile_db.append_message( + session_key, + message["role"], + message["content"], + ) + target_row_id = profile_db.get_messages_as_conversation( + session_key, include_row_ids=True + )[2]["_row_id"] + + sid = "profile-carrier-row-id-sid" + session = { + "agent": SimpleNamespace(), + "attached_images": [], + "history": [dict(message) for message in persisted], + "history_lock": threading.Lock(), + "history_version": 0, + "image_counter": 0, + "profile_home": str(profile_home), + "running": False, + "session_key": session_key, + "show_reasoning": False, + "slash_worker": None, + "tool_progress_mode": "all", + } + + class _DormantThread: + def __init__(self, target=None, daemon=None): + self._target = target + + def start(self): + pass + + old_db = server._db + server._db = launch_db + server._sessions[sid] = session + monkeypatch.setattr(server, "_start_agent_build", lambda *_args, **_kwargs: None) + monkeypatch.setattr(server, "_start_inflight_turn", lambda *_args, **_kwargs: None) + monkeypatch.setattr(server.threading, "Thread", _DormantThread) + + try: + response = server._methods["prompt.submit"]( + "request-id", + { + "session_id": sid, + "text": "EDITED ASK", + "truncate_before_row_id": target_row_id, + "truncate_before_user_ordinal": 1, + "confirm_truncate": True, + }, + ) + + assert response["result"]["status"] == "streaming" + survivor_ids = response["result"]["survivor_user_row_ids"] + assert len(survivor_ids) == 1 + assert isinstance(survivor_ids[0], int) + + active = profile_db.get_messages_as_conversation( + session_key, include_row_ids=True + ) + assert [message["content"] for message in active[:2]] == [ + "OLDER ASK", + "older answer", + ] + assert active[0]["_row_id"] == survivor_ids[0] + assert active[2]["display_kind"] == "hidden" + assert "REAL ASK" not in active[2]["content"] + assert "private" not in active[2]["content"] + assert [ + (message["role"], message["content"], message.get("display_kind")) + for message in session["history"] + ] == [ + (message["role"], message["content"], message.get("display_kind")) + for message in active + ] + assert [ + message["content"] + for message in launch_db.get_messages_as_conversation(session_key) + ] == ["launch profile must stay untouched"] + finally: + server._sessions.pop(sid, None) + server._db = old_db + profile_db.close() + launch_db.close() diff --git a/tui_gateway/methods_prompt.py b/tui_gateway/methods_prompt.py index 0e13b4cb2451..ee8d93a6b02d 100644 --- a/tui_gateway/methods_prompt.py +++ b/tui_gateway/methods_prompt.py @@ -14,11 +14,13 @@ def _history_user_indices(history: list) -> list: - """Indices of model-visible user turns (excludes display_kind timeline markers).""" + """Indices of canonical live-user turns, including composite carriers.""" + from agent.context_compressor import user_originated_turn_view + return [ i for i, m in enumerate(history) - if m.get("role") == "user" and not m.get("display_kind") + if user_originated_turn_view(m) is not None ] @@ -50,17 +52,28 @@ def _mem_db_pair_agrees(mem, db_msg) -> bool: return False if mem.get("role") != db_msg.get("role"): return False + if mem.get("role") == "user": + from agent.context_compressor import user_originated_turn_view + from agent.memory_manager import sanitize_context + + mem_view = user_originated_turn_view(mem) + db_view = user_originated_turn_view(db_msg) + if (mem_view is None) != (db_view is None): + return False + if mem_view is None: + return bool(mem.get("display_kind")) == bool( + db_msg.get("display_kind") + ) + mem_content = mem_view.get("content") + db_content = db_view.get("content") + if isinstance(mem_content, str) and isinstance(db_content, str): + if sanitize_context(mem_content).strip() != sanitize_context( + db_content + ).strip(): + return False + return True if bool(mem.get("display_kind")) != bool(db_msg.get("display_kind")): return False - if mem.get("role") == "user" and not mem.get("display_kind"): - mem_content = mem.get("content") - db_content = db_msg.get("content") - if ( - isinstance(mem_content, str) - and isinstance(db_content, str) - and mem_content.strip() != db_content.strip() - ): - return False return True @@ -90,20 +103,15 @@ def _resolve_truncate_row_id(session: dict, history: list, target_row_id: int): return None try: - db = _get_db() - except Exception: - db = None - if db is None: - return None - - get_conv = getattr(db, "get_messages_as_conversation", None) - if not callable(get_conv): - return None - - try: - db_history = get_conv( - session_key, repair_alternation=True, include_row_ids=True - ) + with _session_db(session) as db: + if db is None: + return None + get_conv = getattr(db, "get_messages_as_conversation", None) + if not callable(get_conv): + return None + db_history = get_conv( + session_key, repair_alternation=True, include_row_ids=True + ) except Exception: logger.debug( "prompt.submit: failed loading DB history for row_id %s session %s", @@ -379,7 +387,9 @@ def _(rid, params: dict) -> dict: or truncate_message_id is not None or truncate_row_id is not None ): - history = session.get("history", []) + history = _history_without_ephemeral_scaffolding( + session.get("history", []) + ) # Malformed params refuse first (4004), regardless of consent — # the historical ordinal-path precedence. @@ -504,7 +514,11 @@ def _(rid, params: dict) -> dict: # via replace_messages — an unrecoverable overwrite of the session DB. if ordinal < 0 or ordinal >= len(user_indices): return _err(rid, 4018, "target user message is no longer in session history") - truncated = history[: user_indices[ordinal]] + from agent.context_compressor import history_before_user_originated_turn + + truncated, _live_view = history_before_user_originated_turn( + history, user_indices[ordinal] + ) # Second gate, on top of confirm_truncate: ordinal 0 resolves to # history[:0] == [] and replace_messages() DELETEs every durable # row. A confirmed rewind that happens to erase the whole @@ -547,58 +561,39 @@ def _(rid, params: dict) -> dict: # new exchange is appended on top of the "undone" turns — durable # zombie history on resume, and the edit/regenerate never sticks. # Fail closed: refuse the turn and leave memory/DB unchanged. - if (db := _get_db()) is not None: - try: - # active_only=True: replace only the live (active=1) rows. - # In-place compaction (#38763) keeps the pre-compaction - # transcript as active=0/compacted=1 rows under this same - # session key; a bare replace_messages() would DELETE that - # durable archive on every edit/regenerate — the same bug - # class #80216 fixed for /retry. On an uncompacted session - # all rows are active=1, so this is behaviorally identical - # to the full replace. - # archive_dropped: a rewind overwrites turns the user may - # not have meant to drop, and this write is the last step - # before they are gone — three reported incidents ended - # here with nothing to restore from (#70516, #80763, - # #82756). Soft-archiving keeps them on disk (active=0) and - # in the FTS index, so a mis-aimed cut is recoverable - # instead of terminal. The live transcript is unchanged. - db.replace_messages( - session["session_key"], - truncated, - active_only=True, - archive_dropped=True, - ) - except Exception as exc: - logger.error( - "prompt.submit: replace_messages failed for session %s " - "(ordinal=%d); refusing turn so memory and DB stay " - "aligned: %s", - sid, - ordinal, - exc, - exc_info=True, - ) - return _err( - rid, - 5008, - f"failed to persist history truncation: {exc}", - ) + try: + with _session_db(session) as db: + if db is not None: + # Keep main's durable row-id contract: this rewrite + # stamps fresh row ids onto the same surviving dicts, + # and the response below lets Desktop rebind them. + # ``truncated`` is carrier-aware, so a composite target + # becomes its pure hidden scaffold in the same atomic + # replace that archives the selected row and tail. + db.replace_messages( + session["session_key"], + truncated, + active_only=True, + archive_dropped=True, + ) + except Exception as exc: + logger.error( + "prompt.submit: replace_messages failed for session %s " + "(ordinal=%d); refusing turn so memory and DB stay " + "aligned: %s", + sid, + ordinal, + exc, + exc_info=True, + ) + return _err( + rid, + 5008, + f"failed to persist history truncation: {exc}", + ) session["history"] = truncated session["history_version"] = int(session.get("history_version", 0)) + 1 if db is not None: - # replace_messages re-inserted the surviving prefix as NEW rows - # and stamped fresh _row_id values onto these same dicts. - # Surface the surviving user-turn ids (in visible-user-ordinal - # order) so the client can rebind its cached rowId stamps — - # otherwise a second rewind targeting an older surviving turn - # sends the pre-rewind id and the fail-closed resolver refuses - # it with 4018 (#83202 review: consecutive-rewind staleness). - # Ordinal order matches the client's visible-user filter the - # same way truncate ordinals already do. Entries are None when - # a row somehow has no stamp — the client must drop its cached - # id for that turn rather than keep a stale one. survivor_user_row_ids = [ _message_row_id(truncated[i]) for i in _history_user_indices(truncated) diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index d0dc6d88d701..f139c2edef45 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -2481,25 +2481,34 @@ def _(rid, params: dict) -> dict: ) removed = 0 with session["history_lock"]: - history = session.get("history", []) + if session.get("running"): + return _err( + rid, 4009, "session busy — /interrupt the current turn before /undo" + ) + history = _history_without_ephemeral_scaffolding( + session.get("history", []) + ) # Truncate from the last *real* user turn. Popping only trailing # assistant/tool then one user left timeline markers # (async_delegation_complete, model_switch, …) or compaction # handoffs as the undo target — so session.undo removed # bookkeeping instead of the last exchange (#80622). # Match list_recent_user_messages / CLI turn counting. - from agent.context_compressor import is_user_originated_turn + from agent.context_compressor import user_originated_turn_view - last_user_idx = None - for i in range(len(history) - 1, -1, -1): - msg = history[i] - if is_user_originated_turn(msg): - last_user_idx = i - break - if last_user_idx is not None: - removed = len(history) - last_user_idx - del history[last_user_idx:] - session["history_version"] = int(session.get("history_version", 0)) + 1 + user_indices = [ + index + for index, message in enumerate(history) + if user_originated_turn_view(message) is not None + ] + if user_indices: + try: + _installed, _live_view, rewound_count = ( + _rewind_active_session_history(session, len(user_indices) - 1) + ) + removed = rewound_count + except Exception as exc: + return _err(rid, 5008, f"undo: {exc}") return _ok(rid, {"removed": removed}) diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index aae1f295ad3f..552970788cf0 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -706,41 +706,49 @@ def _(rid, params: dict) -> dict: return _err( rid, 4009, "session busy — /interrupt the current turn before /retry" ) - history = session.get("history", []) - if not history: - return _err(rid, 4018, "no previous user message to retry") - # Walk backwards to the last *real* user turn. Timeline bookkeeping - # rows (display_kind set) and compaction handoffs are durable - # role=user but must not count as user-originated asks — same - # predicate as CLI resume/count and the prompt.submit ordinal fix. - # Without this, /retry re-sends opaque markers (model_switch / - # async_delegation_complete / auto_continue / CONTEXT COMPACTION - # handoffs) and truncates only the marker instead of the failed - # exchange (#80622). - from agent.context_compressor import is_user_originated_turn - - last_user_idx = None - for i in range(len(history) - 1, -1, -1): - msg = history[i] - if is_user_originated_turn(msg): - last_user_idx = i - break - if last_user_idx is None: - return _err(rid, 4018, "no previous user message to retry") - content = history[last_user_idx].get("content", "") - if isinstance(content, list): - content = " ".join( - p.get("text", "") - for p in content - if isinstance(p, dict) and p.get("type") == "text" - ) - if not content: - return _err(rid, 4018, "last user message is empty") - # Truncate history: remove everything from the last user message onward - # (mirrors CLI retry_last() which strips the failed exchange) + from agent.context_compressor import ( + history_before_user_originated_turn, + retryable_user_text, + user_originated_turn_view, + ) + with session["history_lock"]: - session["history"] = history[:last_user_idx] - session["history_version"] = int(session.get("history_version", 0)) + 1 + if session.get("running"): + return _err( + rid, + 4009, + "session busy — /interrupt the current turn before /retry", + ) + if session.get("attached_images"): + return _err( + rid, + 4018, + "retry cannot safely reconstruct or combine attached media", + ) + history = _history_without_ephemeral_scaffolding( + session.get("history", []) + ) + user_indices = [ + index + for index, message in enumerate(history) + if user_originated_turn_view(message) is not None + ] + if not user_indices: + return _err(rid, 4018, "no previous user message to retry") + _prefix, live_view = history_before_user_originated_turn( + history, user_indices[-1] + ) + try: + content = retryable_user_text(live_view.get("content")) + except ValueError as exc: + return _err(rid, 4018, str(exc)) + try: + _active, durable_live_view, _rewound_count = ( + _rewind_active_session_history(session, len(user_indices) - 1) + ) + except Exception as exc: + return _err(rid, 5008, f"retry: failed to persist history: {exc}") + content = retryable_user_text(durable_live_view.get("content")) return _ok(rid, {"type": "send", "message": content}) if name == "steer": @@ -846,9 +854,6 @@ def _(rid, params: dict) -> dict: return _err( rid, 4009, "session busy — /interrupt the current turn before /undo" ) - db = _get_db() - if db is None: - return _db_unavailable_error(rid, code=5008) session_key = session.get("session_key", "") if not session_key: return _err(rid, 4001, "no session key for undo") @@ -862,35 +867,39 @@ def _(rid, params: dict) -> dict: return _err(rid, 4004, f"undo: invalid count {arg_str!r} — use /undo or /undo N") if n < 1: n = 1 - try: - recents = db.list_recent_user_messages(session_key, limit=max(n, 10)) - except Exception as e: - return _err(rid, 5008, f"undo: failed to load history: {e}") - if not recents: - return _err(rid, 4018, "no user messages to undo") - # recents[0] is the most-recent user turn; pick the Nth-from-last. - # If N exceeds the number of user turns, back up to the oldest. - target_idx = min(n - 1, len(recents) - 1) - target_id = recents[target_idx]["id"] - try: - result = db.rewind_to_message(session_key, target_id) - except ValueError as e: - return _err(rid, 4004, f"undo: {e}") - except Exception as e: - return _err(rid, 5008, f"undo: {e}") - # Reload the active-only transcript into the in-memory session - # history so subsequent turns see the truncated view. - # repair_alternation: this reload feeds LIVE REPLAY — session["history"] - # is the working conversation for subsequent turns, and a rewind that - # lands on a durable user;user pair would otherwise re-fire the - # pre-request repair on every request from here on. - try: - active = db.get_messages_as_conversation(session_key, repair_alternation=True) - except Exception: - active = [] + from agent.context_compressor import ( + user_originated_turn_view, + ) + from agent.message_content import flatten_message_text + with session["history_lock"]: - session["history"] = list(active) - session["history_version"] = int(session.get("history_version", 0)) + 1 + if session.get("running"): + return _err( + rid, + 4009, + "session busy — /interrupt the current turn before /undo", + ) + history = _history_without_ephemeral_scaffolding( + session.get("history", []) + ) + user_indices = [ + index + for index, message in enumerate(history) + if user_originated_turn_view(message) is not None + ] + if not user_indices: + return _err(rid, 4018, "no user messages to undo") + turns_undone = min(n, len(user_indices)) + target_position = len(user_indices) - turns_undone + try: + active, live_view, rewound_count = _rewind_active_session_history( + session, target_position + ) + except ValueError as exc: + return _err(rid, 4004, f"undo: {exc}") + except Exception as exc: + return _err(rid, 5008, f"undo: {exc}") + target_text = flatten_message_text(live_view.get("content")) # Notify memory providers — same hook /branch fires, plus the # rewound flag so providers caching per-turn document state # know to invalidate. See #6672 + #21910. @@ -917,18 +926,6 @@ def _(rid, params: dict) -> dict: agent._last_flushed_db_idx = len(active) except Exception: pass - target_msg = result.get("target_message") or {} - target_text = target_msg.get("content") or "" - if isinstance(target_text, list): - parts = [ - p.get("text", "") for p in target_text - if isinstance(p, dict) and p.get("type") == "text" - ] - target_text = "\n".join(t for t in parts if t) - if not isinstance(target_text, str): - target_text = "" - rewound_count = result.get("rewound_count", 0) - turns_undone = target_idx + 1 turn_word = "turn" if turns_undone == 1 else "turns" notice = ( f"↶ Undid {turns_undone} {turn_word} ({rewound_count} message(s)). " @@ -1297,27 +1294,28 @@ def go(mgr, cwd): if result.get("success") and not file_path: removed = 0 with session["history_lock"]: - history = session.get("history", []) - # Truncate from the last *real* user turn. Same predicate - # as list_recent_user_messages / /undo / /retry — - # is_user_originated_turn also excludes compaction - # handoffs (durable role=user, sometimes without - # display_kind on legacy sessions; #80622). - from agent.context_compressor import is_user_originated_turn - - last_user_idx = None - for i in range(len(history) - 1, -1, -1): - msg = history[i] - if is_user_originated_turn(msg): - last_user_idx = i - break - if last_user_idx is not None: - removed = len(history) - last_user_idx - del history[last_user_idx:] - if removed: - session["history_version"] = ( - int(session.get("history_version", 0)) + 1 - ) + history = _history_without_ephemeral_scaffolding( + session.get("history", []) + ) + from agent.context_compressor import user_originated_turn_view + + user_indices = [ + index + for index, message in enumerate(history) + if user_originated_turn_view(message) is not None + ] + if user_indices: + try: + _active, _live_view, removed = ( + _rewind_active_session_history( + session, len(user_indices) - 1 + ) + ) + except Exception as exc: + raise RuntimeError( + "checkpoint restored, but session history rewind " + f"failed: {exc}" + ) from exc result["history_removed"] = removed return result diff --git a/tui_gateway/server.py b/tui_gateway/server.py index fc0c3f02f5a5..179ed6ca881a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2980,6 +2980,140 @@ def _session_db(session: dict): db.close() +def _rewind_active_session_history( + session: dict, user_ordinal: int +) -> tuple[list[dict], dict, int]: + """Rewind one canonical user turn while retaining carrier scaffolding. + + The caller holds ``history_lock``. Persistent sessions archive the target + and tail; a composite carrier's own hidden handoff is inserted in that same + transaction. Memory is installed only after the durable commit and is + built from the already-validated prefix plus the returned scaffold row id, + so there is no fallible post-commit reload. + """ + from agent.context_compressor import ( + history_before_user_originated_turn, + split_user_originated_turn, + user_originated_turn_view, + ) + from agent.memory_manager import sanitize_context + from agent.tool_dispatch_helpers import ( + _is_multimodal_tool_result, + _multimodal_text_summary, + ) + + def _comparison_content(message: dict) -> Any: + content = message.get("content") + if _is_multimodal_tool_result(content): + content = _multimodal_text_summary(content) + elif isinstance(content, list): + text_parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + text_parts.append(str(part.get("text", ""))) + elif ( + isinstance(part, dict) + and part.get("type") in {"image", "image_url", "input_image"} + ): + text_parts.append("[screenshot]") + content = "\n".join(text_parts) if text_parts else None + if message.get("role") in {"user", "assistant"} and isinstance(content, str): + return sanitize_context(content).strip() + return content + + history = _history_without_ephemeral_scaffolding(session.get("history", [])) + user_indices = [ + index + for index, message in enumerate(history) + if user_originated_turn_view(message) is not None + ] + if user_ordinal < 0 or user_ordinal >= len(user_indices): + raise ValueError("target user message is no longer in session history") + target_index = user_indices[user_ordinal] + installed, live_view = history_before_user_originated_turn(history, target_index) + rewound_count = len(history) - target_index + + session_key = str(session.get("session_key") or "").strip() + persisted = False + if session_key: + with _session_db(session) as db: + if db is None: + raise RuntimeError("session database is unavailable") + durable = db.get_messages_as_conversation( + session_key, + include_row_ids=True, + ) + durable_user_indices = [ + index + for index, message in enumerate(durable) + if user_originated_turn_view(message) is not None + ] + if len(durable_user_indices) != len(user_indices): + raise RuntimeError( + "session history changed before the rewind could be persisted" + ) + durable_target_index = durable_user_indices[user_ordinal] + durable_target = durable[durable_target_index] + durable_prefix, durable_live_view = history_before_user_originated_turn( + durable, durable_target_index + ) + if _comparison_content(durable_live_view) != _comparison_content(live_view): + raise RuntimeError( + "session history changed before the rewind could be persisted" + ) + target_row_id = durable_target.get("_row_id") + if not isinstance(target_row_id, int): + raise RuntimeError("rewind target has no durable row identity") + expected_active_ids = [ + int(message["_row_id"]) + for message in durable + if isinstance(message.get("_row_id"), int) + ] + scaffold, _ = split_user_originated_turn(durable_target) + result = db.rewind_to_message( + session_key, + target_row_id, + preserve_compaction_handoff=scaffold is not None, + expected_active_ids=expected_active_ids, + expected_target_content=durable_live_view.get("content"), + ) + if scaffold is not None: + replacement_id = result.get("replacement_message_id") + if not isinstance(replacement_id, int): + raise RuntimeError( + "rewind commit did not return the replacement scaffold id" + ) + durable_prefix[-1]["_row_id"] = replacement_id + durable_prefix[-1]["_db_persisted"] = True + installed[-1] = durable_prefix[-1] + live_view = durable_live_view + rewound_count = int(result.get("rewound_count", 0)) + persisted = True + + installed = [message.copy() for message in installed] + session["history"] = installed + session["history_version"] = int(session.get("history_version", 0)) + 1 + agent = session.get("agent") + if agent is not None: + agent._session_messages = installed + if hasattr(agent, "_last_flushed_db_idx"): + agent._last_flushed_db_idx = len(installed) if persisted else 0 + if hasattr(agent, "_db_flush_scan_prefix"): + agent._db_flush_scan_prefix = installed[:] if persisted else None + return installed, live_view, rewound_count + + +def _history_without_ephemeral_scaffolding(history: list[dict]) -> list[dict]: + """Return the durable transcript shape without transient recovery rows.""" + from run_agent import _is_ephemeral_scaffolding + + return [ + message.copy() + for message in history + if not _is_ephemeral_scaffolding(message) + ] + + def _persist_session_git_meta(session: dict, cwd: str) -> None: """Resolve + persist a session's git branch / repo root WITHOUT blocking. @@ -7190,6 +7324,20 @@ def _history_to_messages(history: list[dict]) -> list[dict]: role = m.get("role") if role not in {"user", "assistant", "tool", "system"}: continue + if role == "user": + from agent.context_compressor import ( + is_compaction_summary_message, + user_originated_turn_view, + ) + + if is_compaction_summary_message(m): + carrier_row_id = m.get("_row_id") + live_view = user_originated_turn_view(m) + if live_view is None: + continue + if carrier_row_id is not None: + live_view["_row_id"] = carrier_row_id + m = live_view # An explicit display_kind="hidden" row is model-facing scaffolding # (compaction references, interrupted-turn checkpoints). The string # sniff below only catches the "[System:" convention; honor the From 67ee481621449bd8fe798d7acb642795660c6399 Mon Sep 17 00:00:00 2001 From: poisdahl <4091911+poisdahl@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:43:00 +0200 Subject: [PATCH 007/666] fix(gateway): preserve pending transcript state on failed rewinds --- gateway/session.py | 182 ++++++++++++------------ tests/gateway/test_retry_replacement.py | 169 ++++++++++++++++++++++ 2 files changed, 263 insertions(+), 88 deletions(-) diff --git a/gateway/session.py b/gateway/session.py index 123deaf0f0f5..efb70d7d4af6 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -3480,17 +3480,21 @@ def peek_session_id(self, session_key: str) -> Optional[str]: entry = self._entries.get(session_key) return getattr(entry, "session_id", None) if entry else None - def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db: bool = False) -> None: - """Serialize transcript draining across queue migration boundaries.""" - if not self._db or skip_db: - return + def _get_transcript_drain_lock(self): + """Return the lock that serializes pending-queue drain boundaries.""" drain_lock = getattr(self, "_transcript_drain_lock", None) if drain_lock is None: # Compatibility for old in-memory/test instances created via # object.__new__ before this field existed. drain_lock = threading.RLock() self._transcript_drain_lock = drain_lock - with drain_lock: + return drain_lock + + def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db: bool = False) -> None: + """Serialize transcript draining across queue migration boundaries.""" + if not self._db or skip_db: + return + with self._get_transcript_drain_lock(): reroutes = getattr(self, "_transcript_reroutes", None) if reroutes is None: reroutes = {} @@ -3844,18 +3848,19 @@ def rewrite_transcript( """ if not self._db: return True - self._clear_dirty_transcript(session_id) - try: - self._db.replace_messages( - session_id, - messages, - active_only=active_only, - reject_active_turn_lease=reject_active_turn_lease, - ) + with self._get_transcript_drain_lock(): + try: + self._db.replace_messages( + session_id, + messages, + active_only=active_only, + reject_active_turn_lease=reject_active_turn_lease, + ) + except Exception as e: + logger.debug("Failed to rewrite transcript in DB: %s", e) + return False + self._clear_dirty_transcript(session_id) return True - except Exception as e: - logger.debug("Failed to rewrite transcript in DB: %s", e) - return False def load_transcript(self, session_id: str) -> List[Dict[str, Any]]: """Load all messages from a session's transcript. @@ -3930,82 +3935,83 @@ def rewind_session( """ if not self._db: return None - self._clear_dirty_transcript(session_id) - if n < 1: - n = 1 - from agent.context_compressor import ( - retryable_user_text, - split_user_originated_turn, - user_originated_turn_view, - ) - - try: - durable = self._db.get_messages_as_conversation( - session_id, - include_row_ids=True, + with self._get_transcript_drain_lock(): + if n < 1: + n = 1 + from agent.context_compressor import ( + retryable_user_text, + split_user_originated_turn, + user_originated_turn_view, ) - expected_active_ids = [ - int(message["_row_id"]) - for message in durable - if isinstance(message.get("_row_id"), int) - ] - user_indices = [ - index - for index, message in enumerate(durable) - if user_originated_turn_view(message) is not None - ] - if not user_indices: + + try: + durable = self._db.get_messages_as_conversation( + session_id, + include_row_ids=True, + ) + expected_active_ids = [ + int(message["_row_id"]) + for message in durable + if isinstance(message.get("_row_id"), int) + ] + user_indices = [ + index + for index, message in enumerate(durable) + if user_originated_turn_view(message) is not None + ] + if not user_indices: + return None + turns_undone = min(n, len(user_indices)) + target = durable[user_indices[-turns_undone]] + target_id = target.get("_row_id") + if not isinstance(target_id, int): + return None + handoff, target_view = split_user_originated_turn(target) + if target_view is None: + return None + if require_retryable_composite: + if handoff is None: + return None + target_text = retryable_user_text(target_view.get("content")) + except Exception as e: + logger.debug("rewind_session: failed to resolve canonical target: %s", e) return None - turns_undone = min(n, len(user_indices)) - target = durable[user_indices[-turns_undone]] - target_id = target.get("_row_id") - if not isinstance(target_id, int): + try: + result = self._db.rewind_to_message( + session_id, + target_id, + preserve_compaction_handoff=handoff is not None, + expected_active_ids=expected_active_ids, + expected_target_content=target_view.get("content"), + ) + except ValueError as e: + logger.debug("rewind_session: %s", e) return None - handoff, target_view = split_user_originated_turn(target) - if target_view is None: + except Exception as e: + logger.debug("rewind_session: rewind_to_message failed: %s", e) return None - if require_retryable_composite: - if handoff is None: - return None - target_text = retryable_user_text(target_view.get("content")) - except Exception as e: - logger.debug("rewind_session: failed to resolve canonical target: %s", e) - return None - try: - result = self._db.rewind_to_message( - session_id, - target_id, - preserve_compaction_handoff=handoff is not None, - expected_active_ids=expected_active_ids, - expected_target_content=target_view.get("content"), - ) - except ValueError as e: - logger.debug("rewind_session: %s", e) - return None - except Exception as e: - logger.debug("rewind_session: rewind_to_message failed: %s", e) - return None - # ``target_view`` is the canonical live projection of the physical DB - # row. For a composite carrier, the raw target contains the historical - # summary wrapper and must never be echoed back as the editable prompt. - if not require_retryable_composite: - content = target_view.get("content") or "" - if isinstance(content, list): - parts = [ - p.get("text", "") - for p in content - if isinstance(p, dict) and p.get("type") == "text" - ] - target_text = "\n".join(t for t in parts if t) - elif isinstance(content, str): - target_text = content - else: - target_text = "" - return { - "rewound_count": result.get("rewound_count", 0), - "turns_undone": turns_undone, - "target_text": target_text, - } + self._clear_dirty_transcript(session_id) + # ``target_view`` is the canonical live projection of the physical DB + # row. For a composite carrier, the raw target contains the historical + # summary wrapper and must never be echoed back as the editable prompt. + if not require_retryable_composite: + content = target_view.get("content") or "" + if isinstance(content, list): + parts = [ + p.get("text", "") + for p in content + if isinstance(p, dict) and p.get("type") == "text" + ] + target_text = "\n".join(t for t in parts if t) + elif isinstance(content, str): + target_text = content + else: + target_text = "" + return { + "rewound_count": result.get("rewound_count", 0), + "turns_undone": turns_undone, + "target_text": target_text, + } def build_session_context( diff --git a/tests/gateway/test_retry_replacement.py b/tests/gateway/test_retry_replacement.py index 35394cc688fd..b3f2e8d96f01 100644 --- a/tests/gateway/test_retry_replacement.py +++ b/tests/gateway/test_retry_replacement.py @@ -1,5 +1,7 @@ """Regression tests for /retry replacement and carrier-aware undo semantics.""" +import os +import threading from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -26,6 +28,173 @@ def _composite_carrier(ask="REAL ASK"): } +def _seed_pending_recovery(store, session_id): + pending = {"role": "assistant", "content": "pending recovery answer"} + store._dirty_transcripts[session_id] = [dict(pending)] + store._transcript_append_failures[session_id] = 3 + return pending + + +def test_rewrite_transcript_keeps_pending_recovery_state_when_lease_rejects( + tmp_path, monkeypatch +): + import hermes_state + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db") + store = SessionStore(sessions_dir=tmp_path, config=GatewayConfig()) + session_id = "rewrite-pending-lease" + store._db.create_session(session_id=session_id, source="test") + store._db.append_message(session_id, "user", "old ask") + pending = _seed_pending_recovery(store, session_id) + before = store._db.get_messages(session_id, include_inactive=True) + holder = f"pid={os.getpid()}:turn=foreign" + assert store._db.try_acquire_session_turn_lease( + session_id, holder, ttl_seconds=60 + ) + + assert not store.rewrite_transcript( + session_id, + [{"role": "user", "content": "replacement ask"}], + active_only=True, + reject_active_turn_lease=True, + ) + + assert store._db.get_messages(session_id, include_inactive=True) == before + assert store._dirty_transcripts[session_id] == [pending] + assert store._transcript_append_failures[session_id] == 3 + + store._db.release_session_turn_lease(session_id, holder) + assert store.rewrite_transcript( + session_id, + [{"role": "user", "content": "replacement ask"}], + active_only=True, + reject_active_turn_lease=True, + ) + assert session_id not in store._dirty_transcripts + assert session_id not in store._transcript_append_failures + + +def test_rewind_session_keeps_pending_recovery_state_when_lease_rejects( + tmp_path, monkeypatch +): + import hermes_state + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db") + store = SessionStore(sessions_dir=tmp_path, config=GatewayConfig()) + session_id = "rewind-pending-lease" + store._db.create_session(session_id=session_id, source="test") + store._db.append_message(session_id, "user", _composite_carrier()["content"]) + store._db.append_message(session_id, "assistant", "old answer") + pending = _seed_pending_recovery(store, session_id) + before = store._db.get_messages(session_id, include_inactive=True) + holder = f"pid={os.getpid()}:turn=foreign" + assert store._db.try_acquire_session_turn_lease( + session_id, holder, ttl_seconds=60 + ) + + assert ( + store.rewind_session(session_id, require_retryable_composite=True) is None + ) + + assert store._db.get_messages(session_id, include_inactive=True) == before + assert store._dirty_transcripts[session_id] == [pending] + assert store._transcript_append_failures[session_id] == 3 + + store._db.release_session_turn_lease(session_id, holder) + result = store.rewind_session( + session_id, require_retryable_composite=True + ) + assert result is not None + assert result["target_text"] == "REAL ASK" + assert session_id not in store._dirty_transcripts + assert session_id not in store._transcript_append_failures + + +@pytest.mark.parametrize("operation", ["rewrite", "rewind"]) +def test_transcript_mutation_serializes_pending_queue_drain( + operation, tmp_path, monkeypatch +): + import hermes_state + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db") + store = SessionStore(sessions_dir=tmp_path, config=GatewayConfig()) + session_id = f"serialized-{operation}" + store._db.create_session(session_id=session_id, source="test") + store._db.append_message(session_id, "user", _composite_carrier()["content"]) + store._db.append_message(session_id, "assistant", "old answer") + _seed_pending_recovery(store, session_id) + + mutation_entered = threading.Event() + release_mutation = threading.Event() + append_started = threading.Event() + append_done = threading.Event() + errors = [] + + if operation == "rewrite": + original_mutation = store._db.replace_messages + + def gated_mutation(*args, **kwargs): + mutation_entered.set() + assert release_mutation.wait(timeout=5) + return original_mutation(*args, **kwargs) + + monkeypatch.setattr(store._db, "replace_messages", gated_mutation) + + def mutate(): + assert store.rewrite_transcript( + session_id, + [{"role": "user", "content": "replacement ask"}], + active_only=True, + ) + + else: + original_mutation = store._db.rewind_to_message + + def gated_mutation(*args, **kwargs): + mutation_entered.set() + assert release_mutation.wait(timeout=5) + return original_mutation(*args, **kwargs) + + monkeypatch.setattr(store._db, "rewind_to_message", gated_mutation) + + def mutate(): + assert store.rewind_session(session_id) is not None + + def run_mutation(): + try: + mutate() + except BaseException as exc: # surface worker failures in the test thread + errors.append(exc) + + def append_after_mutation_starts(): + append_started.set() + try: + store.append_to_transcript( + session_id, + {"role": "assistant", "content": "concurrent answer"}, + ) + except BaseException as exc: # surface worker failures in the test thread + errors.append(exc) + finally: + append_done.set() + + mutation_thread = threading.Thread(target=run_mutation) + mutation_thread.start() + assert mutation_entered.wait(timeout=5) + append_thread = threading.Thread(target=append_after_mutation_starts) + append_thread.start() + assert append_started.wait(timeout=5) + assert not append_done.wait(timeout=0.1) + + release_mutation.set() + mutation_thread.join(timeout=5) + append_thread.join(timeout=5) + assert not mutation_thread.is_alive() + assert not append_thread.is_alive() + assert errors == [] + assert store.load_transcript(session_id)[-1]["content"] == "concurrent answer" + + @pytest.mark.asyncio async def test_gateway_retry_replaces_last_user_turn_in_transcript(tmp_path, monkeypatch): # Pin DEFAULT_DB_PATH so SessionDB() doesn't write to the real ~/.hermes/state.db. From d67583ac66ee805594e67491eb2985e3f96655cf Mon Sep 17 00:00:00 2001 From: poisdahl <4091911+poisdahl@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:27:15 +0200 Subject: [PATCH 008/666] fix(tui): classify repaired rows in rewind rebinds --- tests/test_tui_gateway_server.py | 60 ++++++++++++++++++++++++++++++++ tui_gateway/methods_prompt.py | 26 +++++++++----- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index c44326cb376d..487d1380f8fb 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -19735,6 +19735,66 @@ def test_prompt_submit_consecutive_rewinds_with_returned_survivor_row_ids( server._sessions.pop(sid, None) +def test_prompt_submit_rebind_map_clears_active_row_hidden_by_sequence_repair( + monkeypatch, tmp_path +): + """The bounded map classifies physical active IDs before user;user repair.""" + from hermes_state import SessionDB + + db = SessionDB(db_path=tmp_path / "rowid-repaired-wedge.db") + session_key = "real-db-rowid-repaired-wedge" + db.create_session(session_key, "cli") + physical = [ + {"role": "user", "content": "first fragment"}, + {"role": "user", "content": "second fragment"}, + {"role": "assistant", "content": "combined reply"}, + {"role": "user", "content": "target"}, + {"role": "assistant", "content": "target reply"}, + ] + with db._lock: + db._insert_message_rows(db._conn, session_key, physical) + db._conn.commit() + physical_ids = [message["_row_id"] for message in physical] + repaired = db.get_messages_as_conversation( + session_key, repair_alternation=True, include_row_ids=True + ) + # Provider repair merges the wedge and necessarily drops the second + # physical user's row identity from the replay view. + assert physical_ids[1] not in { + server._message_row_id(message) for message in repaired + } + + sess = _session( + history=[dict(message) for message in repaired], session_key=session_key + ) + sid = "rowid-repaired-wedge-sid" + server._sessions[sid] = sess + monkeypatch.setattr(server, "_get_db", lambda: db) + monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None) + monkeypatch.setattr(server, "_start_inflight_turn", lambda *a, **k: None) + + try: + response = server.handle_request( + { + "id": "1", + "method": "prompt.submit", + "params": { + "session_id": sid, + "text": "retry target", + "truncate_before_row_id": physical_ids[3], + "rebind_survivor_row_ids": [*physical_ids, 999_999], + "confirm_truncate": True, + }, + } + ) + assert response.get("error") is None, response + row_id_map = response["result"]["survivor_row_id_map"] + assert row_id_map[str(physical_ids[1])] is None + assert "999999" not in row_id_map + finally: + server._sessions.pop(sid, None) + + def test_prompt_submit_unconfirmed_truncation_refuses_before_target_resolution( monkeypatch, ): diff --git a/tui_gateway/methods_prompt.py b/tui_gateway/methods_prompt.py index 8d45f0092c07..8eb898ff7129 100644 --- a/tui_gateway/methods_prompt.py +++ b/tui_gateway/methods_prompt.py @@ -85,7 +85,11 @@ def _find_user_turn_by_row_id(history: list, target_row_id: int): return None -def _load_durable_truncation_history(session: dict, fallback_sid: str = ""): +def _load_durable_truncation_history( + session: dict, + fallback_sid: str = "", + repair_alternation: bool = True, +): """Load the durable live-replay transcript, or None when it cannot be proven safe.""" session_key = str(session.get("session_key") or fallback_sid or "") if not session_key: @@ -96,7 +100,9 @@ def _load_durable_truncation_history(session: dict, fallback_sid: str = ""): if not callable(get_conv): return None history = get_conv( - session_key, repair_alternation=True, include_row_ids=True + session_key, + repair_alternation=repair_alternation, + include_row_ids=True, ) except Exception: logger.debug( @@ -695,16 +701,18 @@ def _stale_target_data(resolved_ordinal=None): for message in history if isinstance((row_id := _message_row_id(message)), int) } - if requested_rebind_ids is not None and any( - _message_row_id(message) is None for message in history - ): + if requested_rebind_ids is not None: # Row-id fallback can resolve a durable target even - # when the live list is too misaligned to stamp safely. - # Read the authoritative pre-write active-id set so a - # rewritten row is never mistaken for an untouched + # when the live list is too misaligned to stamp safely, + # and alternation repair can merge a physical user;user + # pair while preserving only the first row id. Read the + # authoritative un-repaired pre-write active-id set so + # a rewritten row is never mistaken for an untouched # archived/ancestor row by the bounded client map. durable_rebind_history = _load_durable_truncation_history( - session, truncation_key + session, + truncation_key, + repair_alternation=False, ) if durable_rebind_history is None: raise RuntimeError( From bed7e975aa14d28ae43c69ba30c289ba467a8853 Mon Sep 17 00:00:00 2001 From: poisdahl <4091911+poisdahl@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:42:25 +0200 Subject: [PATCH 009/666] fix(desktop): omit empty rewind rebind requests --- apps/desktop/src/app/chat/session-tile-actions.ts | 2 +- .../desktop/src/app/session/hooks/use-prompt-actions/index.ts | 2 +- .../src/app/session/hooks/use-prompt-actions/rewind.ts | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/chat/session-tile-actions.ts b/apps/desktop/src/app/chat/session-tile-actions.ts index 8eaf52cf8e47..d1479ba4107c 100644 --- a/apps/desktop/src/app/chat/session-tile-actions.ts +++ b/apps/desktop/src/app/chat/session-tile-actions.ts @@ -37,12 +37,12 @@ import { applyBranchVisibility, applyReloadOptimistic, applyRewindOptimistic, + durableRowIdsForRebind, finalizeInterruptedMessages, planEdit, planReload, planRestore, rebindSurvivorRowIds, - durableRowIdsForRebind, runRewindSubmit, type SurvivorUserRowIds } from '../session/hooks/use-prompt-actions/rewind' diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index a3ecc05807ca..cd261b14cf6c 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -56,12 +56,12 @@ import { applyBranchVisibility, applyReloadOptimistic, applyRewindOptimistic, + durableRowIdsForRebind, finalizeInterruptedMessages, planEdit, planReload, planRestore, rebindSurvivorRowIds, - durableRowIdsForRebind, runRewindSubmit, type SurvivorUserRowIds } from './rewind' diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts index 7ff9dc6db8e0..e964f4d96cd2 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts @@ -341,7 +341,9 @@ export async function runRewindSubmit( // first-active-tip cut; the gateway ignores this when the prefix is // non-empty. ...(resolvedRowId !== undefined && resolvedOrdinal === undefined ? { confirm_empty_truncate: true } : {}), - ...(rebindRowIds ? { rebind_survivor_row_ids: [...new Set(rebindRowIds.filter(Number.isInteger))] } : {}) + ...(rebindRowIds?.length + ? { rebind_survivor_row_ids: [...new Set(rebindRowIds.filter(Number.isInteger))] } + : {}) }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS ) From 9b7ab9d65aeda7c535e0a91e5a1c0d951cb48180 Mon Sep 17 00:00:00 2001 From: Sara Burke Date: Wed, 19 Aug 2026 08:01:29 -0400 Subject: [PATCH 010/666] feat(desktop): route remote bot actions by connection Carry immutable connection and profile ownership through Bot Mode actions, session hydration, transcript loading, new chats, and profile deletion. Keep same-named local and remote profiles isolated, preserve the full context menu, and migrate Bot metadata to connection-qualified storage. Add adversarial routing, alias, migration, cache, and compatibility coverage. --- apps/desktop/electron/connection-registry.ts | 3 + apps/desktop/src/app/chat/index.tsx | 26 +- .../src/app/chat/transcript-backfill.test.ts | 62 + .../src/app/chat/transcript-backfill.ts | 28 +- .../session/hooks/use-route-resume.test.tsx | 57 +- .../src/app/session/hooks/use-route-resume.ts | 11 +- .../hooks/use-session-actions.test.tsx | 159 ++- .../hooks/use-session-actions/index.ts | 77 +- .../hooks/use-session-actions/utils.ts | 33 +- apps/desktop/src/global.d.ts | 1 + apps/desktop/src/hermes.test.ts | 38 + apps/desktop/src/hermes.ts | 59 +- .../src/lib/session-link-title.test.ts | 8 +- .../desktop/src/plugins/hermes-bots/plugin.js | 1081 ++++++++++++----- .../tests/activity-toasts.test.mjs | 2 + .../hermes-bots/tests/bot-delete.test.mjs | 84 +- .../tests/bot-meta-hydrate.test.mjs | 3 +- .../tests/bot-meta-persistence.test.mjs | 2 +- .../tests/canonical-chat-creation.test.mjs | 2 + .../canonical-chat-empty-recovery.test.mjs | 2 + .../tests/canonical-chat-identity.test.mjs | 2 + .../tests/canonical-chat-pin.test.mjs | 2 + .../tests/cross-connection-bots.test.mjs | 66 +- .../tests/embed-real-capabilities.test.mjs | 10 +- .../tests/embed-skills-view.test.mjs | 186 ++- .../hermes-bots/tests/hide-bot-chats.test.mjs | 7 +- .../hermes-bots/tests/hide-bots.test.mjs | 2 +- .../hermes-bots/tests/model-inherit.test.mjs | 7 +- .../tests/multi-source-roster.test.mjs | 24 +- .../tests/profile-prewarm.test.mjs | 20 +- .../tests/remote-dm-delivery.test.mjs | 55 + .../tests/remote-routing-races.test.mjs | 521 ++++++++ .../hermes-bots/tests/roster-groups.test.mjs | 16 +- .../hermes-bots/tests/routine-owner.test.mjs | 6 +- .../hermes-bots/tests/routine-prompt.test.mjs | 4 +- .../tests/routines-profile-scope.test.mjs | 8 +- .../tests/session-workspace.test.mjs | 16 +- apps/desktop/src/sdk/index.ts | 77 +- apps/desktop/src/sdk/profile-routing.test.ts | 112 +- apps/desktop/src/store/profile.ts | 33 + .../src/store/session-request-router.test.ts | 67 + .../src/store/session-request-router.ts | 51 +- apps/desktop/src/store/session.test.ts | 31 + apps/desktop/src/store/session.ts | 65 +- apps/desktop/src/store/transcript-tail.ts | 135 +- 45 files changed, 2796 insertions(+), 465 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs diff --git a/apps/desktop/electron/connection-registry.ts b/apps/desktop/electron/connection-registry.ts index c9dcc09b2c88..827127af0bb1 100644 --- a/apps/desktop/electron/connection-registry.ts +++ b/apps/desktop/electron/connection-registry.ts @@ -255,6 +255,8 @@ export interface RosterAgent { connectionKind: ConnectionKind connectionLabel: string profile: string + /** Backend profile when the registry route maps the Desktop profile name. */ + targetProfile?: string /** Bare profile name, or `-` when the profile name * exists on more than one registered source (the @name-device rule). */ handle: string @@ -403,6 +405,7 @@ export function buildAgentRoster( connectionKind: connection.kind, connectionLabel: connection.label, profile, + targetProfile: connection.remoteProfile || profile, handle: agentHandle(profile, connection.label, (counts.get(profile) || 0) > 1) }) } diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 13ed225186d9..6e8f149c008a 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -32,19 +32,21 @@ import { $petOverlayActive } from '@/store/pet-overlay' import { $activeGatewayProfile, $gatewaySwapTarget, $profiles } from '@/store/profile' import { $contextSuggestions, + $connection, $freshDraftReady, $gatewayState, $introPersonality, $introSeed, $resumeExhaustedSessionId, $sessions, + getSessionOwnerHint, resolveComposerSessionKey, sessionMatchesStoredId, sessionPinId, shouldMigrateComposerScope } from '@/store/session' import { sessionTileDelegate } from '@/store/session-states' -import { $transcriptTailBySessionId } from '@/store/transcript-tail' +import { $transcriptTailBySessionId, transcriptTailState } from '@/store/transcript-tail' import { isAuxiliaryWindow, isWatchWindow } from '@/store/windows' import type { ModelOptionsResponse } from '@/types/hermes' @@ -261,10 +263,20 @@ function ChatRuntimeBoundary({ const runtimeMessageRepository = useRuntimeMessageRepository(windowedMessages) const storedId = useStore(view.$storedId) + const connection = useStore($connection) + const activeProfile = useStore($activeGatewayProfile) // Subscribed (not read imperatively) so the "Show earlier" affordance // appears/retires as tail hydrations and backfill pages record their state. const transcriptTailStates = useStore($transcriptTailBySessionId) - const restBackfillAvailable = Boolean(storedId && transcriptTailStates[storedId]?.possiblyTruncated) + const connectionId = connection?.connectionId || (connection?.mode === 'local' ? 'local' : '') + const ownerRoute = storedId + ? getSessionOwnerHint(storedId, connectionId ? { connectionId, profile: activeProfile } : undefined) + : undefined + const tailProfile = ownerRoute + ? { connectionId: ownerRoute.connectionId, profile: ownerRoute.targetProfile || ownerRoute.profile } + : undefined + const tailState = storedId && transcriptTailStates ? transcriptTailState(storedId, tailProfile) : undefined + const restBackfillAvailable = Boolean(tailState?.possiblyTruncated) const expandWindow = useCallback(() => { // The store window still holds older messages: growing pages is enough. @@ -273,9 +285,15 @@ function ChatRuntimeBoundary({ // PREPEND it to the session store before growing, so the grown window has // something older to show. Fire-and-forget: the prepend lands through the // session-state write path and re-renders this boundary. - if (!windowStateRef.current?.window.windowed && runtimeId && storedId && transcriptBackfillAvailable(storedId)) { + if ( + !windowStateRef.current?.window.windowed && + runtimeId && + storedId && + transcriptBackfillAvailable(storedId, tailProfile) + ) { void backfillOlderTranscriptPage({ storedSessionId: storedId, + profile: tailProfile, // Stale-response guard: a session switch remounts/re-keys this view; // checking the live atoms (not captured props) discards a page that // resolves after the user moved on — same pattern as isCurrentResume. @@ -291,7 +309,7 @@ function ChatRuntimeBoundary({ } setWindowPages(pages => pages + 1) - }, [runtimeId, storedId, view]) + }, [runtimeId, storedId, tailProfile, view]) const olderAvailable = windowed || restBackfillAvailable diff --git a/apps/desktop/src/app/chat/transcript-backfill.test.ts b/apps/desktop/src/app/chat/transcript-backfill.test.ts index 6863ad61b562..5a020b51ef22 100644 --- a/apps/desktop/src/app/chat/transcript-backfill.test.ts +++ b/apps/desktop/src/app/chat/transcript-backfill.test.ts @@ -68,6 +68,40 @@ describe('transcript tail bookkeeping', () => { expect(transcriptBackfillAvailable('stored-1')).toBe(false) }) + + it('counts the same session id on separate connections as separate entries', () => { + const page = { + messages: [row(1, 'tail')], + pagination: { limit: 120, offset: 0, order: 'latest' as const, returned: 1 } + } + const sourceA = { connectionId: 'source-a', profile: 'backend' } + const sourceB = { connectionId: 'source-b', profile: 'backend' } + + recordTranscriptTail('same-session', page, sourceA) + recordTranscriptTail('same-session', page, sourceB) + + expect(Object.keys($transcriptTailBySessionId.get())).toHaveLength(2) + expect(transcriptTailState('same-session', sourceA)?.profile).toEqual(sourceA) + expect(transcriptTailState('same-session', sourceB)?.profile).toEqual(sourceB) + expect(transcriptTailState('same-session')).toBeUndefined() + }) + + it('bounds entries and deterministically evicts the oldest scoped identity', () => { + const page = { + messages: [row(1, 'tail')], + pagination: { limit: 120, offset: 0, order: 'latest' as const, returned: 1 } + } + const scope = { connectionId: 'source-a', profile: 'backend' } + + for (let index = 0; index < 257; index += 1) { + recordTranscriptTail(`bounded-${index}`, page, scope) + } + + expect(Object.keys($transcriptTailBySessionId.get())).toHaveLength(256) + expect(transcriptTailState('bounded-0', scope)).toBeUndefined() + expect(transcriptTailState('bounded-1', scope)).toBeDefined() + expect(transcriptTailState('bounded-256', scope)).toBeDefined() + }) }) describe('mergeOlderTranscriptPage', () => { @@ -165,6 +199,34 @@ describe('backfillOlderTranscriptPage', () => { expect(transcriptBackfillAvailable('stored-1')).toBe(false) }) + it('backfills the matching connection when two owners share one session id', async () => { + const sourceA = { connectionId: 'source-a', profile: 'backend-a' } + const sourceB = { connectionId: 'source-b', profile: 'backend-b' } + const page = { + messages: Array.from({ length: 120 }, (_, index) => row(index, `tail${index}`)), + pagination: { limit: 120, offset: 0, order: 'latest' as const, returned: 120 } + } + recordTranscriptTail('same-session', page, sourceA) + recordTranscriptTail('same-session', page, sourceB) + vi.mocked(getOlderSessionMessages).mockResolvedValue({ + messages: [row(1, 'older')], + pagination: { limit: 120, offset: 120, order: 'latest', returned: 1 }, + session_id: 'same-session' + } as never) + + await backfillOlderTranscriptPage({ + storedSessionId: 'same-session', + profile: sourceB, + isCurrent: () => true, + applyOlderPage: vi.fn() + }) + + expect(getOlderSessionMessages).toHaveBeenCalledWith('same-session', sourceB, 120) + expect(transcriptTailState('same-session', sourceA)).toMatchObject({ possiblyTruncated: true }) + expect(transcriptTailState('same-session', sourceB)).toMatchObject({ possiblyTruncated: false }) + expect(transcriptTailState('same-session')).toBeUndefined() + }) + it('keeps backfill available while pages keep coming back full', async () => { truncatedTail() vi.mocked(getOlderSessionMessages).mockResolvedValue({ diff --git a/apps/desktop/src/app/chat/transcript-backfill.ts b/apps/desktop/src/app/chat/transcript-backfill.ts index fb6ff3d3d265..43b41354663d 100644 --- a/apps/desktop/src/app/chat/transcript-backfill.ts +++ b/apps/desktop/src/app/chat/transcript-backfill.ts @@ -17,11 +17,18 @@ import { getOlderSessionMessages } from '@/hermes' import { type ChatMessage, toChatMessages } from '@/lib/chat-messages' -import { recordTranscriptBackfillPage, transcriptTailState } from '@/store/transcript-tail' +import { + recordTranscriptBackfillPage, + type TranscriptProfileScope, + transcriptTailState +} from '@/store/transcript-tail' /** Older rows likely exist beyond what the in-memory store holds. */ -export function transcriptBackfillAvailable(storedSessionId: null | string | undefined): boolean { - return Boolean(transcriptTailState(storedSessionId)?.possiblyTruncated) +export function transcriptBackfillAvailable( + storedSessionId: null | string | undefined, + profile?: TranscriptProfileScope +): boolean { + return Boolean(transcriptTailState(storedSessionId, profile)?.possiblyTruncated) } /** @@ -92,6 +99,8 @@ export function graftRefreshedTailOntoBackfill(refreshedTail: ChatMessage[], pre export interface BackfillRequest { /** Durable stored session id — the tail bookkeeping key. */ storedSessionId: string + /** Owner scope captured when the tail was hydrated. */ + profile?: TranscriptProfileScope /** Stale-response guard: called after the fetch resolves; when it reports * false (the user switched sessions mid-flight) the page is discarded and * the bookkeeping is left untouched, mirroring the isCurrentResume() @@ -118,15 +127,16 @@ export function _resetTranscriptBackfillForTests(): void { * for the same session share one fetch. */ export function backfillOlderTranscriptPage(request: BackfillRequest): Promise { - const { storedSessionId } = request - const inflight = inflightByStoredSessionId.get(storedSessionId) + const { profile, storedSessionId } = request + const inflightKey = JSON.stringify([profile || null, storedSessionId]) + const inflight = inflightByStoredSessionId.get(inflightKey) if (inflight) { return inflight } const run = (async () => { - const tail = transcriptTailState(storedSessionId) + const tail = transcriptTailState(storedSessionId, profile) if (!tail?.possiblyTruncated) { return false @@ -152,15 +162,15 @@ export function backfillOlderTranscriptPage(request: BackfillRequest): Promise { - inflightByStoredSessionId.delete(storedSessionId) + inflightByStoredSessionId.delete(inflightKey) }) - inflightByStoredSessionId.set(storedSessionId, run) + inflightByStoredSessionId.set(inflightKey, run) return run } diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx index b24636998682..ca27979e955f 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx @@ -3,6 +3,7 @@ import type { MutableRefObject } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { $resumeExhaustedSessionId, setResumeExhaustedSessionId } from '@/store/session' +import type { SessionProfileRoute } from '@/store/session-request-router' import { markSelectionRestore } from '@/store/session-states' import { useRouteResume } from './use-route-resume' @@ -20,10 +21,10 @@ interface HarnessProps { freshDraftReady: boolean gatewayState: string locationPathname: string - resumeSession: (sessionId: string, focus: boolean) => Promise + resumeSession: (sessionId: string, focus: boolean, ownerRoute?: SessionProfileRoute) => Promise resumeFailedSessionId?: null | string resumeExhaustedSessionId?: null | string - sessionResumeRequest?: null | { sequence: number; sessionId: string } + sessionResumeRequest?: null | { ownerRoute?: SessionProfileRoute; sequence: number; sessionId: string } routedSessionId: null | string runtimeIdByStoredSessionIdRef: MutableRefObject> selectedStoredSessionId: null | string @@ -343,6 +344,58 @@ describe('useRouteResume', () => { expect(resumeSession).toHaveBeenCalledTimes(1) expect(resumeSession).toHaveBeenCalledWith('session-1', true) }) + + it('does not reuse a stale plugin owner route after pathname navigation', () => { + const resumeSession = vi.fn(async () => undefined) + const startFreshSessionDraft = vi.fn() + const activeSessionIdRef: MutableRefObject = { current: 'runtime-1' } + const creatingSessionRef = { current: false } + const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) } + const selectedStoredSessionIdRef: MutableRefObject = { current: 'session-1' } + const ownerRoute: SessionProfileRoute = { + connectionId: 'source-a', + mode: 'remote', + profile: 'worker', + targetProfile: 'backend-worker' + } + const request = { ownerRoute, sequence: 1, sessionId: 'session-1' } + const props = { + activeSessionId: 'runtime-1', + activeSessionIdRef, + creatingSessionRef, + currentView: 'chat', + freshDraftReady: false, + gatewayState: 'open', + resumeSession, + runtimeIdByStoredSessionIdRef, + selectedStoredSessionId: 'session-1', + selectedStoredSessionIdRef, + sessionResumeRequest: request, + startFreshSessionDraft + } + + const { rerender } = render( + + ) + + expect(resumeSession).toHaveBeenCalledWith('session-1', true, ownerRoute) + resumeSession.mockClear() + + rerender( + + ) + + expect(resumeSession).toHaveBeenCalledTimes(1) + expect(resumeSession).toHaveBeenCalledWith('session-2', true) + }) }) describe('useRouteResume bounded auto-retry after a failed resume', () => { diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.ts b/apps/desktop/src/app/session/hooks/use-route-resume.ts index 597f72bb2cfb..af51055dec24 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.ts +++ b/apps/desktop/src/app/session/hooks/use-route-resume.ts @@ -2,6 +2,7 @@ import { type MutableRefObject, useEffect, useRef } from 'react' import { isNewChatRoute } from '@/app/routes' import { type SessionResumeRequest, setResumeExhaustedSessionId } from '@/store/session' +import type { SessionProfileRoute } from '@/store/session-request-router' import { markSelectionRestore } from '@/store/session-states' interface RouteResumeOptions { @@ -12,7 +13,7 @@ interface RouteResumeOptions { freshDraftReady: boolean gatewayState: string | undefined locationPathname: string - resumeSession: (sessionId: string, focus: boolean) => Promise + resumeSession: (sessionId: string, focus: boolean, ownerRoute?: SessionProfileRoute) => Promise // Stored-session id whose most recent resume failed terminally (set by // useSessionActions, mirrored from $resumeFailedSessionId). While this equals // routedSessionId the window would otherwise latch on the loader forever, so @@ -177,7 +178,13 @@ export function useRouteResume({ } bootResumeRef.current = false - void resumeSession(routedSessionId, true) + const ownerRoute = + sessionResumeRequest?.sessionId === routedSessionId ? sessionResumeRequest.ownerRoute : undefined + if (ownerRoute) { + void resumeSession(routedSessionId, true, ownerRoute) + } else { + void resumeSession(routedSessionId, true) + } } return diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 5a0c89a754a7..66c1713eb11c 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -15,7 +15,14 @@ import { } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer' -import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile } from '@/store/profile' +import { + $activeGatewayProfile, + $newChatProfile, + $newChatRoute, + ensureGatewayProfile +} from '@/store/profile' +import { requestGatewayForAgent } from '@/store/gateway' +import type { SessionProfileRoute } from '@/store/session-request-router' import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' import { $activeSessionId, @@ -68,9 +75,15 @@ vi.mock('@/hermes', async importOriginal => ({ vi.mock('@/store/profile', async importOriginal => ({ ...(await importOriginal>()), + ensureGatewayAgent: vi.fn().mockResolvedValue(undefined), ensureGatewayProfile: vi.fn().mockResolvedValue(undefined) })) +vi.mock('@/store/gateway', async importOriginal => ({ + ...(await importOriginal>()), + requestGatewayForAgent: vi.fn() +})) + vi.mock('@/components/pane-shell/tree/store', async importOriginal => ({ ...(await importOriginal>()), noteActiveTreeGroup: vi.fn(), @@ -477,6 +490,7 @@ describe('createBackendSessionForSend profile routing', () => { afterEach(() => { cleanup() $newChatProfile.set(null) + $newChatRoute.set(null) $activeGatewayProfile.set('default') $projectScope.set(ALL_PROJECTS) $projectTree.set([]) @@ -534,6 +548,40 @@ describe('createBackendSessionForSend profile routing', () => { expect(params).toMatchObject({ cwd: '/remote/worktree' }) }) + it('keeps a route-aware New Chat pinned when foreground activation changes before Send', async () => { + const route = { + connectionId: 'source-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'backend-default' + } + const ambientRequest = vi.fn(async () => ({}) as never) + vi.mocked(requestGatewayForAgent).mockResolvedValueOnce({ + session_id: RUNTIME_SESSION_ID, + stored_session_id: null + } as never) + + $newChatProfile.set(route.profile) + $newChatRoute.set({ ...route }) + $activeGatewayProfile.set('other-connection-profile') + + let handle: HarnessHandle | null = null + render( (handle = value)} requestGateway={ambientRequest} />) + await waitFor(() => expect(handle).not.toBeNull()) + + await act(async () => { + await handle!.createBackendSessionForSend() + }) + + expect(requestGatewayForAgent).toHaveBeenCalledWith( + 'source-a', + 'default', + 'session.create', + expect.objectContaining({ profile: 'backend-default', source: 'desktop' }) + ) + expect(ambientRequest).not.toHaveBeenCalledWith('session.create', expect.anything()) + }) + it('freezes the visible selector state before profile readiness and sends fast: false explicitly', async () => { const profileReady = deferred() vi.mocked(ensureGatewayProfile).mockReturnValueOnce(profileReady.promise) @@ -618,7 +666,13 @@ function ResumeHarness({ sessionStateByRuntimeIdRef }: { onStateUpdate?: (sessionId: string, state: ClientSessionState) => void - onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise) => void + onReady: ( + resume: ( + storedSessionId: string, + replaceRoute?: boolean, + ownerRoute?: SessionProfileRoute + ) => Promise + ) => void requestGateway: (method: string, params?: Record) => Promise runtimeIdByStoredSessionIdRef?: MutableRefObject> selectedStoredSessionId?: string | null @@ -1700,9 +1754,110 @@ describe('resumeSession warm-cache mapping integrity', () => { setResumeFailedSessionId(null) setMessages([]) setSessions([]) + vi.mocked(getSession).mockReset() + vi.mocked(getLatestSessionMessages).mockReset().mockResolvedValue({ messages: [] } as never) + vi.mocked(requestGatewayForAgent).mockReset() vi.restoreAllMocks() }) + it('pins metadata, transcript, resume, activate, and usage to the captured connection', async () => { + const ownerRoute: SessionProfileRoute = { + connectionId: 'source-a', + mode: 'remote', + profile: 'default', + targetProfile: 'backend-default' + } + const runtimeIdByStoredSessionIdRef: MutableRefObject> = { + current: new Map([['stored-warm', 'runtime-warm']]) + } + const sessionStateByRuntimeIdRef: MutableRefObject> = { + current: new Map([['runtime-warm', clientState('stored-warm')]]) + } + // Same-name rows without a source tag are not authoritative for an + // explicit owner. Metadata must be re-read from the captured connection. + setSessions([storedSession({ id: 'stored-warm', profile: 'default' })]) + vi.mocked(getSession).mockImplementation(async id => storedSession({ id, profile: 'default' })) + vi.mocked(getLatestSessionMessages).mockImplementation(async id => ({ messages: [], session_id: id }) as never) + vi.mocked(requestGatewayForAgent).mockImplementation(async (_connectionId, _profile, method, params) => { + if (method === 'session.activate') { + throw new Error('Method not found') + } + + if (method === 'session.usage') { + return { input: 1, output: 2, total: 3 } as never + } + + if (method === 'session.resume') { + return { + info: {}, + messages: [], + resumed: params?.session_id, + session_id: 'runtime-cold' + } as never + } + + return {} as never + }) + + const ambientRequest = vi.fn(async () => ({}) as never) + let resume: null | (( + storedSessionId: string, + replaceRoute?: boolean, + ownerRoute?: SessionProfileRoute + ) => Promise) = null + render( + (resume = ready)} + requestGateway={ambientRequest} + runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef} + sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef} + /> + ) + await waitFor(() => expect(resume).not.toBeNull()) + + await resume!('stored-warm', true, ownerRoute) + await resume!('stored-cold', true, ownerRoute) + await resume!('stored-cold', true, { + connectionId: 'source-b', + mode: 'remote', + profile: 'default', + targetProfile: 'backend-b' + }) + + const restScope = { connectionId: 'source-a', profile: 'backend-default' } + expect(getSession).toHaveBeenCalledWith('stored-warm', restScope) + expect(getSession).toHaveBeenCalledWith('stored-cold', restScope) + expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-warm', restScope) + expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-cold', restScope) + expect(getSession).toHaveBeenCalledWith('stored-cold', { + connectionId: 'source-b', + profile: 'backend-b' + }) + expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-cold', { + connectionId: 'source-b', + profile: 'backend-b' + }) + expect(requestGatewayForAgent).toHaveBeenCalledWith( + 'source-a', + 'default', + 'session.activate', + expect.objectContaining({ session_id: 'runtime-warm' }) + ) + expect(requestGatewayForAgent).toHaveBeenCalledWith( + 'source-a', + 'default', + 'session.usage', + { session_id: 'runtime-warm' } + ) + expect(requestGatewayForAgent).toHaveBeenCalledWith( + 'source-a', + 'default', + 'session.resume', + expect.objectContaining({ session_id: 'stored-cold' }) + ) + expect(ambientRequest).not.toHaveBeenCalled() + }) + it('rejects a cross-wired runtime mapping and falls through to a full resume', async () => { // A recycled runtime id ('rt-recycled') is mapped to 'stored-A', but its // cached state actually belongs to a DIFFERENT session ('stored-B') — the diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 572c7e1f6205..bbe8eb29a440 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -14,12 +14,14 @@ import { normalizeChoices, setClarifyRequest } from '@/store/clarify' import { migrateSessionDraft } from '@/store/composer' import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue' import { $gatewaySwitching } from '@/store/gateway-switch' +import { requestGatewayForAgent } from '@/store/gateway' import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, $gatewaySwapTarget, $newChatProfile, + $newChatRoute, ensureGatewayAgent, ensureGatewayProfile, normalizeProfileKey @@ -64,11 +66,12 @@ import { setSelectedStoredSessionId, setSessions, setSessionStartedAt, + getSessionOwnerHint, setTurnStartedAt, setWorkspaceCwdOwner, setYoloActive } from '@/store/session' -import { requestForSessionProfile } from '@/store/session-request-router' +import { requestForSessionProfile, type SessionOwnerScope, type SessionProfileRoute } from '@/store/session-request-router' import { $sessionTiles, closeSessionTile, @@ -188,7 +191,10 @@ function reconcileAuthoritativeMessages( // A no-op for single-profile/local-pooled users (a backend resolves its own launch // profile to None). The sticky UI model/effort/fast ride as per-session overrides, // never the profile default (that lives in Settings → Model). -async function desktopSessionCreateParams(cwd: string): Promise> { +async function desktopSessionCreateParams( + cwd: string, + capturedRoute = $newChatRoute.get() +): Promise> { // Treat Send as the linearization point for the visible selector state. The // profile handshake below can yield long enough for background config/model // refreshes to finish; reading atoms afterward would silently create the @@ -200,14 +206,19 @@ async function desktopSessionCreateParams(cwd: string): Promise('session.create', params) + const capturedRoute = $newChatRoute.get() + const params = await desktopSessionCreateParams(cwd, capturedRoute) + const created = capturedRoute + ? await requestGatewayForAgent( + capturedRoute.connectionId, + capturedRoute.profile, + 'session.create', + params + ) + : await requestGateway('session.create', params) const stored = created.stored_session_id ?? null // Only a genuine move to a DIFFERENT chat mid-create should orphan the @@ -574,8 +593,16 @@ export function useSessionActions({ // Fresh tile → the caller's workspace when one was named (the sidebar // "+" on a project/worktree lane), else the resolved new-session cwd // (project scope → configured default). - const params = await desktopSessionCreateParams((options?.cwd || resolveNewSessionCwd()).trim()) - const created = await requestGateway('session.create', params) + const capturedRoute = $newChatRoute.get() + const params = await desktopSessionCreateParams((options?.cwd || resolveNewSessionCwd()).trim(), capturedRoute) + const created = capturedRoute + ? await requestGatewayForAgent( + capturedRoute.connectionId, + capturedRoute.profile, + 'session.create', + params + ) + : await requestGateway('session.create', params) const stored = created.stored_session_id if (!stored) { @@ -639,7 +666,7 @@ export function useSessionActions({ }, [navigate, selectedStoredSessionId]) const resumeSession = useCallback( - async (storedSessionId: string, replaceRoute = false) => { + async (storedSessionId: string, replaceRoute = false, capturedOwner?: SessionProfileRoute) => { const requestId = resumeRequestRef.current + 1 resumeRequestRef.current = requestId const resumedSameSelectedSession = selectedStoredSessionIdRef.current === storedSessionId @@ -730,7 +757,8 @@ export function useSessionActions({ // gateway call (no-op when it's already on that profile / single-profile). // resolveStoredSession finds the row by id (cheap), so an uncached pasted // id loads as fast as a sidebar click instead of hanging on a list scan. - const storedForProfile = await resolveStoredSession(storedSessionId) + const ownerRoute = capturedOwner || getSessionOwnerHint(storedSessionId) + const storedForProfile = await resolveStoredSession(storedSessionId, ownerRoute) const sessionProfile = storedForProfile?.profile if (resumeRequestRef.current !== requestId) { @@ -740,8 +768,16 @@ export function useSessionActions({ // A row spliced from a CONNECTED registry gateway (#88880) carries its // owning connection — activate THAT gateway, not a same-named local // profile. Rows without the tag keep the legacy profile path. - if (storedForProfile?.connection_id) { - await ensureGatewayAgent(storedForProfile.connection_id, sessionProfile || 'default') + const sessionOwner: SessionOwnerScope = ownerRoute || + (storedForProfile?.connection_id + ? { + connectionId: storedForProfile.connection_id, + profile: sessionProfile || 'default' + } + : sessionProfile) + + if (ownerRoute?.connectionId || storedForProfile?.connection_id) { + await ensureGatewayAgent(ownerRoute?.connectionId || storedForProfile?.connection_id || null, sessionProfile || 'default') } else { await ensureGatewayProfile(sessionProfile) } @@ -759,7 +795,14 @@ export function useSessionActions({ // own backend is healthy one port over (#89206: local pool AND SSH). // requestForSessionProfile re-resolves the route at each call. const requestForSession = (method: string, params: Record = {}): Promise => - requestForSessionProfile(sessionProfile, requestGateway, method, params) + requestForSessionProfile(sessionOwner, requestGateway, method, params) + + const sessionRestScope = ownerRoute + ? { + connectionId: ownerRoute.connectionId, + profile: ownerRoute.targetProfile || ownerRoute.profile + } + : sessionProfile // Re-check after the profile-resolve / gateway-swap awaits above: the // cache may have changed, and takeWarmCache re-validates belongs-to and @@ -808,7 +851,7 @@ export function useSessionActions({ // prefetch. Watch mirrors stay live-only by design. const persistedTranscriptPromise = isWatchWindow() ? null - : getLatestSessionMessages(storedSessionId, sessionProfile).catch(() => null) + : getLatestSessionMessages(storedSessionId, sessionRestScope).catch(() => null) setFreshDraftReady(false) clearNotifications() @@ -1101,7 +1144,7 @@ export function useSessionActions({ // max(prefetch, resume) instead of their sum. The prefetch paints the // transcript as soon as it lands; the RPC binds the runtime id. // Watch windows skip the prefetch — lazy resume attaches the live mirror. - const prefetchPromise = watchWindow ? null : getLatestSessionMessages(storedSessionId, sessionProfile) + const prefetchPromise = watchWindow ? null : getLatestSessionMessages(storedSessionId, sessionRestScope) let resumeRuntimeBaselineMessages: ChatMessage[] = [] @@ -1360,7 +1403,7 @@ export function useSessionActions({ let fallbackError: unknown = null try { - const fallback = await getLatestSessionMessages(storedSessionId, sessionProfile) + const fallback = await getLatestSessionMessages(storedSessionId, sessionRestScope) if (!isCurrentResume()) { return diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 198db855d834..6346ee136a6b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -27,6 +27,7 @@ import { setWorkspaceCwdOwner, setYoloActive } from '@/store/session' +import type { SessionProfileRoute } from '@/store/session-request-router' // Re-exported for the many session-actions/tile call sites that already import // it from here; the canonical definition lives in @/store/session. @@ -1292,11 +1293,41 @@ function upsertResolvedSession(session: SessionInfo, storedSessionId: string) { ]) } -export async function resolveStoredSession(storedSessionId: string): Promise { +export async function resolveStoredSession( + storedSessionId: string, + ownerRoute?: SessionProfileRoute +): Promise { const cached = [...$sessions.get(), ...$cronSessions.get(), ...$messagingSessions.get()].find(session => sessionMatchesStoredId(session, storedSessionId) ) + if (ownerRoute) { + const scope = { + connectionId: ownerRoute.connectionId, + profile: ownerRoute.targetProfile || ownerRoute.profile + } + const cachedOwnerMatches = cached && + cached.connection_id === ownerRoute.connectionId && + (!cached.profile || normalizeProfileKey(cached.profile) === normalizeProfileKey(ownerRoute.profile)) + + if (cached && cachedOwnerMatches) { + return cached + } + + try { + const session = await getSession(storedSessionId, scope) + session.profile = normalizeProfileKey(ownerRoute.profile) + session.connection_id = ownerRoute.connectionId + upsertResolvedSession(session, storedSessionId) + + return session + } catch { + // An explicit owner is fail-closed. Probing the ambient or another + // profile would turn a stale route into a cross-connection open. + return undefined + } + } + // A row with no owning profile can't route a resume when more than one // profile exists — a resume without a profile lands on whichever gateway is // active (#67603 family, cross-profile open asymmetry). Treat such a hit as diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 55be4019271f..a407043fc6a4 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -834,6 +834,7 @@ export interface DesktopRosterAgent { connectionKind: DesktopConnectionKind connectionLabel: string profile: string + targetProfile?: string handle: string } diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index 43c6fa052cec..1cf3a7953123 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -7,6 +7,7 @@ import { AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS, audioSpeakRequestTimeoutMs, audioTranscribeRequestTimeoutMs, + deleteProfile, getAllSessionMessages, getCronJobs, getGlobalModelInfo, @@ -16,6 +17,7 @@ import { getLatestSessionMessages, getOlderSessionMessages, getProfiles, + getSession, getSessionMessages, getStatus, LATEST_SESSION_MESSAGES_LIMIT, @@ -358,6 +360,42 @@ describe('Hermes REST helpers', () => { }) }) + it('pins session metadata and transcripts to an explicit connection scope', async () => { + api.mockResolvedValue({ messages: [], session_id: 'session-1' }) + const scope = { connectionId: 'source-a', profile: 'backend-default' } + + await getSession('session-1', scope) + await getSessionMessages('session-1', scope) + + expect(api).toHaveBeenNthCalledWith(1, { + connectionId: 'source-a', + path: '/api/sessions/session-1?profile=backend-default', + profile: 'backend-default' + }) + expect(api).toHaveBeenNthCalledWith(2, { + connectionId: 'source-a', + path: '/api/sessions/session-1/messages?profile=backend-default', + profile: 'backend-default' + }) + }) + + it('scopes profile deletion and rejects default before Electron dispatch', async () => { + api.mockResolvedValue({ ok: true, path: '/profiles/worker' }) + + await deleteProfile('backend-worker', { connectionId: 'source-a', profile: 'backend-worker' }) + await expect( + deleteProfile('worker', { connectionId: 'source-a', profile: 'default' }) + ).rejects.toThrow(/default profile cannot be deleted/i) + + expect(api).toHaveBeenCalledOnce() + expect(api).toHaveBeenCalledWith({ + connectionId: 'source-a', + method: 'DELETE', + path: '/api/profiles/backend-worker', + profile: 'backend-worker' + }) + }) + it('hydrates the latest transcript with a small tail page (120, latest, compacted rows included)', async () => { api.mockResolvedValue({ messages: [], diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 296e33c20fe0..e8cf3e782212 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -311,7 +311,21 @@ function connectionScoped(): { connectionId?: string } { // ids mean the local pool and deliberately DROP the ambient connection // tag, so a local-profile pick made while a remote gateway is active still // routes to the local machine. -export type ProfileScope = null | string | { connectionId?: null | string; profile?: null | string } +export type ProfileScope = undefined | null | string | { connectionId?: null | string; profile?: null | string } + +function sessionScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } { + if (scope === undefined || scope === null) { + return {} + } + + return capabilityScoped(scope) +} + +function sessionScopeQuery(scope?: ProfileScope): string { + const profile = sessionScoped(scope).profile + + return profile ? `?profile=${encodeURIComponent(profile)}` : '' +} function capabilityScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } { if (scope && typeof scope === 'object') { @@ -773,11 +787,12 @@ export function searchSessions(query: string): Promise { // the given `profile`). The backend resolves exact ids and unique prefixes and // 404s when the id isn't on that profile — so a cheap by-id lookup replaces the // cross-profile list scan when locating an unknown id's owner. -export function getSession(id: string, profile?: string | null): Promise { - const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' +export function getSession(id: string, profile?: ProfileScope): Promise { + const scoped = sessionScoped(profile) + const suffix = sessionScopeQuery(profile) return window.hermesDesktop.api({ - ...(profile ? { profile } : {}), + ...scoped, path: `/api/sessions/${encodeURIComponent(id)}${suffix}` }) } @@ -788,13 +803,15 @@ export function getSession(id: string, profile?: string | null): Promise { const query = new URLSearchParams() - if (profile) { - query.set('profile', profile) + const scoped = sessionScoped(profile) + + if (scoped.profile) { + query.set('profile', scoped.profile) } if (page.limit !== undefined) { @@ -816,7 +833,7 @@ export function getSessionMessages( const suffix = query.size ? `?${query.toString()}` : '' return window.hermesDesktop.api({ - ...(profile ? { profile } : {}), + ...scoped, path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}` }) } @@ -830,7 +847,7 @@ export function getSessionMessages( */ export const LATEST_SESSION_MESSAGES_LIMIT = 120 -export function getLatestSessionMessages(id: string, profile?: string | null): Promise { +export function getLatestSessionMessages(id: string, profile?: ProfileScope): Promise { // includeCompacted: durable display history must include rows preserved by // in-place compaction (active=0, compacted=1); without them the transcript // silently ends at the compaction boundary and earlier turns are unreachable. @@ -868,7 +885,7 @@ export function getLatestSessionMessages(id: string, profile?: string | null): P */ export function getOlderSessionMessages( id: string, - profile: string | null | undefined, + profile: ProfileScope, offset: number, limit: number = LATEST_SESSION_MESSAGES_LIMIT ): Promise { @@ -877,7 +894,7 @@ export function getOlderSessionMessages( export async function getAllSessionMessages( id: string, - profile?: string | null, + profile?: ProfileScope, options: { maxJsonChars?: number } = {} ): Promise { const messages: SessionMessage[] = [] @@ -917,9 +934,9 @@ export async function getAllSessionMessages( return { session_id: resolvedSessionId, messages } } -export function deleteSession(id: string, profile?: string | null): Promise<{ ok: boolean }> { +export function deleteSession(id: string, profile?: ProfileScope): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ - ...(profile ? { profile } : {}), + ...sessionScoped(profile), path: `/api/sessions/${encodeURIComponent(id)}`, method: 'DELETE' }) @@ -1771,9 +1788,21 @@ export function renameProfile(name: string, newName: string): Promise<{ name: st }) } -export function deleteProfile(name: string): Promise<{ ok: boolean; path: string }> { +export function deleteProfile(name: string, scope?: ProfileScope): Promise<{ ok: boolean; path: string }> { + const normalized = name.trim() + const scopedProfile = scope && typeof scope === 'object' ? scope.profile?.trim() : undefined + + if (!normalized) { + return Promise.reject(new Error('Profile name required')) + } + + if (normalized.toLowerCase() === 'default' || scopedProfile?.toLowerCase() === 'default') { + return Promise.reject(new Error('The default profile cannot be deleted.')) + } + return window.hermesDesktop.api<{ ok: boolean; path: string }>({ - path: `/api/profiles/${encodeURIComponent(name)}`, + ...capabilityScoped(scope), + path: `/api/profiles/${encodeURIComponent(normalized)}`, method: 'DELETE' }) } diff --git a/apps/desktop/src/lib/session-link-title.test.ts b/apps/desktop/src/lib/session-link-title.test.ts index 5efa8ba3efd5..4f753c33312e 100644 --- a/apps/desktop/src/lib/session-link-title.test.ts +++ b/apps/desktop/src/lib/session-link-title.test.ts @@ -84,9 +84,11 @@ describe('fetchSessionLinkTitle', () => { }) it('keeps separate cache entries per profile', async () => { - vi.mocked(getSession).mockImplementation(async (id, profile) => - makeSession({ id, profile: profile ?? 'default', title: profile === 'work' ? 'Work chat' : 'Home chat' }) - ) + vi.mocked(getSession).mockImplementation(async (id, scope) => { + const profile = typeof scope === 'string' ? scope : scope?.profile || 'default' + + return makeSession({ id, profile, title: profile === 'work' ? 'Work chat' : 'Home chat' }) + }) await expect(fetchSessionLinkTitle('default/20260101_abc123')).resolves.toBe('Home chat') await expect(fetchSessionLinkTitle('work/20260101_abc123')).resolves.toBe('Work chat') diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 1f5257f18c36..cab453ae2745 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -86,6 +86,12 @@ const ID = 'hermes-bots' const ROSTER_KEY = [ID, 'roster'] const ROUTINES_KEY = [ID, 'routines'] const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ +const BOT_META_V1_KEY = 'bot-meta' +const BOT_META_V2_KEY = 'bot-meta-v2' +const BOT_META_MIGRATION_KEY = 'bot-meta-v2-migrated' +let botMetaV2Active = false +let botMetaV2Commit = Promise.resolve() +const migratedLocalRoutes = new Map() /** Captured in register() so components can reach plugin storage. */ let pluginCtx = null @@ -126,9 +132,10 @@ function trackInboundActivity(roster) { watermarksSeeded = true for (const bot of roster) { + const key = botSelectionKey(bot) const ts = bot.last_session?.last_active || 0 - const prev = rosterWatermarks.get(bot.name) || 0 - rosterWatermarks.set(bot.name, Math.max(prev, ts)) + const prev = rosterWatermarks.get(key) || 0 + rosterWatermarks.set(key, Math.max(prev, ts)) if (seeding || ts <= prev) { continue @@ -136,22 +143,22 @@ function trackInboundActivity(roster) { // Activity in the bot the user is currently looking at is already // visible — never badge the open chat. - if ($selectedBot.get() === bot.name) { + if ($selectedBot.get() === key) { continue } - $botUnread.set({ ...$botUnread.get(), [bot.name]: true }) + $botUnread.set({ ...$botUnread.get(), [key]: true }) // Roster-hidden bots stay quiet: the unread flag above accumulates // silently (unhiding reveals the badge) but a hidden bot never toasts. - if ($botMeta.get()[bot.name]?.hidden) { + if (botRosterMeta(bot, $botMeta.get())?.hidden) { continue } // Toasts are opt-in: the unread badge is always set above, but the // per-message notification fires only when the user enabled it. if ($activityToasts.get()) { - const meta = $botMeta.get()[bot.name] + const meta = botRosterMeta(bot, $botMeta.get()) const label = displayName(bot, meta) const preview = (bot.last_session?.preview || '').trim() const inbound = /^Message from/i.test(preview) @@ -214,14 +221,61 @@ function handleSessionsGatewayTransition() { * { [botName]: { shape, color, title } } */ const $botMeta = atom({}) -async function saveBotMeta(name, patch) { - const prevMeta = $botMeta.get()[name] || {} - const next = { ...$botMeta.get(), [name]: { ...prevMeta, ...patch } } +function commitBotMetaV2(storage, snapshot) { + const commit = botMetaV2Commit.then(async () => { + if (typeof storage?.remove !== 'function' || typeof storage?.set !== 'function') { + throw new Error('bot metadata v2 storage is unavailable') + } + + try { + await storage.remove(BOT_META_MIGRATION_KEY) + await storage.set(BOT_META_V2_KEY, snapshot) + await storage.set(BOT_META_MIGRATION_KEY, true) + } catch (error) { + await Promise.allSettled( + [BOT_META_MIGRATION_KEY, BOT_META_V2_KEY].map(key => storage.remove(key)) + ) + throw error + } + }) + + botMetaV2Commit = commit.catch(() => undefined) + + return commit +} + +function botOwner(owner) { + if (typeof owner === 'string') { + const name = owner.trim() + const route = migratedLocalRoutes.get(name) + + return { + bot: route ? { name, sourceScoped: true, route } : { name }, + name, + key: route ? botRouteKey(route) : name, + route: route || null + } + } + + const name = String(owner?.name || '').trim() + const route = botConnectionRoute(owner) + + return { bot: owner, name, key: route ? botRouteKey(route) : name, route } +} + +async function saveBotMeta(owner, patch) { + const { bot, key, name, route } = botOwner(owner) + const prevMeta = $botMeta.get()[key] || {} + const next = { ...$botMeta.get(), [key]: { ...prevMeta, ...patch } } $botMeta.set(next) // Local plugin storage: instant, and the fallback for older gateways. + let localPersistence = Promise.resolve() try { - Promise.resolve(pluginCtx?.storage?.set?.('bot-meta', next)).catch(() => undefined) + const persisted = route || botMetaV2Active + ? commitBotMetaV2(pluginCtx?.storage, next) + : Promise.resolve(pluginCtx?.storage?.set?.(BOT_META_V1_KEY, next)) + localPersistence = persisted.catch(() => undefined) } catch { /* storage unavailable — look persists for this window only */ } @@ -236,8 +290,10 @@ async function saveBotMeta(name, patch) { // the list call — so pfps follow the profile across machines too. let serverRequest = null try { - const { image, pet, ...rest } = next[name] || {} - serverRequest = Promise.resolve(host.request('profiles.configure', { name, ui_meta: { 'hermes-bots': rest } })) + const { image, pet, ...rest } = next[key] || {} + const request = route ? requestForBot(bot, 'profiles.configure', { name, ui_meta: { 'hermes-bots': rest } }) : + host.request('profiles.configure', { name, ui_meta: { 'hermes-bots': rest } }) + serverRequest = Promise.resolve(request) } catch { /* older/unavailable gateway — the local fallback remains saved */ } @@ -251,8 +307,12 @@ async function saveBotMeta(name, patch) { if ('image' in patch && patch.image !== (prevMeta.image ?? null)) { try { const req = patch.image - ? host.request('profiles.set_asset', { name, asset: 'avatar', data: patch.image }) - : host.request('profiles.set_asset', { name, asset: 'avatar', clear: true }) + ? (route + ? requestForBot(bot, 'profiles.set_asset', { name, asset: 'avatar', data: patch.image }) + : host.request('profiles.set_asset', { name, asset: 'avatar', data: patch.image })) + : (route + ? requestForBot(bot, 'profiles.set_asset', { name, asset: 'avatar', clear: true }) + : host.request('profiles.set_asset', { name, asset: 'avatar', clear: true })) req.catch(() => undefined) } catch { /* older gateway */ @@ -282,9 +342,147 @@ async function saveBotMeta(name, patch) { } } + await localPersistence + return { serverPersisted: serverOutcome === 'persisted', serverOutcome } } +/** Migrate name-keyed appearance state only when the live registry proves + * there is one local source. A v1 name cannot identify a machine in a + * multi-source desktop, so the conservative result there is to retain v1 as + * rollback data and leave remote rows unpainted. */ +function hydrateBotMeta(snapshot, remap = null) { + const next = { ...snapshot } + + for (const [key, meta] of Object.entries($botMeta.get())) { + const target = remap?.get(key) || key + next[target] = { ...(next[target] || {}), ...meta } + } + + $botMeta.set(next) + + return next +} + +async function migrateBotMeta(storage = pluginCtx?.storage) { + let v1 = null + let v2 = null + let v2Committed = false + + try { + ;[v1, v2, v2Committed] = await Promise.all([ + storage?.get?.(BOT_META_V1_KEY), + storage?.get?.(BOT_META_V2_KEY), + storage?.get?.(BOT_META_MIGRATION_KEY) + ]) + } catch { + return false + } + + if (v2Committed === true && v2 && typeof v2 === 'object' && !Array.isArray(v2)) { + hydrateBotMeta(v2) + botMetaV2Active = true + + return true + } + + if (!v1 || typeof v1 !== 'object' || Array.isArray(v1) || typeof host.agents !== 'function') { + if (v1 && typeof v1 === 'object' && !Array.isArray(v1)) { + hydrateBotMeta(v1) + } + + return false + } + + let union + let routes + + try { + union = await host.agents() + routes = typeof host.profileRoutes === 'function' ? await host.profileRoutes() : [] + } catch { + hydrateBotMeta(v1) + + return false + } + + const sources = Array.isArray(union?.sources) ? union.sources : [] + const localAgents = (union?.agents || []).filter(agent => agent?.connectionKind === 'local') + const soleLocal = sources.length === 1 + ? sources[0]?.kind === 'local' + : sources.length === 0 && localAgents.length > 0 && (union?.agents || []).every(agent => agent?.connectionKind === 'local') + + if (!soleLocal) { + hydrateBotMeta(v1) + + return false + } + + const migrated = {} + const pendingLocalRoutes = new Map() + + for (const [name, meta] of Object.entries(v1)) { + const route = (routes || []).find(candidate => candidate?.mode === 'local' && candidate?.profile === name) || + (() => { + const agent = localAgents.find(candidate => candidate.profile === name) + + return agent + ? { + connectionId: agent.connectionId, + mode: 'local', + profile: name, + targetProfile: agent.targetProfile || name + } + : null + })() + + if (!route?.connectionId) { + // A missing route makes the topology proof unusable for this key. Keep + // the v1 record intact rather than guessing a local/remote projection. + hydrateBotMeta(v1) + + return false + } + + const captured = { + connectionId: route.connectionId, + mode: 'local', + profile: name, + targetProfile: route.targetProfile || name + } + migrated[botRouteKey(captured)] = meta + pendingLocalRoutes.set(name, captured) + } + + const remap = new Map( + [...pendingLocalRoutes].map(([name, route]) => [name, botRouteKey(route)]) + ) + const hydrated = { ...migrated } + + for (const [key, meta] of Object.entries($botMeta.get())) { + const target = remap.get(key) || key + hydrated[target] = { ...(hydrated[target] || {}), ...meta } + } + + try { + await commitBotMetaV2(storage, hydrated) + } catch { + botMetaV2Active = false + hydrateBotMeta(v1) + + return false + } + + migratedLocalRoutes.clear() + for (const [name, route] of pendingLocalRoutes) { + migratedLocalRoutes.set(name, route) + } + hydrateBotMeta(hydrated) + botMetaV2Active = true + + return true +} + // ── hidden bots (right-click → Hide Bot) ──────────────────────────────────── // Hiding is a ROSTER-DISPLAY concern only: a hidden bot keeps working — // @mentions still resolve, group-chat membership is untouched, its name @@ -318,14 +516,19 @@ function fallbackSelectionAfterHide(name) { const meta = $botMeta.get() const visible = $lastRoster .get() - .filter(bot => !bot.remoteSource && bot.name !== name && !meta[bot.name]?.hidden) + .filter(bot => botSelectionKey(bot) !== name && !botRosterMeta(bot, meta)?.hidden) if (visible.length) { - $selectedBot.set(visible[0].name) + $selectedBot.set(botSelectionKey(visible[0])) return } - if (name !== 'default' && !meta.default?.hidden) { + const defaultBot = $lastRoster.get().find(bot => isDefaultBot(bot) && !botRosterMeta(bot, meta)?.hidden) + if (defaultBot && botSelectionKey(defaultBot) !== name) { + $selectedBot.set(botSelectionKey(defaultBot)) + } else if (!$lastRoster.get().some(isDefaultBot)) { + // Legacy sole-local rosters can transiently omit the default row. Keep the + // historic fallback rather than leaving Routines attached to a hidden bot. $selectedBot.set('default') } } @@ -339,19 +542,26 @@ function fallbackSelectionAfterHide(name) { * setter is a no-op on already-hidden rows) and feature-detected: older * gateways lack session.set_hidden and simply keep the rows visible. */ function hideOwnedBotSessions() { - const canonical = Object.values($botMeta.get()) - .map(m => m && m.chat) + const roster = $lastRoster.get() + const canonical = Object.entries($botMeta.get()) + .map(([key, meta]) => { + const bot = roster.find(candidate => botMetaKey(candidate) === key) + + return meta?.chat && bot ? { bot, id: meta.chat } : null + }) .filter(Boolean) + const canonicalIds = new Set(canonical.map(({ id }) => id)) const rooms = Object.values($groupChats.get()) .flatMap(room => Object.values(room?.sessions || {})) - .filter(sid => Boolean(sid) && sid !== true) - const ids = [...new Set([...canonical, ...rooms])] - - const known = Promise.all( - ids.map(sid => + .filter(sid => Boolean(sid) && sid !== true && !canonicalIds.has(sid)) + const known = Promise.all([ + ...canonical.map(({ bot, id }) => + Promise.resolve(requestForBot(bot, 'session.set_hidden', { session_id: id, hidden: true })).catch(() => undefined) + ), + ...rooms.map(sid => Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden: true })).catch(() => undefined) ) - ) + ]) return Promise.all([known, sweepBotProfileSessions().catch(() => undefined)]) } @@ -391,7 +601,12 @@ async function sweepBotProfileSessions() { // back to the active gateway's own profile list (local bots; remote // sources get covered by the next sweep once the roster cache exists). try { - const res = await host.request('profiles.list', {}) + const route = await activeBotRoute() + const res = await requestForBot( + route ? { name: route.profile, sourceScoped: true, route } : { name: 'default' }, + 'profiles.list', + {} + ) roster = Array.isArray(res?.profiles) ? res.profiles : [] } catch { return @@ -437,18 +652,22 @@ const avatarPushInflight = new Set() * cross-machine roster art, so local-only images are a bug, not a state. */ function pushLocalAvatars(roster) { for (const bot of roster) { - if (bot.has_avatar || avatarPushInflight.has(bot.name)) { + const key = botMetaKey(bot) + + if (bot.has_avatar || avatarPushInflight.has(key)) { continue } - const image = $botMeta.get()[bot.name]?.image + const image = $botMeta.get()[key]?.image if (image && typeof image === 'string' && image.startsWith('data:')) { - avatarPushInflight.add(bot.name) - host - .request('profiles.set_asset', { name: bot.name, asset: 'avatar', data: image }) + avatarPushInflight.add(key) + const request = bot.sourceScoped + ? requestForBot(bot, 'profiles.set_asset', { name: bot.name, asset: 'avatar', data: image }) + : host.request('profiles.set_asset', { name: bot.name, asset: 'avatar', data: image }) + Promise.resolve(request) .then(() => queryClient.invalidateQueries({ queryKey: ['hermes-bots', 'roster'] })) - .catch(() => avatarPushInflight.delete(bot.name)) + .catch(() => avatarPushInflight.delete(key)) continue } @@ -461,16 +680,17 @@ function pushLocalAvatars(roster) { continue } - avatarPushInflight.add(bot.name) + avatarPushInflight.add(key) rasterizeSvgToPng(svg, 160) .then(png => png - ? host - .request('profiles.set_asset', { name: bot.name, asset: 'avatar', data: png }) + ? (bot.sourceScoped + ? requestForBot(bot, 'profiles.set_asset', { name: bot.name, asset: 'avatar', data: png }) + : host.request('profiles.set_asset', { name: bot.name, asset: 'avatar', data: png })) .then(() => queryClient.invalidateQueries({ queryKey: ['hermes-bots', 'roster'] })) : Promise.reject(new Error('rasterize failed')) ) - .catch(() => avatarPushInflight.delete(bot.name)) + .catch(() => avatarPushInflight.delete(key)) } } @@ -529,37 +749,36 @@ function pullServerAvatars(roster) { pushLocalAvatars(roster) for (const bot of roster) { - if (!bot.has_avatar || avatarFetchInflight.has(bot.name)) { + const key = botMetaKey(bot) + + if (!bot.has_avatar || avatarFetchInflight.has(key)) { continue } - if ($botMeta.get()[bot.name]?.image) { + if ($botMeta.get()[key]?.image) { continue } - avatarFetchInflight.add(bot.name) - host - .request('profiles.get_asset', { name: bot.name, asset: 'avatar' }) + avatarFetchInflight.add(key) + const assetRequest = bot.sourceScoped + ? requestForBot(bot, 'profiles.get_asset', { name: bot.name, asset: 'avatar' }) + : host.request('profiles.get_asset', { name: bot.name, asset: 'avatar' }) + Promise.resolve(assetRequest) .then(res => { if (res?.found && res.data) { const current = $botMeta.get() - const mine = current[bot.name] || {} + const mine = current[key] || {} // A 160px raster of the vector face is only for inter-agent // notices. Do not park it on the roster or the live face dies. if (isBackfilledFacePng(res.data) && mine.imageKind !== 'photo' && !mine.pet) { return } - $botMeta.set({ ...current, [bot.name]: { ...mine, image: res.data } }) - - try { - Promise.resolve(pluginCtx?.storage?.set?.('bot-meta', $botMeta.get())).catch(() => undefined) - } catch { - /* no storage */ - } + $botMeta.set({ ...current, [key]: { ...mine, image: res.data } }) + persistBotMetaSnapshot($botMeta.get(), Boolean(bot.sourceScoped)) } }) .catch(() => undefined) - .finally(() => avatarFetchInflight.delete(bot.name)) + .finally(() => avatarFetchInflight.delete(key)) } } @@ -577,7 +796,8 @@ function mergeServerMeta(roster) { for (const bot of roster) { const server = bot.ui_meta?.['hermes-bots'] if (server && typeof server === 'object') { - const mine = next[bot.name] || {} + const key = botMetaKey(bot) + const mine = next[key] || {} const merged = { ...mine, ...server } // Local-only fields survive the server overlay. @@ -607,8 +827,8 @@ function mergeServerMeta(roster) { delete merged.group } - if (JSON.stringify(next[bot.name] || null) !== JSON.stringify(merged)) { - next[bot.name] = merged + if (JSON.stringify(next[key] || null) !== JSON.stringify(merged)) { + next[key] = merged changed = true } } @@ -620,7 +840,7 @@ function mergeServerMeta(roster) { // Persist server reconciliation so a relaunch cannot rehydrate stale // local fields that the server intentionally removed. try { - Promise.resolve(pluginCtx?.storage?.set?.('bot-meta', next)).catch(() => undefined) + persistBotMetaSnapshot(next, roster.some(bot => bot.sourceScoped)) } catch { /* storage unavailable — reconciliation lasts for this window only */ } @@ -630,7 +850,10 @@ function mergeServerMeta(roster) { /** Clone a bot: profile (config/skills/SOUL/memory via clone_from) + look. * Name is "-2", "-3", … — first free slot against the live roster. */ async function duplicateBot(bot, roster) { + await ensureBotMetadata(bot) const base = bot.name + const ownerRoute = botConnectionRoute(bot) + const ownerKey = ownerRoute ? botRouteKey(ownerRoute) : null let name = null for (let n = 2; n < 100; n++) { // Truncate the BASE, never the suffix — slicing the joined string chops @@ -638,7 +861,7 @@ async function duplicateBot(bot, roster) { // base forever (#19). const suffix = `-${n}` const candidate = base.slice(0, 64 - suffix.length) + suffix - if (!roster.some(b => b.name === candidate)) { + if (!roster.some(b => b.name === candidate && (!ownerKey || botMetaKey(b)?.startsWith(`${ownerRoute.connectionId}::`)))) { name = candidate break } @@ -648,7 +871,7 @@ async function duplicateBot(bot, roster) { throw new Error('No free name for the duplicate.') } - await host.request('profiles.create', { + await requestForBot(bot, 'profiles.create', { name, clone_from: base, description: bot.description || '' @@ -657,10 +880,10 @@ async function duplicateBot(bot, roster) { // Same look: avatar shape/color/image and a "(copy)" title so the two // are tellable apart in the roster until the user renames. Do not copy // chat or created. Those belong to the original bot. - const meta = $botMeta.get()[base] + const meta = $botMeta.get()[botMetaKey(bot)] if (meta) { const { chat, created, ...look } = meta - saveBotMeta(name, { + await saveBotMeta({ ...bot, name, route: ownerRoute, sourceScoped: Boolean(ownerRoute) }, { ...look, title: meta.title ? `${meta.title} (copy)` : '' }) @@ -681,13 +904,25 @@ async function duplicateBot(bot, roster) { * renderer's socket reconnect respawns it mid-delete, resurrecting the * directory (hermes-agent#52279). That is the "can't delete a bot" error. */ async function deleteBot(bot) { + const route = botConnectionRoute(bot) + + if (isDefaultBot(bot) || String(route?.targetProfile || '').toLowerCase() === 'default') { + throw new Error('The default profile cannot be deleted.') + } + if (typeof host.deleteProfile === 'function') { - await host.deleteProfile(bot.name) + if (route) { + await host.deleteProfile(route) + } else { + await host.deleteProfile(bot.name) + } } else { - // Older desktop without the SDK verb — best effort via the CLI. - const result = await host.request('cli.exec', { + // Older desktop without the SDK verb — source-scoped rows fail closed. + const result = await (route ? requestForBot(bot, 'cli.exec', { argv: ['profile', 'delete', bot.name, '--yes'] - }) + }) : host.request('cli.exec', { + argv: ['profile', 'delete', bot.name, '--yes'] + })) if (result?.blocked || result?.code !== 0) { throw new Error(result?.hint || result?.output || `Could not delete profile ${bot.name}.`) @@ -695,23 +930,27 @@ async function deleteBot(bot) { } const meta = { ...$botMeta.get() } - delete meta[bot.name] + delete meta[botMetaKey(bot)] $botMeta.set(meta) try { - await Promise.resolve(pluginCtx?.storage?.set?.('bot-meta', meta)) + if (route) { + await commitBotMetaV2(pluginCtx?.storage, meta) + } else { + await Promise.resolve(pluginCtx?.storage?.set?.(BOT_META_V1_KEY, meta)) + } } catch { /* profile is deleted; stale local appearance is harmless if storage fails */ } const unread = { ...$botUnread.get() } - delete unread[bot.name] + delete unread[botSelectionKey(bot)] $botUnread.set(unread) - rosterWatermarks.delete(bot.name) - avatarFetchInflight.delete(bot.name) - avatarPushInflight.delete(bot.name) + rosterWatermarks.delete(botSelectionKey(bot)) + avatarFetchInflight.delete(botMetaKey(bot)) + avatarPushInflight.delete(botMetaKey(bot)) - if ($selectedBot.get() === bot.name) { + if ($selectedBot.get() === botSelectionKey(bot)) { $selectedBot.set('default') } @@ -2605,16 +2844,43 @@ let serverInjectsProtocol = false * (preferred_session), so a row's preview can describe the same session its * click opens (hermes-agent#88200). Unknown params are ignored by older * gateways, which simply omit the field. */ -function preferredSessionIds(allMeta) { +function preferredSessionIds(allMeta, activeRoute = null) { const pins = {} - for (const [name, meta] of Object.entries(allMeta || {})) { + for (const [key, meta] of Object.entries(allMeta || {})) { if (meta?.chat) { - pins[name] = meta.chat + const name = key.includes('::') + ? (activeRoute && key === botRouteKey(activeRoute) ? activeRoute.profile : null) + : key + + if (name) { + pins[name] = meta.chat + } } } return pins } +async function activeBotRoute() { + if (typeof host.profileRoutes !== 'function') { + return null + } + + const profile = String(host.state.profile?.get?.() || 'default').trim() || 'default' + const connectionId = String( + host.state.connectionId?.get?.() || + (typeof host.activeConnectionId === 'function' ? host.activeConnectionId() : '') || + 'local' + ).trim() || 'local' + const routes = await host.profileRoutes() + const route = routes.find(candidate => candidate?.connectionId === connectionId && candidate?.profile === profile) + + if (!route && typeof host.agents === 'function') { + throw new Error(`No route for active bot ${connectionId}::${profile}`) + } + + return route || null +} + function useRoster() { const activeConnectionId = useValue(host.state.connectionId) @@ -2623,8 +2889,13 @@ function useRoster() { queryFn: async () => { // Rich rows (last_session, ui_meta, has_avatar) come from the ACTIVE // gateway's profiles.list — unchanged single-source behavior. - const pins = preferredSessionIds($botMeta.get()) - const local = await host.request( + const route = await activeBotRoute() + const activeBot = route + ? { name: route.profile, sourceScoped: true, route } + : { name: String(host.state.profile?.get?.() || 'default').trim() || 'default' } + const pins = preferredSessionIds($botMeta.get(), route) + const local = await requestForBot( + activeBot, 'profiles.list', Object.keys(pins).length ? { preferred_session_ids: pins } : {} ) @@ -2665,8 +2936,9 @@ function useRoster() { * already returned: they only ANNOTATE the rich rows (handle, connection * fields); rich fields stay authoritative and they are NOT duplicated. * Rows from other sources become new roster entries tagged with their - * source label so BotRow can badge them and route open/warm through - * ensureAgent/warmAgent. Pure — exercised directly by the tests. */ + * source label so BotRow can badge them, warm the captured agent, and route + * every open directly through that descriptor. Pure — exercised directly by + * the tests. */ function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) { const localProfiles = Array.isArray(local?.profiles) ? local.profiles : [] const agents = Array.isArray(union?.agents) ? union.agents : [] @@ -2756,6 +3028,13 @@ function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) row.connectionId = agent.connectionId row.connectionKind = agent.connectionKind row.connectionLabel = agent.connectionLabel + row.targetProfile = agent.targetProfile || profile + row.route = { + connectionId, + mode: agent.connectionKind === 'local' ? 'local' : 'remote', + profile, + targetProfile: agent.targetProfile || profile + } row.sourceScoped = true continue } @@ -2772,6 +3051,13 @@ function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) connectionId, connectionKind: agent.connectionKind, connectionLabel: agent.connectionLabel, + targetProfile: agent.targetProfile || profile, + route: { + connectionId, + mode: agent.connectionKind === 'local' ? 'local' : 'remote', + profile, + targetProfile: agent.targetProfile || profile + }, remoteSource: true, sourceScoped: true }) @@ -2849,6 +3135,26 @@ function isActiveRosterBot(bot, active) { return botName === activeName } +function botSelectionKey(bot) { + return bot?.sourceScoped || bot?.remoteSource ? botRosterKey(bot) : bot?.name +} + +function isDefaultBot(bot) { + const route = botConnectionRoute(bot) + + return String(route?.profile || bot?.name || '').trim().toLowerCase() === 'default' +} + +function newBotChat(bot) { + if (typeof host.newChat !== 'function') { + host.navigate?.('/') + + return + } + + host.newChat(bot?.sourceScoped || bot?.remoteSource ? botConnectionRoute(bot) : bot?.name) +} + /** Resolve @handles in prose against the Bot Mode roster (local + Connections). * Skips the bot already speaking in this chat. Unique bare names match; * duplicate names require the @name-device handle. */ @@ -2926,10 +3232,11 @@ const REMOTE_DM_POLL_MS = 2000 * minting a fresh "Bot Chat" per mention. */ async function ensureRemoteCanonicalChat(route, profile) { let pinned = null + const backendProfile = route.targetProfile || profile try { const listed = await host.requestProfile(route, 'profiles.list', {}) - const owner = listed?.profiles?.find(p => p.name === profile) + const owner = listed?.profiles?.find(p => p.name === backendProfile) pinned = owner?.ui_meta?.['hermes-bots']?.chat || null } catch { /* older remote gateway — title lookup below still works */ @@ -2943,7 +3250,7 @@ async function ensureRemoteCanonicalChat(route, profile) { try { const res = await host.requestProfile(route, 'session.resume', { session_id: target, - profile, + profile: backendProfile, omit_messages: true }) @@ -2956,7 +3263,7 @@ async function ensureRemoteCanonicalChat(route, profile) { } const created = await host.requestProfile(route, 'session.create', { - profile, + profile: backendProfile, title: 'Bot Chat', // Bot Mode sessions are always hidden from the global sidebar. hidden: true @@ -2969,6 +3276,7 @@ async function ensureRemoteCanonicalChat(route, profile) { * member turn: wait for a NEW assistant message after `before`, or time out. */ async function pollRemoteDmReply(route, profile, sessionRef, before) { const deadline = Date.now() + REMOTE_DM_TIMEOUT_MS + const backendProfile = route.targetProfile || profile while (Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, REMOTE_DM_POLL_MS)) @@ -2976,7 +3284,10 @@ async function pollRemoteDmReply(route, profile, sessionRef, before) { let state = null try { - state = await host.requestProfile(route, 'session.resume', { session_id: sessionRef, profile }) + state = await host.requestProfile(route, 'session.resume', { + session_id: sessionRef, + profile: backendProfile + }) } catch { continue } @@ -3029,7 +3340,7 @@ async function deliverRemoteRosterMentions(bots, userText, sender) { continue } - const route = { connectionId, mode: 'remote', profile, targetProfile: profile } + const route = botConnectionRoute(bot) const label = bot.connectionLabel || connectionId try { @@ -3043,7 +3354,10 @@ async function deliverRemoteRosterMentions(bots, userText, sender) { let before = 0 try { - const pre = await host.requestProfile(route, 'session.resume', { session_id: stored || runtime, profile }) + const pre = await host.requestProfile(route, 'session.resume', { + session_id: stored || runtime, + profile: route.targetProfile || profile + }) before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0 } catch { /* lazy session — zero messages */ @@ -3091,33 +3405,106 @@ function botRosterKey(bot) { return `${bot?.connectionId || 'legacy'}::${bot?.name || 'default'}` } +function botRouteKey(route) { + return `${route.connectionId}::${route.profile}` +} + +function botMetaKey(bot) { + const route = botConnectionRoute(bot) + + return route ? botRouteKey(route) : bot?.name +} + +function persistBotMetaSnapshot(value, scoped = false) { + try { + const persisted = scoped + ? commitBotMetaV2(pluginCtx?.storage, value) + : Promise.resolve(pluginCtx?.storage?.set?.(BOT_META_V1_KEY, value)) + + return persisted.catch(() => undefined) + } catch { + return Promise.resolve() + } +} + // ── cross-connection routing ───────────────────────────────────────────────── // A bot from another registered connection (remoteSource rows) is reached // through host.requestProfile with a route descriptor; local bots keep the // active-gateway door. Feature-detected: older desktops without // requestProfile simply have no remote routes (callers fall back / disable). -/** Route descriptor for a bot on another connection, or null for the local / - * active source (or when the desktop can't route). */ +/** Immutable owner descriptor for every source-scoped row. The active + * gateway is presentation state and is never consulted here. */ function botConnectionRoute(bot) { - const id = String(bot?.connectionId || '').trim() - - if (!bot?.remoteSource || !id || id === 'local' || typeof host.requestProfile !== 'function') { + if (!bot?.sourceScoped && !bot?.remoteSource) { return null } - const profile = String(bot?.name || '').trim() || 'default' + const candidate = bot.route || { + connectionId: bot.connectionId, + mode: bot.connectionKind === 'local' ? 'local' : 'remote', + profile: bot.name, + targetProfile: bot.targetProfile || bot.name + } + const connectionId = String(candidate?.connectionId || '').trim() + const profile = String(candidate?.profile || bot?.name || '').trim() || 'default' + const targetProfile = String(candidate?.targetProfile || profile).trim() || profile - return { connectionId: id, mode: 'remote', profile, targetProfile: profile } + if (!connectionId) { + throw new Error(`Bot ${profile} has no connection owner`) + } + + return Object.freeze({ + connectionId, + mode: candidate.mode === 'local' || connectionId === 'local' ? 'local' : 'remote', + profile, + targetProfile + }) } -/** Gateway RPC on the bot's OWN source: requestProfile for remote rows, - * the active gateway for local ones. Never activates/foregrounds. */ +function scopedBotParams(route, method, params) { + const logical = route.profile + const target = route.targetProfile || logical + let next = params + + if (Object.prototype.hasOwnProperty.call(params, 'profile')) { + next = { ...next, profile: target } + } + + if (method.startsWith('profiles.') && method !== 'profiles.create' && params.name === logical) { + next = { ...next, name: target } + } + + if (params.clone_from === logical) { + next = { ...next, clone_from: target } + } + + if (method === 'cli.exec' && Array.isArray(params.argv)) { + next = { ...next, argv: params.argv.map(arg => arg === logical ? target : arg) } + } + + return next +} + +function botBackendProfileScope(route, fallbackProfile = 'default') { + if (!route) { + return fallbackProfile + } + + return { connectionId: route.connectionId, profile: route.targetProfile || route.profile } +} + +/** Gateway RPC on the bot's OWN source. Source-scoped rows always use the + * explicit descriptor, including a registered local source. */ async function requestForBot(bot, method, params = {}) { const route = botConnectionRoute(bot) if (route) { - return host.requestProfile(route, method, params) + if (typeof host.requestProfile !== 'function') { + throw new Error(`Cannot route ${method} for ${route.connectionId}::${route.profile}`) + } + + return host.requestProfile(route, method, scopedBotParams(route, method, params)) } return host.request(method, params) @@ -3128,7 +3515,7 @@ async function requestForBot(bot, method, params = {}) { * remote members get the source-qualified key so `dixie` on the Mini and a * local `dixie` never share watermarks or sessions. */ function groupMemberKey(member) { - return member?.remoteSource ? botRosterKey(member) : member?.name + return member?.sourceScoped || member?.remoteSource ? botRosterKey(member) : member?.name } // Bot metadata is scoped to the active gateway until the server exposes a @@ -3136,7 +3523,13 @@ function groupMemberKey(member) { // another source: two `default` agents must not borrow each other's title, // pin, avatar, group, unread state, or canonical-chat pointer. function botRosterMeta(bot, metaByName) { - return bot?.remoteSource ? null : metaByName?.[bot?.name] + if (bot?.sourceScoped || bot?.remoteSource) { + const route = botConnectionRoute(bot) + + return route ? metaByName?.[botRouteKey(route)] : null + } + + return metaByName?.[bot?.name] } function showsHandle(name, meta, bot) { @@ -3163,16 +3556,19 @@ function showsHandle(name, meta, bot) { const canonicalCreations = new Map() let botOpenGeneration = 0 -async function openStoredBotChat(name, storedId, summary) { +async function openStoredBotChat(owner, storedId, summary) { if (!storedId || typeof host.openSession !== 'function') { throw new Error('This Hermes Desktop version cannot open stored sessions') } + const { name, route } = botOwner(owner) + const hasAuthoritativeCount = typeof summary?.message_count === 'number' && Number.isFinite(summary.message_count) const expectHistory = hasAuthoritativeCount ? summary.message_count > 0 : true await host.openSession(storedId, { + ...(route ? { route } : {}), profile: name, intent: 'main', awaitHydration: true, @@ -3186,15 +3582,16 @@ async function openStoredBotChat(name, storedId, summary) { * message (the gateway prunes zero-message sessions, so the chat is born * with the bot introducing itself). Pins the stored id in bot meta and * returns it. */ -function createCanonicalChat(name) { - const inflight = canonicalCreations.get(name) +function createCanonicalChat(owner) { + const { bot, key, name, route } = botOwner(owner) + const inflight = canonicalCreations.get(key) if (inflight) { return inflight } const run = (async () => { - const res = await host.request('session.create', { + const res = await requestForBot(bot, 'session.create', { profile: name, title: 'Bot Chat', // Always born hidden from the global sidebar — Bot Mode sessions are @@ -3207,7 +3604,7 @@ function createCanonicalChat(name) { const runtime = res?.session_id if (sid) { - saveBotMeta(name, { chat: sid }) + saveBotMeta(owner, { chat: sid }) } // Mount the session view FIRST, then send the kickoff — submitting into @@ -3216,7 +3613,7 @@ function createCanonicalChat(name) { if (sid && typeof host.openSession === 'function') { try { - await host.openSession(sid, { profile: name, intent: 'main' }) + await host.openSession(sid, { ...(route ? { route } : {}), profile: name, intent: 'main' }) opened = true } catch { // The stored row may not exist until the kickoff persists it. Retry @@ -3228,10 +3625,10 @@ function createCanonicalChat(name) { await new Promise(resolve => window.setTimeout(resolve, 400)) try { - await host.request('prompt.submit', { session_id: runtime, text: 'Hey, tell me about yourself!' }) + await requestForBot(bot, 'prompt.submit', { session_id: runtime, text: 'Hey, tell me about yourself!' }) if (!opened && sid && typeof host.openSession === 'function') { - await host.openSession(sid, { profile: name, intent: 'main' }) + await host.openSession(sid, { ...(route ? { route } : {}), profile: name, intent: 'main' }) } } catch { // The chat already exists. Keep the pin so the next click @@ -3240,9 +3637,9 @@ function createCanonicalChat(name) { } return sid || null - })().finally(() => canonicalCreations.delete(name)) + })().finally(() => canonicalCreations.delete(key)) - canonicalCreations.set(name, run) + canonicalCreations.set(key, run) return run } @@ -3260,16 +3657,18 @@ function createCanonicalChat(name) { * session.list window, which misjudged real hidden pins as gone; * - transient lookup failures keep the pin: try the stored id as-is, and * only a rejected open enters recovery. */ -async function openBotCanonicalChat(name, pinned, history) { +async function openBotCanonicalChat(owner, pinned, history) { + const { bot, name } = botOwner(owner) + if (!pinned) { // Grandfather: adopt the conversation the row already previews. const adoptId = history?.id if (adoptId && typeof host.openSession === 'function') { - await openStoredBotChat(name, adoptId, history) - saveBotMeta(name, { chat: adoptId }) + await openStoredBotChat(owner, adoptId, history) + saveBotMeta(owner, { chat: adoptId }) return adoptId } - return createCanonicalChat(name) + return createCanonicalChat(owner) } // Precise verification. An older gateway ignores the unknown param and @@ -3278,7 +3677,7 @@ async function openBotCanonicalChat(name, pinned, history) { let preferred let lookupFailed = false try { - const res = await host.request('profiles.list', { + const res = await requestForBot(bot, 'profiles.list', { include_sessions: true, preferred_session_ids: { [name]: pinned } }) @@ -3296,12 +3695,12 @@ async function openBotCanonicalChat(name, pinned, history) { // until proven guilty — try it as-is. A rejected open is still ambiguous: // it can be the same reconnect/hydration outage that broke this lookup, so // preserve the forever-chat pin and surface Retry instead of forking it. - return openStoredBotChat(name, pinned, history) + return openStoredBotChat(owner, pinned, history) } if (preferred) { try { - await openStoredBotChat(name, preferred.resolved_id || preferred.id, preferred) + await openStoredBotChat(owner, preferred.resolved_id || preferred.id, preferred) return pinned } catch (error) { // The precise lookup JUST confirmed this session exists, so a failed @@ -3316,41 +3715,23 @@ async function openBotCanonicalChat(name, pinned, history) { // recovery): re-anchor on the previewed session when there is one. const recoveryId = history?.id if (recoveryId && typeof host.openSession === 'function') { - await openStoredBotChat(name, recoveryId, history) - saveBotMeta(name, { chat: recoveryId }) + await openStoredBotChat(owner, recoveryId, history) + saveBotMeta(owner, { chat: recoveryId }) return recoveryId } - saveBotMeta(name, { chat: null }) - return createCanonicalChat(name) + saveBotMeta(owner, { chat: null }) + return createCanonicalChat(owner) } async function prepareBotSource(bot, pinnedChat) { - if (!bot.sourceScoped) { + if (!bot.sourceScoped || pinnedChat) { return pinnedChat } - if (typeof host.ensureAgent !== 'function') { - throw new Error('Update Hermes Desktop to chat with agents on other connections.') - } - - await host.ensureAgent(bot.connectionId, bot.name) - - if (!bot.remoteSource) { - return pinnedChat - } - - const liveId = String(typeof host.activeConnectionId === 'function' ? host.activeConnectionId() || '' : '').trim() - const targetId = String(bot.connectionId || '').trim() - - if (targetId && targetId !== 'local' && liveId !== targetId) { - throw new Error(`Still on ${liveId || 'this device'}, not ${bot.connectionLabel || targetId}`) - } - - // Thin rows deliberately omit metadata from the active source. Once their - // owner is active, recover that source's canonical-chat pointer so - // same-named agents never reuse or overwrite each other's pin. + // Thin rows deliberately omit metadata. Read it on the captured owner + // route; activation is a UI concern and cannot authorize this RPC. try { - const refreshed = await host.request('profiles.list', {}) + const refreshed = await requestForBot(bot, 'profiles.list', {}) const owner = refreshed?.profiles?.find(profile => profile.name === bot.name) return owner?.ui_meta?.['hermes-bots']?.chat || null @@ -3360,6 +3741,24 @@ async function prepareBotSource(bot, pinnedChat) { } } +async function ensureBotMetadata(bot) { + if (!bot?.sourceScoped) { + return botRosterMeta(bot, $botMeta.get()) || {} + } + + const result = await requestForBot(bot, 'profiles.list', {}) + const row = (result?.profiles || []).find(profile => profile?.name === bot.name) + const server = row?.ui_meta?.['hermes-bots'] + + if (server && typeof server === 'object') { + const key = botMetaKey(bot) + $botMeta.set({ ...$botMeta.get(), [key]: { ...($botMeta.get()[key] || {}), ...server } }) + persistBotMetaSnapshot($botMeta.get(), true) + } + + return botRosterMeta(bot, $botMeta.get()) || {} +} + function displayName(bot, meta) { // Only THIN rows from another source trade the friendly name for their // connection label — the active gateway's own default must keep reading @@ -3509,7 +3908,7 @@ function groupLastActivity(room) { * Prefers the LIVE roster row for a stored descriptor when present. */ function groupChatMemberBots(group, roster, metaByName) { const local = (roster || []).filter( - bot => !bot.remoteSource && botGroups(botRosterMeta(bot, metaByName)).includes(group) + bot => botGroups(botRosterMeta(bot, metaByName)).includes(group) ) const stored = ($groupChats.get()[group] || {}).members || [] const seated = new Set(local.map(botRosterKey)) @@ -3533,15 +3932,20 @@ function groupChatMemberBots(group, roster, metaByName) { * source's row may become remote after a connection switch, so retaining it * here is what keeps the same room intact across machines. */ function durableGroupChatMembers(bots) { - return (bots || []).map(bot => ({ - name: bot.name, - handle: bot.handle || bot.name, - connectionId: bot.connectionId, - connectionKind: bot.connectionKind, - connectionLabel: bot.connectionLabel, - remoteSource: true, - sourceScoped: true - })) + return (bots || []).map(bot => { + const route = botConnectionRoute(bot) + + return { + name: bot.name, + handle: bot.handle || bot.name, + connectionId: bot.connectionId, + connectionKind: bot.connectionKind, + connectionLabel: bot.connectionLabel, + ...(route ? { route, targetProfile: route.targetProfile } : {}), + remoteSource: true, + sourceScoped: Boolean(route) + } + }) } /** Existing group names, alphabetical — feeds the Manage-groups dialog. */ @@ -3862,12 +4266,12 @@ async function disbandGroupChat(group, members) { // best-effort profiles.configure per member), so a flaky gateway can't // strand the disband halfway with the room log already gone. for (const member of members) { - if (!member?.name || member.remoteSource) { + if (!member?.name) { continue } - const meta = $botMeta.get()[member.name] || {} - await saveBotMeta(member.name, groupMembershipPatch(meta, group, false)) + const meta = botRosterMeta(member, $botMeta.get()) || {} + await saveBotMeta(member, groupMembershipPatch(meta, group, false)) } } @@ -3935,14 +4339,14 @@ async function renameGroupChat(oldName, newName, members) { // list (syncs cross-machine via ui_meta). Remote members' seating lives in // the room record we just moved. for (const member of members || []) { - if (!member?.name || member.remoteSource) { + if (!member?.name) { continue } - const meta = $botMeta.get()[member.name] || {} + const meta = botRosterMeta(member, $botMeta.get()) || {} const groups = [...new Set(botGroups(meta).map(g => (g === oldName ? next : g)))] - await saveBotMeta(member.name, { groups, group: groups[0] || null }) + await saveBotMeta(member, { groups, group: groups[0] || null }) } // Persist the re-keyed map (updateGroupChat writes the whole durable map). @@ -4643,7 +5047,8 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { const meta = botRosterMeta(bot, useValue($botMeta)) const groups = botGroups(meta) const last = bot.last_session - const isActive = !bot.remoteSource && bot.name === activeProfile + const activeConnectionId = String(host.state.connectionId?.get?.() || 'local').trim() + const isActive = isActiveRosterBot(bot, { name: activeProfile, connectionId: activeConnectionId }) const { shape, color, image } = botAppearance(bot.name, meta) // Keep user photos/pets. Drop the 160px SVG backfill so the math face can move. const photo = Boolean(image && !isBackfilledFacePng(image)) @@ -4656,7 +5061,7 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { // Subscribe on every render. A source switch turns the same keyed row from // thin to rich; conditionally calling useValue here breaks React hook order. const unreadByName = useValue($botUnread) - const unread = !bot.remoteSource && Boolean(unreadByName[bot.name]) + const unread = Boolean(unreadByName[botSelectionKey(bot)]) // WHO sent the last message (bot-to-bot DM vs human) — the full stored // history lives in the Sessions workspace (context menu), not inline. // Preview identity must match click identity (#88200): when the backend @@ -4698,28 +5103,18 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { const open = async () => { const generation = ++botOpenGeneration haptic('tap') - $selectedBot.set(bot.name) - - if (bot.remoteSource) { - const handle = botHandle(bot.name, bot) - host.notify?.({ - kind: 'info', - title: displayName(bot), - message: `Stay in this chat and @${handle} to message them. Gateway stays on this device.` - }) - return - } + $selectedBot.set(botSelectionKey(bot)) let pinnedChat = meta?.chat - if (!bot.remoteSource && $botUnread.get()[bot.name]) { + if ($botUnread.get()[botSelectionKey(bot)]) { const next = { ...$botUnread.get() } - delete next[bot.name] + delete next[botSelectionKey(bot)] $botUnread.set(next) } - // Activate the owner first so every canonical-chat RPC lands on the - // backend that owns this bot's state database. + // Refresh through the immutable owner route. Foreground activation is + // presentation state and is never routing authority for this open. try { pinnedChat = await prepareBotSource(bot, pinnedChat) } catch (error) { @@ -4733,7 +5128,7 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { } try { - const id = await openBotCanonicalChat(bot.name, pinnedChat, previewSession) + const id = await openBotCanonicalChat(bot, pinnedChat, previewSession) if (generation === botOpenGeneration && id) { return @@ -4752,7 +5147,7 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { if (typeof host.newChat === 'function') { // Older gateway without profile-scoped session.create — plain draft. - host.newChat(bot.name) + newBotChat(bot) } else { host.navigate('/') } @@ -4862,14 +5257,6 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { ] }) - // Thin rows from another source are navigation targets only. Their profile - // metadata is not loaded yet, so edit/delete/pin/group actions would mutate - // whichever backend happens to be active. A normal click activates the - // owner; the refreshed rich row then exposes the full context menu. - if (bot.remoteSource) { - return row - } - return jsxs(ContextMenu, { children: [ jsx(ContextMenuTrigger, { asChild: true, children: row }), @@ -4877,32 +5264,34 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { children: [ jsx(ContextMenuItem, { onSelect: () => { - const pinned = Boolean($botMeta.get()[bot.name]?.pinned) - saveBotMeta(bot.name, { pinned: !pinned }) - host.notify({ - kind: 'info', - message: `${displayName(bot, meta)} ${pinned ? 'unpinned' : 'pinned to top'}` - }) + void ensureBotMetadata(bot).then(current => { + const pinned = Boolean(current.pinned) + void saveBotMeta(bot, { pinned: !pinned }) + host.notify({ + kind: 'info', + message: `${displayName(bot, current)} ${pinned ? 'unpinned' : 'pinned to top'}` + }) + }).catch(error => host.notifyError?.(error, 'Could not load bot metadata')) }, children: meta?.pinned ? 'Unpin' : 'Pin to top' }), jsx(ContextMenuItem, { onSelect: () => { - const hidden = Boolean($botMeta.get()[bot.name]?.hidden) - // `hidden: false` (not null) so unhide round-trips through the - // server ui_meta merge the same way the local merge sees it. - saveBotMeta(bot.name, { hidden: !hidden }) + void ensureBotMetadata(bot).then(current => { + const hidden = Boolean(current.hidden) + void saveBotMeta(bot, { hidden: !hidden }) - if (!hidden) { - fallbackSelectionAfterHide(bot.name) - } + if (!hidden) { + fallbackSelectionAfterHide(botSelectionKey(bot)) + } - host.notify({ - kind: 'info', - message: hidden - ? `${displayName(bot, meta)} is back in the roster` - : `${displayName(bot, meta)} hidden — use the eye button in the Bots header to see hidden bots` - }) + host.notify({ + kind: 'info', + message: hidden + ? `${displayName(bot, current)} is back in the roster` + : `${displayName(bot, current)} hidden — use the eye button in the Bots header to see hidden bots` + }) + }).catch(error => host.notifyError?.(error, 'Could not load bot metadata')) }, children: meta?.hidden ? 'Unhide Bot' : 'Hide Bot' }), @@ -4911,17 +5300,18 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { onSelect: () => openBotSessionsWorkspace(bot), children: 'Sessions' }), - jsx(ContextMenuItem, { onSelect: () => onEdit(bot), children: 'Edit Profile' }), - !bot.remoteSource - ? jsx(ContextMenuItem, { - onSelect: () => onGroup(bot), - children: groups.length ? `Groups: ${groups.join(', ')}…` : 'Manage groups…' - }) - : null, + jsx(ContextMenuItem, { + onSelect: () => void ensureBotMetadata(bot).then(() => onEdit(bot)).catch(error => host.notifyError?.(error, 'Could not load bot')), + children: 'Edit Profile' + }), + jsx(ContextMenuItem, { + onSelect: () => void ensureBotMetadata(bot).then(() => onGroup(bot)).catch(error => host.notifyError?.(error, 'Could not load bot groups')), + children: groups.length ? `Groups: ${groups.join(', ')}…` : 'Manage groups…' + }), jsx(ContextMenuItem, { onSelect: () => { host.notify({ kind: 'info', message: `Duplicating ${displayName(bot, meta)}…` }) - duplicateBot(bot, $lastRoster.get().filter(candidate => !candidate.remoteSource)) + duplicateBot(bot, $lastRoster.get()) .then(name => { queryClient.invalidateQueries({ queryKey: ROSTER_KEY }) host.notify({ kind: 'success', message: `Created ${name} — full copy of ${bot.name}` }) @@ -4933,16 +5323,16 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { jsx(ContextMenuSeparator, {}), jsx(ContextMenuItem, { onSelect: () => { - $selectedBot.set(bot.name) + $selectedBot.set(botSelectionKey(bot)) if (typeof host.newChat === 'function') { - host.newChat(bot.name) + newBotChat(bot) } }, children: 'New chat with this agent' }), - bot.is_default ? null : jsx(ContextMenuSeparator, {}), - bot.is_default + isDefaultBot(bot) ? null : jsx(ContextMenuSeparator, {}), + isDefaultBot(bot) ? null : jsx(ContextMenuItem, { onSelect: () => onDelete(bot), @@ -4957,10 +5347,16 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { // ── model picker (provider/model dropdowns via model.options) ─────────────── -function useModelOptions() { +function useModelOptions(bot = null) { + const route = botConnectionRoute(bot) + return useQuery({ - queryKey: [ID, 'model-options'], - queryFn: () => host.request('model.options', { include_unconfigured: true, explicit_only: false, refresh: true }), + queryKey: [ID, 'model-options', route ? botRouteKey(route) : 'active'], + queryFn: () => requestForBot(bot, 'model.options', { + include_unconfigured: true, + explicit_only: false, + refresh: true + }), staleTime: 120000, retry: false }) @@ -4971,8 +5367,8 @@ function useModelOptions() { * same data the core model picker shows. `value = {provider, model}`; * onChange receives the merged patch. */ -function ModelPicker({ value, onChange, placeholderModel = 'gateway default' }) { - const { data, isLoading, error } = useModelOptions() +function ModelPicker({ bot = null, value, onChange, placeholderModel = 'gateway default' }) { + const { data, isLoading, error } = useModelOptions(bot) // Hooks are ALWAYS declared up front, before any conditional return. // Declaring them after a return trips React error #310. @@ -5164,12 +5560,15 @@ function AdvancedProfileConfig({ bot, state, setState }) { const [loaded, setLoaded] = useState(false) const [unsupported, setUnsupported] = useState(false) const [skillFilter, setSkillFilter] = useState('') + const botRoute = botConnectionRoute(bot) + const backendProfile = botRoute?.targetProfile || botRoute?.profile || bot.name + const backendScope = botBackendProfileScope(botRoute, bot.name) if (!loaded) { setLoaded(true) Promise.all([ - host.request('profiles.describe', { name: bot }), - host.request('mcp.catalog', { profile: bot }).catch(() => null) + requestForBot(bot, 'profiles.describe', { name: bot.name }), + requestForBot(bot, 'mcp.catalog', { profile: bot.name }).catch(() => null) ]) .then(([res, cat]) => { const configured = res.mcp_servers || [] @@ -5251,11 +5650,12 @@ function AdvancedProfileConfig({ bot, state, setState }) { // Render THAT instead of the checkbox stand-ins; writes go straight to the // bot's backend, so the dirty-section staging below only carries // model + SOUL on these builds. Older builds keep the full checklist UI. - if (SkillsView) { + if (SkillsView && (!botRoute || skillsViewRoutesConnections)) { return jsxs('div', { className: 'grid gap-4', children: [ jsx(ModelPicker, { + bot, value: { provider: state.provider, model: state.model }, onChange: patch => setState(prev => ({ ...prev, dirtyModel: true, ...patch })) }), @@ -5264,7 +5664,11 @@ function AdvancedProfileConfig({ bot, state, setState }) { jsx('div', { className: 'overflow-hidden rounded-md border border-(--ui-stroke-secondary)', style: { height: 460, minHeight: 300, resize: 'vertical', overflow: 'auto' }, - children: jsx(SkillsView, { embedded: true, fixedProfile: bot }) + children: jsx(SkillsView, { + embedded: true, + fixedProfile: backendProfile, + ...(botRoute ? { fixedConnection: botRoute.connectionId } : {}) + }) }) ), labeled( @@ -5279,10 +5683,36 @@ function AdvancedProfileConfig({ bot, state, setState }) { }) } + if (bot?.sourceScoped && botRoute?.mode === 'remote' && !skillsViewRoutesConnections) { + return jsxs('div', { + className: 'grid gap-4', + children: [ + jsx(ModelPicker, { + bot, + value: { provider: state.provider, model: state.model }, + onChange: patch => setState(prev => ({ ...prev, dirtyModel: true, ...patch })) + }), + jsx('div', { + className: 'rounded-md border border-(--ui-stroke-secondary) px-3 py-2 text-xs text-(--ui-text-tertiary)', + children: 'Remote capabilities require a newer desktop. Model and SOUL changes remain staged until you save.' + }), + labeled( + 'SOUL.md (persona + agent-messaging protocol)', + jsx(Textarea, { + className: 'min-h-28 font-mono text-xs leading-5', + value: state.soul, + onChange: event => setState(prev => ({ ...prev, dirtySoul: true, soul: event.target.value })) + }) + ) + ] + }) + } + return jsxs('div', { className: 'grid gap-4', children: [ jsx(ModelPicker, { + bot, value: { provider: state.provider, model: state.model }, onChange: patch => setState(prev => ({ ...prev, dirtyModel: true, ...patch })) }), @@ -5303,7 +5733,7 @@ function AdvancedProfileConfig({ bot, state, setState }) { children: jsx(CheckList, { items: visibleSkills, onToggle: toggleSkill, columns: 2 }) }), jsx(HubSkillsSection, { - forProfile: bot, + forProfile: backendScope, onInstalled: name => setState(prev => prev.skills.some(s => s.name === name) @@ -5345,7 +5775,7 @@ function AdvancedProfileConfig({ bot, state, setState }) { ToolsetConfigPanel ? jsx('div', { className: 'mt-1.5 border-t border-(--ui-stroke-secondary) pt-1.5', - children: jsx(ToolsetConfigPanel, { toolset: tset.name, profile: bot }) + children: jsx(ToolsetConfigPanel, { toolset: tset.name, profile: backendScope }) }) : null ] @@ -5368,7 +5798,7 @@ function AdvancedProfileConfig({ bot, state, setState }) { children: McpTab && typeof host.getGateway === 'function' ? jsx('div', { style: { minHeight: 220, maxHeight: 360 }, - children: jsx(McpTab, { gateway: host.getGateway(), profile: bot }) + children: jsx(McpTab, { gateway: host.getGateway(), profile: backendScope }) }) : mcpList.length === 0 ? jsx('div', { @@ -5404,7 +5834,7 @@ function AdvancedProfileConfig({ bot, state, setState }) { : null, needsSetup ? jsx(McpSetupButton, { - profile: bot, + profile: backendScope, entry: m, onDone: () => toggleMcp(m.name, true) }) @@ -5722,11 +6152,11 @@ function emptyAdvancedState() { /** Persist only the dirty sections of the advanced editor. */ async function applyAdvancedConfig(bot, state) { - const payload = { name: bot } + const payload = { name: bot.name } const applied = {} if (state.dirtySoul) { - payload.soul = ensureMessagingProtocol(state.soul, bot, $lastRoster.get()) + payload.soul = ensureMessagingProtocol(state.soul, bot.name, $lastRoster.get()) } if (state.dirtyModel) { @@ -5738,8 +6168,8 @@ async function applyAdvancedConfig(bot, state) { payload.provider = provider } else if (!model && !provider) { try { - const result = await host.request('cli.exec', { - argv: ['--profile', bot, 'config', 'unset', 'model'] + const result = await requestForBot(bot, 'cli.exec', { + argv: ['--profile', bot.name, 'config', 'unset', 'model'] }) applied.model = result?.blocked !== true && result?.code === 0 } catch { @@ -5769,7 +6199,7 @@ async function applyAdvancedConfig(bot, state) { return { ok: Object.values(applied).every(Boolean), applied } } - const result = await host.request('profiles.configure', payload) + const result = await requestForBot(bot, 'profiles.configure', payload) const merged = { ...applied, ...(result?.applied || {}) } return { ...result, ok: Object.values(merged).every(Boolean), applied: merged } @@ -5792,7 +6222,7 @@ function labeled(label, control) { function EditProfileDialog({ bot, open, onClose }) { const metaAll = useValue($botMeta) - const meta = bot ? metaAll[bot.name] : null + const meta = bot ? botRosterMeta(bot, metaAll) : null const appearance = bot ? botAppearance(bot.name, meta) : { shape: 'circle', color: AVATAR_COLORS[3] } const [shape, setShape] = useState(appearance.shape) const [color, setColor] = useState(appearance.color) @@ -5805,7 +6235,7 @@ function EditProfileDialog({ bot, open, onClose }) { // Re-seed local state each time a different bot opens the dialog. const [seedKey, setSeedKey] = useState(null) - const currentKey = bot ? `${bot.name}:${open}` : null + const currentKey = bot ? `${botSelectionKey(bot)}:${open}` : null if (currentKey !== seedKey) { setSeedKey(currentKey) if (bot && open) { @@ -5831,7 +6261,7 @@ function EditProfileDialog({ bot, open, onClose }) { setBusy(true) let advancedFailed = false - const persistence = await saveBotMeta(bot.name, { + const persistence = await saveBotMeta(bot, { shape, color, image, @@ -5854,7 +6284,7 @@ function EditProfileDialog({ bot, open, onClose }) { const desc = description.trim() if (desc !== (bot.description || '').trim()) { try { - await host.request('cli.exec', { + await requestForBot(bot, 'cli.exec', { argv: ['profile', 'describe', bot.name, '--text', desc] }) queryClient.invalidateQueries({ queryKey: ROSTER_KEY }) @@ -5865,7 +6295,7 @@ function EditProfileDialog({ bot, open, onClose }) { if (adv.loaded && (adv.dirtyModel || adv.dirtySoul || adv.dirtySkills || adv.dirtyToolsets || adv.dirtyMcp)) { try { - const res = await applyAdvancedConfig(bot.name, adv) + const res = await applyAdvancedConfig(bot, adv) const failed = Object.entries(res?.applied || {}).filter(([, ok]) => !ok) if (failed.length) { @@ -5947,7 +6377,7 @@ function EditProfileDialog({ bot, open, onClose }) { advanced ? jsx('div', { className: 'rounded-md border border-(--ui-stroke-secondary) p-3', - children: jsx(AdvancedProfileConfig, { bot: bot.name, state: adv, setState: setAdv }) + children: jsx(AdvancedProfileConfig, { bot, state: adv, setState: setAdv }) }) : null ] @@ -6841,13 +7271,15 @@ function isLegacyDelegatedRoutine(job) { return Boolean(routineBot(job) && typeof preview === 'string' && preview.startsWith(LEGACY_DELEGATED_ROUTINE_PREFIX)) } -async function loadRoutines(profile) { +async function loadRoutines(owner) { + const bot = typeof owner === 'string' ? { name: owner } : owner + const profile = String(bot?.name || '').trim() // profile scopes cron.manage to that bot's own cron store (core RPC gained an // optional `profile` param). Older gateways ignore the unknown param and // return the launch-profile store — the [bot:] tag filter in selectRoutineJobs // remains the graceful fallback there. const scope = profile ? { profile } : {} - const data = await host.request('cron.manage', { action: 'list', include_disabled: true, ...scope }) + const data = await requestForBot(bot, 'cron.manage', { action: 'list', include_disabled: true, ...scope }) const jobs = Array.isArray(data?.jobs) ? data.jobs : [] const activeLegacyJobs = jobs.filter( job => isLegacyDelegatedRoutine(job) && job.enabled !== false && job.state !== 'paused' @@ -6860,8 +7292,7 @@ async function loadRoutines(profile) { // actually paused, and the next poll retries the rest. const pauses = await Promise.all( activeLegacyJobs.map(job => - host - .request('cron.manage', { action: 'pause', name: job.job_id, ...scope }) + requestForBot(bot, 'cron.manage', { action: 'pause', name: job.job_id, ...scope }) .then(() => true) .catch(() => false) ) @@ -6878,10 +7309,14 @@ async function loadRoutines(profile) { } } -function useRoutines(profile) { +function useRoutines(owner) { + const bot = typeof owner === 'string' ? { name: owner } : owner + const route = botConnectionRoute(bot) + const key = route ? botRosterKey(bot) : bot?.name || '' + return useQuery({ - queryKey: [...ROUTINES_KEY, profile || ''], - queryFn: () => loadRoutines(profile), + queryKey: [...ROUTINES_KEY, key], + queryFn: () => loadRoutines(bot), refetchInterval: 20000, staleTime: 8000 }) @@ -6891,9 +7326,13 @@ function routineCreateTarget(owner, activeBot) { return owner || activeBot } -async function invalidateRoutineOwner(profile) { +async function invalidateRoutineOwner(owner) { + const bot = typeof owner === 'string' ? { name: owner } : owner + const route = botConnectionRoute(bot) + const key = route ? botRosterKey(bot) : bot?.name || '' + await queryClient.invalidateQueries({ - queryKey: [...ROUTINES_KEY, profile || ''], + queryKey: [...ROUTINES_KEY, key], exact: true }) } @@ -7001,7 +7440,8 @@ function scheduleLabel(schedule) { return schedule || '' } -function RoutineRow({ job, profile }) { +function RoutineRow({ job, owner }) { + const profile = typeof owner === 'string' ? owner : owner?.name const [busy, setBusy] = useState(false) // Optimistic overlay: null = trust server state. Set immediately on // toggle so the switch responds even before the refetch lands. @@ -7026,8 +7466,8 @@ function RoutineRow({ job, profile }) { } try { - await host.request('cron.manage', { action, name: job.job_id, ...(profile ? { profile } : {}) }) - await invalidateRoutineOwner(profile) + await requestForBot(owner, 'cron.manage', { action, name: job.job_id, ...(profile ? { profile } : {}) }) + await invalidateRoutineOwner(owner) } catch (err) { setPendingActive(null) host.notifyError(err, 'Cronjob update failed') @@ -7310,6 +7750,7 @@ function CreateRoutineDialog({ bot, open, onClose }) { const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const activeProfile = useValue(host.state.profile) + const profile = typeof bot === 'string' ? bot : bot?.name const schedule = composeSchedule(sched) const reset = () => { @@ -7343,12 +7784,12 @@ function CreateRoutineDialog({ bot, open, onClose }) { sched.freq !== 'once' && sched.freq !== 'advanced' && String(sched.repeatN || '').trim() ? Math.max(1, parseInt(sched.repeatN, 10) || 1) : null - await host.request('cron.manage', { + await requestForBot(bot, 'cron.manage', { action: 'add', - name: `[bot:${bot}] ${title}`, + name: `[bot:${profile}] ${title}`, schedule: schedule.trim(), - prompt: routinePrompt(bot, title, task, activeProfile), - ...(bot ? { profile: bot } : {}), + prompt: routinePrompt(profile, title, task, activeProfile), + ...(profile ? { profile } : {}), ...(repeatN ? { repeat: repeatN } : {}), ...(continuity ? { continuity: true } : {}) }) @@ -7377,7 +7818,7 @@ function CreateRoutineDialog({ bot, open, onClose }) { children: [ jsx(DialogTitle, { children: 'New Cronjob' }), jsx(DialogDescription, { - children: `A recurring task ${displayName({ name: bot }, $botMeta.get()[bot])} runs on a schedule. Runs land in its own chat history.` + children: `A recurring task ${displayName(typeof bot === 'string' ? { name: bot } : bot, botRosterMeta(bot, $botMeta.get()))} runs on a schedule. Runs land in its own chat history.` }) ] }), @@ -7452,16 +7893,25 @@ function RoutinesPane() { // The tile maps to the bot you're chatting with: the live gateway profile // is the truth once a chat opens; $selectedBot covers the gap between a // roster click and the profile swap landing. - const bot = (gatewayProfile || selected || 'default').trim() || 'default' - const meta = useValue($botMeta)[bot] + const roster = $lastRoster.get() + const selectedBot = roster.find(bot => botSelectionKey(bot) === selected) + const activeBot = roster.find(bot => + isActiveRosterBot(bot, { + name: gatewayProfile, + connectionId: String(host.state.connectionId?.get?.() || host.activeConnectionId?.() || 'local') + }) + ) + const owner = selectedBot || activeBot || { name: (gatewayProfile || selected || 'default').trim() || 'default' } + const bot = owner.name + const meta = botRosterMeta(owner, useValue($botMeta)) const { shape, color, image } = botAppearance(bot, meta) - const { data, error, isLoading, refetch } = useRoutines(bot) + const { data, error, isLoading, refetch } = useRoutines(owner) const [createOpen, setCreateOpen] = useState(false) const [createOwner, setCreateOwner] = useState(null) const createTarget = routineCreateTarget(createOwner, bot) const openCreate = () => { - setCreateOwner(bot) + setCreateOwner(owner) setCreateOpen(true) } @@ -7573,7 +8023,7 @@ function RoutinesPane() { className: 'min-h-0 flex-1', children: jsx('div', { className: 'grid gap-1.5 px-2.5 py-2', - children: jobs.map(job => jsx(RoutineRow, { job, profile: bot }, job.job_id)) + children: jobs.map(job => jsx(RoutineRow, { job, owner }, job.job_id)) }) }), jsx(CreateRoutineDialog, { @@ -7596,7 +8046,7 @@ const PROFILE_SESSION_LIST_LIMIT = 200 function openBotSessionsWorkspace(bot) { if (bot?.name && NAME_RE.test(bot.name)) { - $botSessionsWorkspace.set(bot.name) + $botSessionsWorkspace.set(botSelectionKey(bot)) } } @@ -7609,21 +8059,33 @@ function filterProfileSessions(sessions, query) { ) } -function useProfileSessions(botName, gatewayGeneration) { +function useProfileSessions(bot, gatewayGeneration) { + const owner = typeof bot === 'string' ? { name: bot } : bot + const profile = String(owner?.name || '') + const route = botConnectionRoute(owner) + const key = route ? botRosterKey(owner) : profile + return useQuery({ - queryKey: [ID, 'profile-sessions', botName, gatewayGeneration], - enabled: Boolean(botName), + queryKey: [ID, 'profile-sessions', key, gatewayGeneration], + enabled: Boolean(profile), // include_hidden: this browser exists precisely to see the profile's own // (always-hidden) Bot Mode sessions alongside its regular ones. - queryFn: () => host.request('session.list', { profile: botName, limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true }), + queryFn: () => requestForBot(owner, 'session.list', { + profile: route?.targetProfile || profile, + limit: PROFILE_SESSION_LIST_LIMIT, + include_hidden: true + }), refetchInterval: 8000, staleTime: 4000, retry: false }) } -async function openProfileSession(botName, session, gatewayGeneration) { - const profile = String(botName || '') +async function openProfileSession(bot, session, gatewayGeneration) { + const owner = typeof bot === 'string' ? { name: bot } : bot + const profile = String(owner?.name || '') + const route = botConnectionRoute(owner) + const selectionKey = route ? botRosterKey(owner) : profile const id = String(session?.id || '') if (!NAME_RE.test(profile) || !id || gatewayGeneration !== $sessionsGatewayGeneration.get()) return if (typeof host.openSession !== 'function') { @@ -7638,16 +8100,21 @@ async function openProfileSession(botName, session, gatewayGeneration) { typeof session?.message_count === 'number' && Number.isFinite(session.message_count) const expectHistory = hasAuthoritativeCount ? session.message_count > 0 : Boolean(session?.preview) - await host.openSession(id, { profile, awaitHydration: true, expectHistory }) + await host.openSession(id, { + ...(route ? { route } : {}), + profile, + awaitHydration: true, + expectHistory + }) if (gatewayGeneration !== $sessionsGatewayGeneration.get()) return - $botSelectedSessions.set({ ...$botSelectedSessions.get(), [profile]: id }) + $botSelectedSessions.set({ ...$botSelectedSessions.get(), [selectionKey]: id }) } -function ProfileSessionRow({ session, botName, active, gatewayGeneration }) { +function ProfileSessionRow({ session, bot, active, gatewayGeneration }) { return jsxs('button', { type: 'button', 'aria-current': active ? 'page' : undefined, - onClick: () => void openProfileSession(botName, session, gatewayGeneration).catch(err => host.notifyError(err, 'Could not open session')), + onClick: () => void openProfileSession(bot, session, gatewayGeneration).catch(err => host.notifyError(err, 'Could not open session')), className: cn( 'flex w-full flex-col gap-0.5 overflow-hidden rounded-md px-2 py-1.5 text-left transition-colors', 'hover:bg-(--chrome-action-hover)', @@ -7668,13 +8135,13 @@ function ProfileSessionRow({ session, botName, active, gatewayGeneration }) { function ProfileSessionsWorkspace({ bot }) { const gatewayGeneration = useValue($sessionsGatewayGeneration) - const { data, isLoading, error } = useProfileSessions(bot.name, gatewayGeneration) + const { data, isLoading, error } = useProfileSessions(bot, gatewayGeneration) const selectedByProfile = useValue($botSelectedSessions) const [query, setQuery] = useState('') const sourceSessions = data?.sessions || [] const sessions = filterProfileSessions(sourceSessions, query) const inventoryBounded = sourceSessions.length >= PROFILE_SESSION_LIST_LIMIT - const selectedId = selectedByProfile[bot.name] || '' + const selectedId = selectedByProfile[botSelectionKey(bot)] || '' const header = jsxs('div', { className: 'flex items-center gap-2 px-2.5 pt-2.5 pb-2', @@ -7687,7 +8154,7 @@ function ProfileSessionsWorkspace({ bot }) { }), jsx('div', { className: 'min-w-0 flex-1 truncate text-sm font-semibold', - children: `${displayName(bot, $botMeta.get()[bot.name])} sessions` + children: `${displayName(bot, botRosterMeta(bot, $botMeta.get()))} sessions` }) ] }) @@ -7728,7 +8195,7 @@ function ProfileSessionsWorkspace({ bot }) { children: sessions.length ? sessions.map(session => jsx(ProfileSessionRow, { session, - botName: bot.name, + bot, active: selectedId === session.id, gatewayGeneration }, session.id)) @@ -7771,7 +8238,7 @@ function ActiveNowStrip({ roster, activeProfile, gatewayState, metaByName, onOpe children: 'Active now' }), ...active.map(bot => { - const meta = metaByName?.[bot.name] + const meta = botRosterMeta(bot, metaByName) const { shape, color, image } = botAppearance(bot.name, meta) const photo = Boolean(image && !isBackfilledFacePng(image)) const label = displayName(bot, meta) @@ -7810,16 +8277,16 @@ function ActiveNowStrip({ roster, activeProfile, gatewayState, metaByName, onOpe function GroupDialog({ bot, onClose }) { const meta = useValue($botMeta) const [name, setName] = useState('') - const current = botGroups(meta[bot?.name]) + const current = botGroups(botRosterMeta(bot, meta)) const groups = knownGroups(meta) const setMembership = (group, enabled) => { - saveBotMeta(bot.name, groupMembershipPatch(meta[bot.name], group, enabled)) + void saveBotMeta(bot, groupMembershipPatch(botRosterMeta(bot, meta), group, enabled)) host.notify({ kind: 'info', message: enabled - ? `${displayName(bot, meta[bot.name])} added to “${group}”` - : `${displayName(bot, meta[bot.name])} removed from “${group}”` + ? `${displayName(bot, botRosterMeta(bot, meta))} added to “${group}”` + : `${displayName(bot, botRosterMeta(bot, meta))} removed from “${group}”` }) } @@ -7891,7 +8358,7 @@ function GroupDialog({ bot, onClose }) { variant: 'ghost', size: 'sm', className: 'justify-self-start', - onClick: () => saveBotMeta(bot.name, { groups: [], group: null }), + onClick: () => void saveBotMeta(bot, { groups: [], group: null }), children: 'Remove from all groups' }) : null @@ -8125,9 +8592,7 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) { } for (const bot of selected) { - if (!bot.remoteSource) { - void saveBotMeta(bot.name, groupMembershipPatch(botRosterMeta(bot, allMeta), groupName, true)) - } + void saveBotMeta(bot, groupMembershipPatch(botRosterMeta(bot, allMeta), groupName, true)) } // Persist every machine identity, including today's active source. That @@ -9311,7 +9776,7 @@ function BotsPane() { const showHidden = useValue($showHiddenBots) const unreadByName = useValue($botUnread) const hiddenBots = roster.filter(bot => isBotHidden(bot, allMeta)) - const hiddenUnread = hiddenBots.some(bot => !bot.remoteSource && unreadByName[bot.name]) + const hiddenUnread = hiddenBots.some(bot => unreadByName[botSelectionKey(bot)]) const visibleRoster = showHidden ? roster : roster.filter(bot => !isBotHidden(bot, allMeta)) const filteredRoster = filterBots(visibleRoster, allMeta, query) // Group chats are first-class roster rows (Discord-style): one standalone @@ -9352,7 +9817,7 @@ function BotsPane() { const staleNotice = error && !live && roster.length ? 'Roster refresh failed — showing the last good list.' + (gatewayUp ? '' : ' Waiting for the gateway to reconnect…') : null - const sessionsWorkspaceBot = roster.find(bot => bot.name === sessionsWorkspaceName) + const sessionsWorkspaceBot = roster.find(bot => botSelectionKey(bot) === sessionsWorkspaceName) if (sessionsWorkspaceBot) { return jsx(ProfileSessionsWorkspace, { bot: sessionsWorkspaceBot }) @@ -9459,21 +9924,11 @@ function BotsPane() { onOpen: bot => { const generation = ++botOpenGeneration haptic('tap') - $selectedBot.set(bot.name) - - if (bot.remoteSource) { - const handle = botHandle(bot.name, bot) - host.notify?.({ - kind: 'info', - title: displayName(bot), - message: `Stay in this chat and @${handle} to message them. Gateway stays on this device.` - }) - return - } + $selectedBot.set(botSelectionKey(bot)) - if ($botUnread.get()[bot.name]) { + if ($botUnread.get()[botSelectionKey(bot)]) { const next = { ...$botUnread.get() } - delete next[bot.name] + delete next[botSelectionKey(bot)] $botUnread.set(next) } @@ -9494,7 +9949,7 @@ function BotsPane() { try { const id = await openBotCanonicalChat( - bot.name, + bot, pinnedChat, bot.preferred_session || bot.last_session ) @@ -9515,7 +9970,7 @@ function BotsPane() { } if (typeof host.newChat === 'function') { - host.newChat(bot.name) + newBotChat(bot) } else { host.navigate('/') } @@ -9750,25 +10205,10 @@ export default { document.head.appendChild(style) } - // Hydrate persisted avatars/titles. Storage may be sync, async, or - // absent depending on shell version — normalize through Promise.resolve - // inside a try so a storage quirk can NEVER fail the plugin load. - try { - Promise.resolve(ctx.storage?.get?.('bot-meta')) - .then(value => { - if (value && typeof value === 'object' && !Array.isArray(value)) { - const live = $botMeta.get() - const next = { ...value } - for (const name of Object.keys(live)) { - next[name] = { ...(value[name] || {}), ...live[name] } - } - $botMeta.set(next) - } - }) - .catch(() => undefined) - } catch { - /* no storage on this shell — defaults stay */ - } + // Hydrate persisted avatars/titles. Migration writes v2 only after a + // provable sole-local topology and deliberately leaves v1 untouched for + // one-version rollback. + void migrateBotMeta(ctx.storage).catch(() => undefined) // Bot Mode sessions are always hidden now — the old "hide Bot Chats" // pref is gone (its stored key is simply ignored). The reconciliation @@ -9826,7 +10266,12 @@ export default { // clock before its onDispose hook — these kept firing until app restart). const unbindProfileListener = host.state.profile.listen(profile => { if (profile && typeof profile === 'string') { - $selectedBot.set(profile) + const connectionId = String( + host.state.connectionId?.get?.() || + (typeof host.activeConnectionId === 'function' ? host.activeConnectionId() : '') || + '' + ).trim() + $selectedBot.set(connectionId ? `${connectionId}::${profile}` : profile) } }) const unbindGatewayListener = host.state.gateway.listen(handleSessionsGatewayTransition) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs index cd89ff4942d2..2937ac829720 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/activity-toasts.test.mjs @@ -27,6 +27,8 @@ function loadTracker(toastsEnabled) { let value = {} return { get: () => value, set: next => { value = next } } })(), + botRosterMeta: (bot, meta) => meta?.[bot.name] || null, + botSelectionKey: bot => bot.name, displayName: bot => bot.name } const section = source diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs index 872ad0627888..c79ae659531e 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs @@ -10,6 +10,7 @@ function load({ sdkDeleteProfile = false } = {}) { const sdkDeletes = [] const invalidations = [] const stored = [] + const storageOps = [] const values = new Map() const atom = initial => { const slot = { get: () => values.get(slot), set: value => values.set(slot, value) } @@ -28,8 +29,8 @@ function load({ sdkDeleteProfile = false } = {}) { }, ...(sdkDeleteProfile ? { - deleteProfile: async name => { - sdkDeletes.push(name) + deleteProfile: async profile => { + sdkDeletes.push(profile) } } : {}), @@ -52,11 +53,15 @@ function load({ sdkDeleteProfile = false } = {}) { context.plugin.register({ storage: { get: () => null, - set: (key, value) => stored.push({ key, value }) + remove: key => storageOps.push({ op: 'remove', key }), + set: (key, value) => { + stored.push({ key, value }) + storageOps.push({ op: 'set', key, value }) + } }, register: () => undefined }) - return { context, requests, sdkDeletes, invalidations, stored } + return { context, requests, sdkDeletes, invalidations, stored, storageOps } } test('unit: deleting a bot prefers the SDK deleteProfile (teardown-routed REST path)', async () => { @@ -68,6 +73,75 @@ test('unit: deleting a bot prefers the SDK deleteProfile (teardown-routed REST p assert.equal(requests.filter(r => r.method === 'cli.exec').length, 0) }) +test('unit: source-scoped deletion preserves the route targetProfile', async () => { + const { context, sdkDeletes } = load({ sdkDeleteProfile: true }) + const route = { + connectionId: 'source-a', + mode: 'remote', + profile: 'worker', + targetProfile: 'backend-worker' + } + + await context.__delete.deleteBot({ name: 'worker', sourceScoped: true, route }) + + assert.deepEqual(JSON.parse(JSON.stringify(sdkDeletes)), [route]) +}) + +test('unit: source-scoped deletion commits bot-meta-v2 atomically', async () => { + const { context, storageOps } = load({ sdkDeleteProfile: true }) + const route = { + connectionId: 'source-a', + mode: 'remote', + profile: 'worker', + targetProfile: 'backend-worker' + } + const key = 'source-a::worker' + context.__delete.$botMeta.set({ + [key]: { title: 'Worker' }, + 'source-a::writer': { title: 'Writer' } + }) + + await context.__delete.deleteBot({ + name: 'worker', + sourceScoped: true, + route + }) + + assert.deepEqual( + JSON.parse(JSON.stringify(storageOps.slice(-3))), + [ + { op: 'remove', key: 'bot-meta-v2-migrated' }, + { + op: 'set', + key: 'bot-meta-v2', + value: { 'source-a::writer': { title: 'Writer' } } + }, + { op: 'set', key: 'bot-meta-v2-migrated', value: true } + ] + ) +}) + +test('unit: an alias targeting backend default is rejected before deletion', async () => { + const { context, sdkDeletes, requests } = load({ sdkDeleteProfile: true }) + + await assert.rejects( + context.__delete.deleteBot({ + name: 'worker', + sourceScoped: true, + route: { + connectionId: 'source-a', + mode: 'remote', + profile: 'worker', + targetProfile: 'default' + } + }), + /default profile cannot be deleted/i + ) + + assert.deepEqual(sdkDeletes, []) + assert.equal(requests.some(request => request.method === 'cli.exec'), false) +}) + test('unit: older desktop without host.deleteProfile falls back to the non-interactive profile CLI command', async () => { const { context, requests } = load() @@ -101,7 +175,7 @@ test('integration: a deleted bot is removed from plugin-local state and the rost test('regression: the bot context menu exposes a destructive delete action and confirmation', () => { assert.match(pluginSource, /ContextMenuItem, \{[\s\S]*?variant: 'destructive'[\s\S]*?children: 'Delete'/) - assert.match(pluginSource, /bot\.is_default \? null : jsx\(ContextMenuSeparator/) + assert.match(pluginSource, /isDefaultBot\(bot\) \? null : jsx\(ContextMenuSeparator/) assert.match(pluginSource, /ConfirmDialog/) assert.match(pluginSource, /title: 'Delete bot and profile\?'/) assert.match(pluginSource, /This will permanently delete the bot[\s\S]*?and its associated Hermes profile at[\s\S]*?This cannot be undone\./) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bot-meta-hydrate.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bot-meta-hydrate.test.mjs index a0d89bbdf2b7..4da93318b083 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bot-meta-hydrate.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bot-meta-hydrate.test.mjs @@ -70,8 +70,7 @@ test('regression: a slow load still keeps looks from disk under a new pin', asyn runtime.saveBotMeta('researcher', { chat: 'sess-just-created' }) resolveStorage({ researcher: { title: 'Research', shape: 'cloud', color: '#38bdf8' } }) await pending - await Promise.resolve() - await Promise.resolve() + await new Promise(resolve => setTimeout(resolve, 0)) assert.equal(runtime.$botMeta.get().researcher.chat, 'sess-just-created') assert.equal(runtime.$botMeta.get().researcher.shape, 'cloud') diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bot-meta-persistence.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bot-meta-persistence.test.mjs index 9be4b6e88545..9451b72a57aa 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bot-meta-persistence.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bot-meta-persistence.test.mjs @@ -91,7 +91,7 @@ test('a rejected configure request is unsupported, not failed', async () => { }) test('Edit Profile waits for persistence and reports a local-only save', () => { - assert.match(pluginSource, /const persistence = await saveBotMeta\(bot\.name,/) + assert.match(pluginSource, /const persistence = await saveBotMeta\(bot,/) assert.match(pluginSource, /Saved look locally; remote persistence failed/) // The toast fires only on an explicit remote failure — never on the // documented older-gateway fallback. diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-creation.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-creation.test.mjs index dc468d1f65bc..8df1b70b05a5 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-creation.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-creation.test.mjs @@ -12,6 +12,8 @@ function loadCanonicalCreation({ openSession, request }) { const context = { host: { openSession, request }, saveBotMeta: (name, patch) => saved.push({ name, patch }), + botOwner: name => ({ bot: { name }, key: name, name, route: null }), + requestForBot: (_bot, method, params) => context.host.request(method, params), $hideBotChats: { get: () => false }, window: { setTimeout: callback => callback() } } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-empty-recovery.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-empty-recovery.test.mjs index 76a93ffa4106..b3a8439a3b5b 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-empty-recovery.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-empty-recovery.test.mjs @@ -19,6 +19,8 @@ function loadCanonicalRecovery({ openSession, request }) { } }, saveBotMeta: (name, patch) => saved.push({ name, patch }), + botOwner: name => ({ bot: { name }, key: name, name, route: null }), + requestForBot: (_bot, method, params) => context.host.request(method, params), $hideBotChats: { get: () => false }, window: { setTimeout: callback => callback() } } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs index 92776344e5ac..de8c7b4ed416 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-identity.test.mjs @@ -20,6 +20,8 @@ function loadOpenPath({ openSession, request }) { } }, saveBotMeta: (name, patch) => saved.push({ name, patch: JSON.parse(JSON.stringify(patch)) }), + botOwner: name => ({ bot: { name }, key: name, name, route: null }), + requestForBot: (_bot, method, params) => context.host.request(method, params), $hideBotChats: { get: () => false }, window: { setTimeout: callback => callback() } } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-pin.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-pin.test.mjs index b2796ba933b5..388862eeca1e 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-pin.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-pin.test.mjs @@ -27,6 +27,8 @@ function loadOpenPath({ openSession, request }) { } }, saveBotMeta: (name, patch) => saved.push({ name, patch: JSON.parse(JSON.stringify(patch)) }), + botOwner: name => ({ bot: { name }, key: name, name, route: null }), + requestForBot: (_bot, method, params) => context.host.request(method, params), $hideBotChats: { get: () => false }, window: { setTimeout: callback => callback() } } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/cross-connection-bots.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/cross-connection-bots.test.mjs index 876e0a196614..af97687327c3 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/cross-connection-bots.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/cross-connection-bots.test.mjs @@ -51,20 +51,20 @@ function runtime() { .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') .replace('export default {', 'globalThis.plugin = {') .concat( - '\nglobalThis.__x = { botConnectionRoute, requestForBot, groupMemberKey, parseGroupChatMentions, resolveGroupResponders, formatGroupChatLine, buildGroupChatTurnPrompt };\n' + '\nglobalThis.__x = { botConnectionRoute, scopedBotParams, botBackendProfileScope, requestForBot, groupMemberKey, parseGroupChatMentions, resolveGroupResponders, formatGroupChatLine, buildGroupChatTurnPrompt };\n' ) vm.runInNewContext(code, context, { filename: 'plugin.js' }) return context } -test('botConnectionRoute: remote rows get a route descriptor, local rows do not', () => { +test('botConnectionRoute: every source-scoped row gets an immutable route', () => { const ctx = runtime() const { botConnectionRoute } = ctx.__x assert.equal(botConnectionRoute({ name: 'writer', connectionId: 'local' }), null) assert.equal(botConnectionRoute({ name: 'writer' }), null) - // remoteSource is required — an annotated ACTIVE row keeps the active door. - assert.equal(botConnectionRoute({ name: 'writer', connectionId: 'spark', sourceScoped: true }), null) + const active = botConnectionRoute({ name: 'writer', connectionId: 'spark', sourceScoped: true }) + assert.equal(Object.isFrozen(active), true) const route = botConnectionRoute({ name: 'dixie', connectionId: 'mac-mini', remoteSource: true }) assert.deepEqual(JSON.parse(JSON.stringify(route)), { @@ -75,7 +75,7 @@ test('botConnectionRoute: remote rows get a route descriptor, local rows do not' }) }) -test('requestForBot: remote members go through requestProfile, local through host.request', async () => { +test('requestForBot: source-scoped members go through requestProfile', async () => { const ctx = runtime() const calls = [] ctx.host.request = async (method, params) => { @@ -90,13 +90,65 @@ test('requestForBot: remote members go through requestProfile, local through hos const { requestForBot } = ctx.__x await requestForBot({ name: 'local-bot' }, 'session.create', { title: 'x' }) + await requestForBot({ name: 'local-bot', connectionId: 'local', sourceScoped: true }, 'session.create', { title: 'x' }) await requestForBot({ name: 'dixie', connectionId: 'mac-mini', remoteSource: true }, 'prompt.submit', { text: 'hi' }) assert.equal(calls[0][0], 'active') assert.equal(calls[0][1], 'session.create') assert.equal(calls[1][0], 'routed') - assert.equal(calls[1][1], 'mac-mini') - assert.equal(calls[1][2], 'prompt.submit') + assert.equal(calls[1][1], 'local') + assert.equal(calls[2][0], 'routed') + assert.equal(calls[2][1], 'mac-mini') + assert.equal(calls[2][2], 'prompt.submit') +}) + +test('requestForBot: non-identity aliases translate every backend profile RPC shape', async () => { + const ctx = runtime() + const calls = [] + ctx.host.requestProfile = async (route, method, params) => { + calls.push({ route, method, params }) + return {} + } + const bot = { + name: 'worker', + sourceScoped: true, + route: { + connectionId: 'remote-a', + mode: 'remote', + profile: 'worker', + targetProfile: 'backend-worker' + } + } + + await ctx.__x.requestForBot(bot, 'profiles.describe', { name: 'worker' }) + await ctx.__x.requestForBot(bot, 'profiles.configure', { name: 'worker', soul: 'x' }) + await ctx.__x.requestForBot(bot, 'profiles.create', { name: 'worker-2', clone_from: 'worker' }) + await ctx.__x.requestForBot(bot, 'session.create', { profile: 'worker', title: 'Bot Chat' }) + await ctx.__x.requestForBot(bot, 'cli.exec', { argv: ['--profile', 'worker', 'config', 'unset', 'model'] }) + await ctx.__x.requestForBot(bot, 'cli.exec', { argv: ['profile', 'describe', 'worker', '--text', 'x'] }) + + assert.deepEqual(JSON.parse(JSON.stringify(calls.map(call => call.params))), [ + { name: 'backend-worker' }, + { name: 'backend-worker', soul: 'x' }, + { name: 'worker-2', clone_from: 'backend-worker' }, + { profile: 'backend-worker', title: 'Bot Chat' }, + { argv: ['--profile', 'backend-worker', 'config', 'unset', 'model'] }, + { argv: ['profile', 'describe', 'backend-worker', '--text', 'x'] } + ]) + assert.equal(calls.every(call => call.route.profile === 'worker'), true) +}) + +test('advanced capability scope keeps Desktop identity separate from backend targetProfile', () => { + const scope = runtime().__x.botBackendProfileScope({ + connectionId: 'remote-a', + profile: 'worker', + targetProfile: 'backend-worker' + }) + + assert.deepEqual(JSON.parse(JSON.stringify(scope)), { + connectionId: 'remote-a', + profile: 'backend-worker' + }) }) test('groupMemberKey: local members keep bare names (persisted-room compat), remote members are source-qualified', () => { diff --git a/apps/desktop/src/plugins/hermes-bots/tests/embed-real-capabilities.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/embed-real-capabilities.test.mjs index 8ee211ab021f..8faf9e677e6d 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/embed-real-capabilities.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/embed-real-capabilities.test.mjs @@ -18,12 +18,18 @@ test('resolves optional Capabilities components from the SDK namespace', () => { test('AdvancedProfileConfig embeds ToolsetConfigPanel per toolset, scoped to the bot profile', () => { // Rendered only when the SDK export exists (older builds: just the toggle). assert.match(source, /ToolsetConfigPanel\s*\n?\s*\?\s*jsx\('div'/) - assert.match(source, /jsx\(ToolsetConfigPanel, \{ toolset: tset\.name, profile: bot \}\)/) + assert.match(source, /jsx\(ToolsetConfigPanel, \{ toolset: tset\.name, profile: backendScope \}\)/) }) test('AdvancedProfileConfig embeds the real McpTab with a live gateway + profile, feature-detected', () => { assert.match(source, /McpTab && typeof host\.getGateway === 'function'/) - assert.match(source, /jsx\(McpTab, \{ gateway: host\.getGateway\(\), profile: bot \}\)/) + assert.match(source, /jsx\(McpTab, \{ gateway: host\.getGateway\(\), profile: backendScope \}\)/) +}) + +test('AdvancedProfileConfig scopes SkillsView to the connection and backend target profile', () => { + assert.match(source, /const botRoute = botConnectionRoute\(bot\)/) + assert.match(source, /fixedProfile: backendProfile/) + assert.match(source, /fixedConnection: botRoute\.connectionId/) }) test('older-build fallback: the checkbox MCP list + inline McpSetupButton is still present', () => { diff --git a/apps/desktop/src/plugins/hermes-bots/tests/embed-skills-view.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/embed-skills-view.test.mjs index 7269f515dd05..272cf75f7db3 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/embed-skills-view.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/embed-skills-view.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import vm from 'node:vm' // Newer desktop builds export the WHOLE core Capabilities surface (SkillsView, // hermes-agent#87317). The bot editor's Advanced section must render it pinned @@ -10,14 +11,118 @@ import test from 'node:test' const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') +function loadAdvanced(SkillsView, sdkComponents = {}) { + const values = new Map() + const atom = initial => { + const slot = { + get: () => values.get(slot), + set: value => values.set(slot, value), + listen: () => undefined + } + values.set(slot, initial) + return slot + } + const jsx = (type, props = {}) => ({ type, props }) + const stateValues = [true, false, ''] + const routed = [] + let gatewayReads = 0 + let query + const context = { + atom, + Checkbox: 'Checkbox', + GlyphSpinner: 'GlyphSpinner', + Input: 'Input', + ScrollArea: 'ScrollArea', + Textarea: 'Textarea', + document: { createElement: () => ({}), getElementById: () => null, head: { appendChild: () => undefined } }, + host: { + getGateway: () => { + gatewayReads += 1 + return 'ambient-gateway' + }, + request: async (...args) => routed.push(['ambient', ...args]), + requestProfile: async (...args) => routed.push(['profile', ...args]), + state: { + gateway: { get: () => 'open', listen: () => undefined }, + profile: { get: () => 'default', listen: () => undefined } + } + }, + jsx, + jsxs: jsx, + queryClient: { invalidateQueries: () => undefined }, + sdk: { SkillsView, ...sdkComponents }, + useQuery: options => { + query = options + return { data: { providers: [] }, isLoading: false, error: null } + }, + useState: initial => [stateValues.length ? stateValues.shift() : initial, () => undefined], + window: { setTimeout, clearTimeout } + } + const code = source + .replace(/^import\s+\*\s+as\s+sdk\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^import .* from 'react'\r?\n/m, '') + .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') + .replace('export default {', 'globalThis.plugin = {') + .concat('\nglobalThis.__AdvancedProfileConfig = AdvancedProfileConfig; globalThis.__useModelOptions = useModelOptions;') + vm.runInNewContext(code, context, { filename: 'plugin.js' }) + + return { context, gatewayReads: () => gatewayReads, query: () => query, routed } +} + +function descendants(node) { + if (!node || typeof node !== 'object') return [] + const children = Array.isArray(node.props?.children) ? node.props.children : [node.props?.children] + return [node, ...children.flatMap(descendants)] +} + +const remoteBot = { + connectionId: 'remote-a', + name: 'default', + remoteSource: true, + route: { + connectionId: 'remote-a', + mode: 'remote', + profile: 'default', + targetProfile: 'backend-default' + }, + sourceScoped: true, + targetProfile: 'backend-default' +} + +const localBot = { + connectionId: 'local', + name: 'default', + remoteSource: false, + route: { + connectionId: 'local', + mode: 'local', + profile: 'default', + targetProfile: 'default' + }, + sourceScoped: true, + targetProfile: 'default' +} + +const advancedState = { + loaded: true, + provider: '', + model: '', + soul: '', + skills: [], + toolsets: [], + mcp: [{ name: 'remote-mcp', enabled: false, fromCatalog: true, installed: false, requires: ['TOKEN'] }] +} + test('resolves SkillsView as an optional SDK namespace export', () => { assert.match(source, /import \* as sdk from '@hermes\/plugin-sdk'/) assert.match(source, /const SkillsView = typeof sdk === 'undefined' \? undefined : sdk\.SkillsView/) }) test('Edit Profile renders the pinned Capabilities surface when the export exists', () => { - assert.match(source, /if \(SkillsView\) \{/) - assert.match(source, /jsx\(SkillsView, \{ embedded: true, fixedProfile: bot \}\)/) + assert.match(source, /if \(SkillsView && \(!botRoute \|\| skillsViewRoutesConnections\)\) \{/) + assert.match(source, /fixedProfile: backendProfile/) + assert.match(source, /fixedConnection: botRoute\.connectionId/) }) test('New Agent gains a Capabilities tab that materializes the profile first', () => { @@ -43,3 +148,80 @@ test('older-build fallback keeps the checklist UI intact', () => { assert.match(source, /jsx\(CheckList, \{ items: visibleSkills/) assert.match(source, /jsx\(HubSkillsSection, \{/) }) + +test('older SkillsView builds fail closed for remote Edit Profile', () => { + function OldSkillsView() {} + function McpTab() {} + function ToolsetConfigPanel() {} + const runtime = loadAdvanced(OldSkillsView, { McpTab, ToolsetConfigPanel }) + const tree = runtime.context.__AdvancedProfileConfig({ + bot: remoteBot, + state: advancedState, + setState: () => undefined + }) + const nodes = descendants(tree) + + assert.equal(nodes.some(node => node.type === OldSkillsView), false) + assert.equal(nodes.some(node => node.type?.name === 'ModelPicker'), true) + assert.equal(nodes.some(node => node.type === 'Textarea'), true) + assert.equal(nodes.some(node => node.type?.name === 'HubSkillsSection'), false) + assert.equal(nodes.some(node => node.type?.name === 'McpSetupButton'), false) + assert.equal(nodes.some(node => node.type === McpTab), false) + assert.equal(nodes.some(node => node.type === ToolsetConfigPanel), false) + assert.equal(runtime.gatewayReads(), 0) + + for (const node of nodes) { + node.props?.onClick?.() + node.props?.onCheckedChange?.(true) + } + + assert.equal(runtime.routed.some(call => call[0] === 'ambient'), false) + assert.equal(runtime.gatewayReads(), 0) +}) + +test('older SkillsView builds keep local capability fallback unchanged', () => { + function OldSkillsView() {} + function McpTab() {} + function ToolsetConfigPanel() {} + const runtime = loadAdvanced(OldSkillsView, { McpTab, ToolsetConfigPanel }) + const tree = runtime.context.__AdvancedProfileConfig({ + bot: localBot, + state: { ...advancedState, toolsets: [{ name: 'local-tools', enabled: true }] }, + setState: () => undefined + }) + const nodes = descendants(tree) + + assert.equal(nodes.some(node => node.type?.name === 'HubSkillsSection'), true) + assert.equal(nodes.some(node => node.type === McpTab), true) + assert.equal(nodes.some(node => node.type === ToolsetConfigPanel), true) + assert.equal(runtime.gatewayReads(), 1) +}) + +test('connection-aware SkillsView receives separate connection and backend profile', () => { + function RoutedSkillsView() {} + RoutedSkillsView.supportsFixedConnection = true + const runtime = loadAdvanced(RoutedSkillsView) + const tree = runtime.context.__AdvancedProfileConfig({ + bot: remoteBot, + state: advancedState, + setState: () => undefined + }) + const view = descendants(tree).find(node => node.type === RoutedSkillsView) + + assert.equal(view.props.fixedConnection, 'remote-a') + assert.equal(view.props.fixedProfile, 'backend-default') +}) + +test('Edit Profile model options use the captured non-identity Bot route', async () => { + const runtime = loadAdvanced(undefined) + runtime.context.__useModelOptions(remoteBot) + + await runtime.query().queryFn() + + assert.deepEqual(JSON.parse(JSON.stringify(runtime.routed)), [[ + 'profile', + remoteBot.route, + 'model.options', + { include_unconfigured: true, explicit_only: false, refresh: true } + ]]) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs index fda52c1c0683..412f0344f7c9 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs @@ -28,6 +28,8 @@ function loadCreate() { } }, saveBotMeta: () => {}, + botOwner: name => ({ bot: { name }, key: name, name, route: null }), + requestForBot: (_bot, method, params) => context.host.request(method, params), window: { setTimeout: cb => cb() } } const section = source.slice(start, end).concat('\nglobalThis.__c = { createCanonicalChat };\n') @@ -63,6 +65,9 @@ test('hideOwnedBotSessions sweeps canonical chats AND room member sessions', asy } }, $botMeta: { get: () => ({ alpha: { chat: 'chat-a' }, beta: { chat: 'chat-b' }, gamma: {} }) }, + $lastRoster: { get: () => [{ name: 'alpha' }, { name: 'beta' }] }, + botMetaKey: bot => bot.name, + requestForBot: (_bot, method, params) => context.host.request(method, params), $groupChats: { get: () => ({ Core: { sessions: { alpha: 'room-core-a', beta: 'room-core-b' } }, @@ -144,5 +149,5 @@ test('the Bots session browser lists with include_hidden', () => { // The one session.list consumer that must see the always-hidden rows. // (Canonical-chat recovery now goes through profiles.list // preferred_session_ids, whose resolver already sees hidden rows.) - assert.match(source, /session\.list', \{ profile: botName, limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true \}/) + assert.match(source, /requestForBot\(owner, 'session\.list',[\s\S]*include_hidden: true/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/hide-bots.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/hide-bots.test.mjs index 9a1bb6e0d9cd..8539e716f696 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/hide-bots.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/hide-bots.test.mjs @@ -204,7 +204,7 @@ test('shape: revealed hidden rows are dimmed and flagged with the eye-closed gly assert.match(botRow, /meta\?\.hidden && 'opacity-60'/) assert.match(botRow, /name: 'eye-closed'/) assert.match(botRow, /children: meta\?\.hidden \? 'Unhide Bot' : 'Hide Bot'/) - assert.match(botRow, /saveBotMeta\(bot\.name, \{ hidden: !hidden \}\)/) + assert.match(botRow, /saveBotMeta\(bot, \{ hidden: !hidden \}\)/) }) test('shape: hiding never filters mentions, group flows, or the meta/activity sweeps', () => { diff --git a/apps/desktop/src/plugins/hermes-bots/tests/model-inherit.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/model-inherit.test.mjs index f36c5cc09e59..1e539cdd5433 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/model-inherit.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/model-inherit.test.mjs @@ -10,6 +10,7 @@ function loadApplyAdvancedConfig(request) { const end = source.indexOf('// ── edit profile dialog', start) const context = { host: { request }, + requestForBot: (_bot, method, params) => request(method, params), ensureMessagingProtocol: soul => soul, $lastRoster: { get: () => [] } } @@ -45,7 +46,7 @@ test('regression: selecting Inherit explicitly clears the profile model assignme return { code: 0, blocked: false, output: 'Unset model' } }) - const result = await apply('ops', state({ dirtyModel: true })) + const result = await apply({ name: 'ops' }, state({ dirtyModel: true })) assert.deepEqual(JSON.parse(JSON.stringify(calls)), [ { @@ -64,7 +65,7 @@ test('integration: model clearing and other dirty sections report a merged resul return { ok: true, applied: { soul: true } } }) - const result = await apply('ops', state({ dirtyModel: true, dirtySoul: true, soul: '# Ops' })) + const result = await apply({ name: 'ops' }, state({ dirtyModel: true, dirtySoul: true, soul: '# Ops' })) assert.equal(calls[0].method, 'cli.exec') assert.deepEqual(JSON.parse(JSON.stringify(calls[1])), { @@ -80,7 +81,7 @@ test('integration: model clearing and other dirty sections report a merged resul test('regression: a rejected model clear is reported as a failed section', async () => { const apply = loadApplyAdvancedConfig(async () => ({ code: 1, blocked: false, output: 'Config key not set' })) - const result = await apply('ops', state({ dirtyModel: true })) + const result = await apply({ name: 'ops' }, state({ dirtyModel: true })) assert.deepEqual(JSON.parse(JSON.stringify(result)), { ok: false, applied: { model: false } }) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs index f097e95326d0..ee0ae378a508 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs @@ -230,24 +230,27 @@ test('default rows use source identity without borrowing another source title', const { __botRosterKey: key, __botRosterMeta: metaFor, __displayName: name } = runtime() const remote = { name: 'default', - connectionId: 'personal', + connectionId: 'other', connectionLabel: 'Personal', remoteSource: true, sourceScoped: true } - const active = { ...remote, remoteSource: undefined } - const metadata = { default: { title: 'Active workspace' } } + const active = { ...remote, connectionId: 'personal', remoteSource: undefined } + const metadata = { + default: { title: 'Legacy local only' }, + 'personal::default': { title: 'Active workspace' } + } - assert.equal(metaFor(remote, metadata), null) + assert.equal(metaFor(remote, metadata), undefined) assert.equal(name(remote, metaFor(remote, metadata)), 'Personal') - assert.equal(key(remote), 'personal::default') + assert.equal(key(remote), 'other::default') // The ACTIVE gateway's own default is the user's main agent — annotation // (sourceScoped + connection fields) must NOT rename it to a connection // label. Titled: the title wins. Untitled: it stays "Hermes". Regression: // remote-gateway desktops showed the main agent as an IP-derived label // with no shortname (Aug 17 2026 report). - assert.equal(name(active, metadata.default), 'Active workspace') + assert.equal(name(active, metadata['personal::default']), 'Active workspace') assert.equal(name(active, undefined), 'Hermes') }) @@ -264,7 +267,14 @@ test('filterBots: matches the source device name for remote rows', () => { const { __filterBots: filterBots } = runtime() const roster = [ { name: 'research' }, - { name: 'research', remoteSource: true, connectionLabel: 'Homelab', handle: 'research-homelab' } + { + name: 'research', + connectionId: 'homelab', + connectionLabel: 'Homelab', + handle: 'research-homelab', + remoteSource: true, + sourceScoped: true + } ] const hits = filterBots(roster, {}, 'homelab') diff --git a/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs index 4c7db313fa93..e2d6c201978e 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs @@ -56,6 +56,11 @@ function renderBotRow(input = 'alpha') { // #49 session-aware-row helpers referenced inside BotRow. previewKind: () => ({ fromBot: false, sender: null }), generatedSessionTitle: () => null, + isActiveRosterBot: () => false, + isBackfilledFacePng: () => false, + botSelectionKey: value => value.sourceScoped ? `${value.connectionId}::${value.name}` : value.name, + isDefaultBot: value => value.name === 'default', + newBotChat: () => undefined, openBotCanonicalChat: async (...args) => { opened.push(args) return 'stored-chat' @@ -121,7 +126,7 @@ test('behavior: pointer entry prewarms only the hovered bot', () => { assert.deepEqual(warmed, ['alpha']) }) -test('behavior: a remote Connections row stays in this chat instead of hopping SSH', async () => { +test('behavior: a remote Connections row opens through its captured route without activation authority', async () => { const { ensured, opened, row, warmed } = renderBotRow({ connectionId: 'work', connectionLabel: 'Work', @@ -135,10 +140,11 @@ test('behavior: a remote Connections row stays in this chat instead of hopping S await row.props.onClick() assert.deepEqual(ensured, []) - assert.deepEqual(opened, []) + assert.equal(opened.length, 1) + assert.equal(opened[0][0].connectionId, 'work') }) -test('behavior: remote default does not open this-device chat when the source did not activate', async () => { +test('behavior: remote default never opens the same-name local chat', async () => { const bot = { connectionId: 'mac-mini', connectionLabel: 'Mac Mini', @@ -182,6 +188,11 @@ test('behavior: remote default does not open this-device chat when the source di haptic: () => undefined, previewKind: () => ({ fromBot: false, sender: null }), generatedSessionTitle: () => null, + isActiveRosterBot: () => false, + isBackfilledFacePng: () => false, + botSelectionKey: value => value.sourceScoped ? `${value.connectionId}::${value.name}` : value.name, + isDefaultBot: value => value.name === 'default', + newBotChat: () => undefined, openBotCanonicalChat: async (...args) => { opened.push(args) return 'this-device-chat' @@ -217,6 +228,7 @@ test('behavior: remote default does not open this-device chat when the source di await row.props.onClick() assert.deepEqual(ensured, []) - assert.deepEqual(opened, []) + assert.equal(opened.length, 1) + assert.equal(opened[0][0].connectionId, 'mac-mini') assert.equal(errors.length, 0) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/remote-dm-delivery.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/remote-dm-delivery.test.mjs index d864d637ce5f..d27c25967ead 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/remote-dm-delivery.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/remote-dm-delivery.test.mjs @@ -127,6 +127,61 @@ test('remote DM carries sender attribution and relays the reply', async () => { ) }) +test('remote DM preserves a non-identity targetProfile through list, resume, create, and submit', async () => { + const calls = [] + let submitted = false + const ctx = runtime({ + requestProfile: async (route, method, params) => { + calls.push({ route, method, params }) + + if (method === 'profiles.list') { + return { profiles: [{ name: 'backend-worker', ui_meta: {} }] } + } + + if (method === 'session.resume' && params.omit_messages) { + throw new Error('not found') + } + + if (method === 'session.create') { + return { session_id: 'runtime-1', stored_session_id: 'stored-1' } + } + + if (method === 'prompt.submit') { + submitted = true + return {} + } + + if (method === 'session.resume') { + return submitted + ? { messages: [{ role: 'assistant', content: 'done' }], inflight: false, running: false } + : { messages: [] } + } + + return {} + } + }) + const route = { + connectionId: 'remote-a', + mode: 'remote', + profile: 'worker', + targetProfile: 'backend-worker' + } + + await ctx.__dm.deliverRemoteRosterMentions( + [{ name: 'worker', connectionId: 'remote-a', remoteSource: true, route }], + 'ping', + { name: 'Hermes', handle: 'hermes' } + ) + + assert.equal(calls.length > 0, true) + assert.equal(calls.every(call => call.route.profile === 'worker'), true) + assert.equal(calls.every(call => call.route.targetProfile === 'backend-worker'), true) + for (const call of calls.filter(call => ['session.resume', 'session.create'].includes(call.method))) { + assert.equal(call.params.profile, 'backend-worker') + } + assert.equal(calls.find(call => call.method === 'prompt.submit')?.params.session_id, 'runtime-1') +}) + test('source contract: DM poll shares the group-turn shape (bounded, new-assistant-message)', () => { assert.match(pluginSource, /const REMOTE_DM_TIMEOUT_MS = /) assert.match(pluginSource, /pollRemoteDmReply/) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs new file mode 100644 index 000000000000..2ac69d87fa1a --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs @@ -0,0 +1,521 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import vm from 'node:vm' + +const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +function deferred() { + let resolve + let reject + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + + return { promise, resolve, reject } +} + +function load({ requestProfile, agents, profileRoutes, storage } = {}) { + const values = new Map() + const atom = initial => { + const slot = { + get: () => values.get(slot), + set: value => values.set(slot, typeof value === 'function' ? value(values.get(slot)) : value), + listen: () => undefined + } + values.set(slot, initial) + return slot + } + const calls = [] + let activeConnectionId = 'remote-a' + const host = { + activeConnectionId: () => activeConnectionId, + agents: agents || (async () => ({ agents: [], sources: [] })), + deleteProfile: async route => calls.push(['deleteProfile', route]), + ensureAgent: async (connectionId, profile) => { + activeConnectionId = connectionId + calls.push(['ensureAgent', connectionId, profile]) + }, + newChat: route => calls.push(['newChat', route]), + notify: () => undefined, + notifyError: () => undefined, + profileRoutes: profileRoutes || (async () => []), + openSession: async (...args) => calls.push(['openSession', ...args]), + request: async (method, params) => { + calls.push(['ambient', method, params]) + return {} + }, + requestProfile: requestProfile || (async (route, method, params) => { + calls.push(['profile', route, method, params]) + return {} + }), + state: { + connectionId: { get: () => activeConnectionId, listen: () => undefined }, + gateway: { get: () => 'open', listen: () => undefined }, + profile: { get: () => 'default', listen: () => undefined } + } + } + const context = { + atom, + clearTimeout, + console, + Date, + document: { createElement: () => ({}), getElementById: () => null, head: { appendChild: () => undefined } }, + PALETTE_AREA: 'palette', + COMPOSER_AREAS: { middleware: 'middleware' }, + queryClient: { invalidateQueries: () => undefined }, + setTimeout, + host, + window: { setTimeout, clearTimeout } + } + const code = source + .replace(/^import\s+\*\s+as\s+sdk\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^const \{ McpTab, ToolsetConfigPanel \} = sdk\r?\n/m, '') + .replace(/^import .* from 'react'\r?\n/m, '') + .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') + .replace('export default {', 'globalThis.plugin = {') + .concat(` + globalThis.__race = { + $botMeta, + botConnectionRoute, + botRosterKey, + botSelectionKey, + deleteBot, + duplicateBot, + groupMemberKey, + migrateBotMeta, + migratedLocalRoutes, + newBotChat, + openBotCanonicalChat, + openProfileSession, + persistBotMetaSnapshot, + requestForBot, + saveBotMeta, + setPluginStorage: storage => { pluginCtx = { storage } }, + useProfileSessions + } + `) + vm.runInNewContext(code, context, { filename: 'plugin.js' }) + return { + context, + calls, + host, + setActiveConnection: value => { + activeConnectionId = value + } + } +} + +const remoteBot = { + connectionId: 'remote-a', + connectionLabel: 'Remote A', + name: 'default', + remoteSource: true, + route: { connectionId: 'remote-a', mode: 'remote', profile: 'default', targetProfile: 'remote-default' }, + sourceScoped: true, + targetProfile: 'remote-default' +} + +function recordingStorage(initial, failSetKey = null, failRemoveKeys = []) { + const values = new Map(Object.entries(initial)) + const operations = [] + const failedRemovals = new Set(failRemoveKeys) + + return { + operations, + get: async key => values.get(key) ?? null, + set: async (key, value) => { + operations.push(['set', key, value]) + if (key === failSetKey) { + throw new Error('disk full') + } + values.set(key, value) + }, + remove: async key => { + operations.push(['remove', key]) + if (failedRemovals.has(key)) { + throw new Error('remove failed') + } + values.delete(key) + } + } +} + +test('same-name bots remain pinned to their own connection under concurrent requests', async () => { + const pendingA = deferred() + const pendingB = deferred() + const seen = [] + const runtime = load({ + requestProfile: (route, method) => { + seen.push([route, method]) + return route.connectionId === 'remote-a' ? pendingA.promise : pendingB.promise + } + }) + const botB = { ...remoteBot, connectionId: 'remote-b', route: { ...remoteBot.route, connectionId: 'remote-b', targetProfile: 'remote-default-b' } } + + const first = runtime.context.__race.requestForBot(remoteBot, 'profiles.describe') + const second = runtime.context.__race.requestForBot(botB, 'profiles.describe') + await runtime.host.ensureAgent('remote-b', 'default') + pendingB.resolve({ ok: true }) + pendingA.resolve({ ok: true }) + await Promise.all([first, second]) + + assert.deepEqual(seen.map(([route]) => route.connectionId), ['remote-a', 'remote-b']) + assert.deepEqual(seen.map(([route]) => route.targetProfile), ['remote-default', 'remote-default-b']) +}) + +test('source-scoped routing fails closed instead of falling back to ambient host.request', async () => { + const runtime = load() + const ambient = [] + runtime.host.request = async (...args) => ambient.push(args) + runtime.host.requestProfile = undefined + + await assert.rejects( + runtime.context.__race.requestForBot(remoteBot, 'profiles.configure'), + /Cannot route profiles\.configure/ + ) + assert.deepEqual(ambient, []) +}) + +test('source-scoped routing rejects a missing connection id before dispatch', async () => { + const runtime = load() + const incomplete = { + ...remoteBot, + connectionId: '', + route: { ...remoteBot.route, connectionId: '' } + } + + await assert.rejects( + runtime.context.__race.requestForBot(incomplete, 'profiles.configure'), + /no connection owner/i + ) + assert.equal(runtime.calls.some(call => call[0] === 'ambient' || call[0] === 'profile'), false) +}) + +test('delayed edit, group mutation, and canonical session RPCs retain the route', async () => { + const pending = deferred() + const seen = [] + const runtime = load({ + requestProfile: (route, method, params) => { + seen.push([route, method, params]) + return pending.promise + } + }) + + const edit = runtime.context.__race.saveBotMeta(remoteBot, { title: 'Remote title' }) + const group = runtime.context.__race.requestForBot(remoteBot, 'profiles.configure', { name: 'default' }) + const open = runtime.context.__race.openBotCanonicalChat(remoteBot, 'stored-remote', { message_count: 2 }) + await runtime.host.ensureAgent('remote-b', 'default') + pending.resolve({ applied: { ui_meta: true }, profiles: [{ name: 'default', preferred_session: { id: 'stored-remote' } }] }) + await Promise.all([edit, group, open]) + + assert.ok(seen.length >= 3) + assert.ok(seen.every(([route]) => route.connectionId === 'remote-a')) + assert.ok(seen.every(([route]) => Object.isFrozen(route))) +}) + +test('delayed duplicate, delete, and new chat keep source ownership', async () => { + const pending = deferred() + const runtime = load({ + requestProfile: (route, method) => { + runtime.calls.push(['profile', route, method]) + return pending.promise + } + }) + const workerBot = { + ...remoteBot, + name: 'worker', + route: { ...remoteBot.route, profile: 'worker', targetProfile: 'backend-worker' }, + targetProfile: 'backend-worker' + } + runtime.context.__race.$botMeta.set({ 'remote-a::worker': { title: 'Original' } }) + + const duplicate = runtime.context.__race.duplicateBot(workerBot, [workerBot]) + const edit = runtime.context.__race.saveBotMeta(workerBot, { pinned: true }) + await runtime.host.ensureAgent('remote-b', 'default') + pending.resolve({ profiles: [{ name: 'default' }], applied: { ui_meta: true } }) + await Promise.all([duplicate, edit]) + + runtime.context.__race.newBotChat(workerBot) + const deletePromise = runtime.context.__race.deleteBot(workerBot) + await deletePromise + + const routes = runtime.calls + .filter(call => call[0] === 'profile' || call[0] === 'deleteProfile' || call[0] === 'newChat') + .map(call => call[1]) + .filter(value => value && typeof value === 'object') + assert.ok(routes.length >= 3) + assert.ok(routes.every(route => route.connectionId === 'remote-a')) +}) + +test('remote session browsing and opening use the captured route after activation changes', async () => { + const runtime = load() + let query + runtime.context.useQuery = config => { + query = config + return config + } + const result = runtime.context.__race.useProfileSessions(remoteBot, 0) + await runtime.host.ensureAgent('remote-b', 'default') + await result.queryFn() + assert.equal(query.queryKey[2], 'remote-a::default') + const routedList = runtime.calls.find(call => call[0] === 'profile' && call[2] === 'session.list') + assert.equal(routedList[1].connectionId, 'remote-a') + + await runtime.context.__race.openProfileSession(remoteBot, { id: 'stored-remote', message_count: 1 }, 0) + const open = runtime.calls.find(call => call[0] === 'openSession') + assert.equal(open[2].route.connectionId, 'remote-a') + assert.equal(open[2].route.targetProfile, 'remote-default') + assert.equal(runtime.calls.some(call => call[0] === 'ambient'), false) +}) + +test('default deletion is rejected in the executable mutation layer', async () => { + const runtime = load() + await assert.rejects(runtime.context.__race.deleteBot(remoteBot), /default profile cannot be deleted/i) + assert.equal(runtime.calls.some(call => call[0] === 'deleteProfile'), false) +}) + +test('source-scoped bot metadata save commits v2 marker after its data', async () => { + const storage = recordingStorage({ 'bot-meta-v2-migrated': true }) + const runtime = load({ + requestProfile: async () => ({ applied: { ui_meta: true } }) + }) + runtime.context.__race.setPluginStorage(storage) + + await runtime.context.__race.saveBotMeta(remoteBot, { title: 'Remote' }) + + assert.deepEqual(storage.operations.map(operation => operation.slice(0, 2)), [ + ['remove', 'bot-meta-v2-migrated'], + ['set', 'bot-meta-v2'], + ['set', 'bot-meta-v2-migrated'] + ]) +}) + +test('scoped metadata reconciliation commits v2 marker after its data', async () => { + const storage = recordingStorage({ 'bot-meta-v2-migrated': true }) + const runtime = load() + runtime.context.__race.setPluginStorage(storage) + + await runtime.context.__race.persistBotMetaSnapshot({ + 'remote-a::default': { hidden: true } + }, true) + + assert.deepEqual(storage.operations.map(operation => operation.slice(0, 2)), [ + ['remove', 'bot-meta-v2-migrated'], + ['set', 'bot-meta-v2'], + ['set', 'bot-meta-v2-migrated'] + ]) +}) + +test('v1 metadata migrates only for a provable sole-local topology and keeps rollback data', async () => { + const writes = [] + const runtime = load({ + agents: async () => ({ + agents: [{ connectionId: 'local', connectionKind: 'local', profile: 'default' }], + sources: [{ connectionId: 'local', kind: 'local' }] + }), + profileRoutes: async () => [{ connectionId: 'local', mode: 'local', profile: 'default', targetProfile: 'default' }], + storage: { + get: async key => key === 'bot-meta' ? { default: { title: 'Local' } } : null, + set: async (key, value) => writes.push([key, value]) + } + }) + + const migrated = await runtime.context.__race.migrateBotMeta({ + get: async key => key === 'bot-meta' ? { default: { title: 'Local' } } : null, + remove: async () => undefined, + set: async (key, value) => writes.push([key, value]) + }) + assert.equal(migrated, true) + assert.deepEqual(Object.keys(runtime.context.__race.$botMeta.get()), ['local::default']) + assert.deepEqual(writes.map(([key]) => key), ['bot-meta-v2', 'bot-meta-v2-migrated']) +}) + +test('migration rolls back routes when a later v1 profile has no local route', async () => { + const runtime = load({ + agents: async () => ({ + agents: [{ connectionId: 'local', connectionKind: 'local', profile: 'first' }], + sources: [{ connectionId: 'local', kind: 'local' }] + }), + profileRoutes: async () => [ + { connectionId: 'local', mode: 'local', profile: 'first', targetProfile: 'backend-first' } + ] + }) + + + const migrated = await runtime.context.__race.migrateBotMeta({ + get: async key => key === 'bot-meta' + ? { first: { title: 'First' }, missing: { title: 'Missing' } } + : null, + set: async () => assert.fail('storage must not be written before every route is proven') + }) + + assert.equal(migrated, false) + assert.equal(runtime.context.__race.migratedLocalRoutes.size, 0) + assert.deepEqual(JSON.parse(JSON.stringify(runtime.context.__race.$botMeta.get())), { + first: { title: 'First' }, + missing: { title: 'Missing' } + }) +}) + +test('failed marker commit rolls persisted v2 back and reload keeps v1 authoritative', async () => { + const storage = recordingStorage( + { 'bot-meta': { first: { title: 'First' } } }, + 'bot-meta-v2-migrated' + ) + const options = { + agents: async () => ({ + agents: [{ connectionId: 'local', connectionKind: 'local', profile: 'first' }], + sources: [{ connectionId: 'local', kind: 'local' }] + }), + profileRoutes: async () => [ + { connectionId: 'local', mode: 'local', profile: 'first', targetProfile: 'backend-first' } + ], + storage + } + const runtime = load(options) + + const migrated = await runtime.context.__race.migrateBotMeta(storage) + + assert.equal(migrated, false) + assert.equal(runtime.context.__race.migratedLocalRoutes.size, 0) + assert.deepEqual(JSON.parse(JSON.stringify(runtime.context.__race.$botMeta.get())), { + first: { title: 'First' } + }) + assert.deepEqual(storage.operations.map(operation => operation.slice(0, 2)), [ + ['remove', 'bot-meta-v2-migrated'], + ['set', 'bot-meta-v2'], + ['set', 'bot-meta-v2-migrated'], + ['remove', 'bot-meta-v2-migrated'], + ['remove', 'bot-meta-v2'] + ]) + + const reloaded = load(options) + const acceptedAfterReload = await reloaded.context.__race.migrateBotMeta(storage) + + assert.equal(acceptedAfterReload, false) + assert.deepEqual(JSON.parse(JSON.stringify(reloaded.context.__race.$botMeta.get())), { + first: { title: 'First' } + }) +}) + +test('failed marker and failed v2 cleanup still reload v1 instead of markerless v2', async () => { + const storage = recordingStorage( + { 'bot-meta': { first: { title: 'Rollback' } } }, + 'bot-meta-v2-migrated', + ['bot-meta-v2'] + ) + const options = { + agents: async () => ({ + agents: [{ connectionId: 'local', connectionKind: 'local', profile: 'first' }], + sources: [{ connectionId: 'local', kind: 'local' }] + }), + profileRoutes: async () => [ + { connectionId: 'local', mode: 'local', profile: 'first', targetProfile: 'backend-first' } + ], + storage + } + const runtime = load(options) + + assert.equal(await runtime.context.__race.migrateBotMeta(storage), false) + + const reloaded = load(options) + assert.equal(await reloaded.context.__race.migrateBotMeta(storage), false) + assert.deepEqual(JSON.parse(JSON.stringify(reloaded.context.__race.$botMeta.get())), { + first: { title: 'Rollback' } + }) +}) + +test('crash-window v2 write without a marker reloads v1 rollback data', async () => { + const storage = recordingStorage({ + 'bot-meta': { first: { title: 'Rollback' } }, + 'bot-meta-v2': { 'local::first': { title: 'Crash window' } } + }) + const runtime = load({ storage }) + + assert.equal(await runtime.context.__race.migrateBotMeta(storage), false) + assert.deepEqual(JSON.parse(JSON.stringify(runtime.context.__race.$botMeta.get())), { + first: { title: 'Rollback' } + }) +}) + +test('v1 metadata is not projected onto same-name bots in a multi-source topology', async () => { + const writes = [] + const runtime = load({ + agents: async () => ({ + agents: [ + { connectionId: 'local', connectionKind: 'local', profile: 'default' }, + { connectionId: 'remote-a', connectionKind: 'remote', profile: 'default' } + ], + sources: [ + { connectionId: 'local', kind: 'local' }, + { connectionId: 'remote-a', kind: 'remote' } + ] + }), + profileRoutes: async () => [ + { connectionId: 'local', mode: 'local', profile: 'default', targetProfile: 'default' }, + remoteBot.route + ] + }) + + const migrated = await runtime.context.__race.migrateBotMeta({ + get: async key => key === 'bot-meta' ? { default: { title: 'Legacy local' } } : null, + set: async (key, value) => writes.push([key, value]) + }) + + assert.equal(migrated, false) + assert.deepEqual(JSON.parse(JSON.stringify(runtime.context.__race.$botMeta.get())), { + default: { title: 'Legacy local' } + }) + assert.deepEqual(writes, []) +}) + +test('bot-meta-v2 is authoritative only with its persisted commit marker', async () => { + const runtime = load() + const v2 = { + 'remote-a::default': { title: 'A' }, + 'remote-b::default': { title: 'B' } + } + + const migrated = await runtime.context.__race.migrateBotMeta({ + get: async key => key === 'bot-meta-v2' + ? v2 + : key === 'bot-meta-v2-migrated' + ? true + : null, + set: async () => assert.fail('existing v2 must not be rewritten during hydration') + }) + + assert.equal(migrated, true) + assert.deepEqual(JSON.parse(JSON.stringify(runtime.context.__race.$botMeta.get())), v2) +}) + +test('markerless bot-meta-v2 is ignored on reload', async () => { + const runtime = load() + const markerlessV2 = { 'remote-a::default': { title: 'Uncommitted' } } + + const migrated = await runtime.context.__race.migrateBotMeta({ + get: async key => key === 'bot-meta-v2' ? markerlessV2 : null, + set: async () => assert.fail('markerless v2 must not be committed implicitly') + }) + + assert.equal(migrated, false) + assert.deepEqual(JSON.parse(JSON.stringify(runtime.context.__race.$botMeta.get())), {}) +}) + +test('selection and metadata keys do not collide for same-name connections', () => { + const runtime = load() + const { botRosterKey, botSelectionKey, botConnectionRoute } = runtime.context.__race + const botB = { + ...remoteBot, + connectionId: 'remote-b', + route: { ...remoteBot.route, connectionId: 'remote-b' } + } + const a = botConnectionRoute(remoteBot) + const b = botConnectionRoute(botB) + + assert.notEqual(botRosterKey({ ...remoteBot, route: a }), botRosterKey({ ...botB, route: b })) + assert.notEqual(botSelectionKey({ ...remoteBot, route: a }), botSelectionKey({ ...botB, route: b })) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs index f4a58bdcefb6..f275ce93d21d 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs @@ -128,14 +128,22 @@ test('groupChatMemberBots: seats local meta members plus stored remote descripto test('durableGroupChatMembers: retains active and remote source identities', () => { const { durableGroupChatMembers } = load() const members = durableGroupChatMembers([ - { name: 'default', handle: 'noah', connectionId: 'noah', connectionKind: 'remote', connectionLabel: 'Noah' }, + { + name: 'default', + handle: 'noah', + connectionId: 'noah', + connectionKind: 'remote', + connectionLabel: 'Noah', + sourceScoped: true + }, { name: 'default', handle: 'maya', connectionId: 'maya', connectionKind: 'remote', connectionLabel: 'Maya', - remoteSource: true + remoteSource: true, + sourceScoped: true } ]) @@ -149,6 +157,8 @@ test('durableGroupChatMembers: retains active and remote source identities', () connectionId: 'noah', connectionKind: 'remote', connectionLabel: 'Noah', + route: { connectionId: 'noah', mode: 'remote', profile: 'default', targetProfile: 'default' }, + targetProfile: 'default', remoteSource: true, sourceScoped: true }, @@ -158,6 +168,8 @@ test('durableGroupChatMembers: retains active and remote source identities', () connectionId: 'maya', connectionKind: 'remote', connectionLabel: 'Maya', + route: { connectionId: 'maya', mode: 'remote', profile: 'default', targetProfile: 'default' }, + targetProfile: 'default', remoteSource: true, sourceScoped: true } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/routine-owner.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/routine-owner.test.mjs index f0e09041b06a..3ebf4308276f 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/routine-owner.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/routine-owner.test.mjs @@ -66,7 +66,7 @@ test('routine mutation invalidates only its immutable owner cache', async () => test('source contract: row mutations invalidate the profile that owned the request', () => { assert.match( pluginSource, - /function RoutineRow\(\{ job, profile \}\)[\s\S]*await host\.request\('cron\.manage', \{ action, name: job\.job_id, \.\.\.\(profile \? \{ profile \} : \{\}\) \}\)[\s\S]*await invalidateRoutineOwner\(profile\)/ + /function RoutineRow\(\{ job, owner \}\)[\s\S]*await requestForBot\(owner, 'cron\.manage',[\s\S]*await invalidateRoutineOwner\(owner\)/ ) assert.doesNotMatch(pluginSource, /function RoutineRow\(\{ job, onChanged, profile \}\)/) }) @@ -74,10 +74,10 @@ test('source contract: row mutations invalidate the profile that owned the reque test('source contract: create mutations and dialog state retain one owner', () => { assert.match( pluginSource, - /function CreateRoutineDialog\(\{ bot, open, onClose \}\)[\s\S]*await host\.request\('cron\.manage', \{[\s\S]*\.\.\.\(bot \? \{ profile: bot \} : \{\}\)[\s\S]*await invalidateRoutineOwner\(bot\)/ + /function CreateRoutineDialog\(\{ bot, open, onClose \}\)[\s\S]*await requestForBot\(bot, 'cron\.manage',[\s\S]*\.\.\.\(profile \? \{ profile \} : \{\}\)[\s\S]*await invalidateRoutineOwner\(bot\)/ ) assert.match(pluginSource, /const \[createOwner, setCreateOwner\] = useState\(null\)/) - assert.match(pluginSource, /const openCreate = \(\) => \{[\s\S]*setCreateOwner\(bot\)[\s\S]*setCreateOpen\(true\)/) + assert.match(pluginSource, /const openCreate = \(\) => \{[\s\S]*setCreateOwner\(owner\)[\s\S]*setCreateOpen\(true\)/) assert.match(pluginSource, /const createTarget = routineCreateTarget\(createOwner, bot\)/) // key must be the jsx() THIRD argument (a `key:` prop is silently ignored // by the react/jsx-runtime and the dialog would keep stale per-bot state). diff --git a/apps/desktop/src/plugins/hermes-bots/tests/routine-prompt.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/routine-prompt.test.mjs index 79e094d81fe5..ed2de91bf4bd 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/routine-prompt.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/routine-prompt.test.mjs @@ -160,12 +160,12 @@ test('robustness: routine input rejects NUL before cron creation', () => { assert.match(__routines.routineInputError('Normal title', 'Bad\0instruction'), /NUL.*U\+0000/) assert.match( pluginSource, - /const inputError = routineInputError\(title, task\)[\s\S]*if \(inputError\)[\s\S]*setError\(inputError\)[\s\S]*return[\s\S]*host\.request\('cron\.manage'/ + /const inputError = routineInputError\(title, task\)[\s\S]*if \(inputError\)[\s\S]*setError\(inputError\)[\s\S]*return[\s\S]*requestForBot\(bot, 'cron\.manage'/ ) }) test('regression: Create Cronjob passes the active profile to routinePrompt', () => { - assert.match(pluginSource, /prompt: routinePrompt\(bot, title, task, activeProfile\)/) + assert.match(pluginSource, /prompt: routinePrompt\(profile, title, task, activeProfile\)/) const { __routines } = load() const instruction = 'Keep "quoted" output intact' assert.equal(__routines.routinePrompt('ops', 'Check', instruction, 'ops'), instruction) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/routines-profile-scope.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/routines-profile-scope.test.mjs index bac16adc8c31..fd4e52b96136 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/routines-profile-scope.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/routines-profile-scope.test.mjs @@ -19,13 +19,13 @@ test('loadRoutines forwards a profile scope to cron.manage list + pause', () => }) test('useRoutines is bot-keyed so switching bots refetches', () => { - assert.match(source, /queryKey: \[\.\.\.ROUTINES_KEY, profile \|\| ''\]/) - assert.match(source, /queryFn: \(\) => loadRoutines\(profile\)/) + assert.match(source, /queryKey: \[\.\.\.ROUTINES_KEY, key\]/) + assert.match(source, /queryFn: \(\) => loadRoutines\(bot\)/) }) test('RoutineRow toggle and create forward the profile scope', () => { // RoutineRow pause/resume - assert.match(source, /action, name: job\.job_id, \.\.\.\(profile \? \{ profile \} : \{\}\)/) + assert.match(source, /requestForBot\(owner, 'cron\.manage', \{ action, name: job\.job_id, \.\.\.\(profile \? \{ profile \} : \{\}\) \}\)/) // create - assert.match(source, /\.\.\.\(bot \? \{ profile: bot \} : \{\}\)/) + assert.match(source, /requestForBot\(bot, 'cron\.manage',[\s\S]*\.\.\.\(profile \? \{ profile \} : \{\}\)/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs index 8d2d1c03b85b..e65c68e462e8 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/session-workspace.test.mjs @@ -132,19 +132,19 @@ test('sessions workspace: an in-flight open cannot restore selection after gatew assert.deepEqual(plain(runtime.__sessions.$botSelectedSessions.get()), {}) }) -test('source contract: bot rows and Active now activate the owner before canonical chat', () => { +test('source contract: Bot opens capture the owner route without activation authority', () => { assert.match(pluginSource, /async function prepareBotSource\(bot, pinnedChat\)/) - assert.match(pluginSource, /await host\.ensureAgent\(bot\.connectionId, bot\.name\)/) - assert.match(pluginSource, /host\.request\('profiles\.list', \{\}\)/) - assert.match(pluginSource, /const open = async \(\) => \{[\s\S]*await prepareBotSource\(bot, pinnedChat\)[\s\S]*openBotCanonicalChat\(bot\.name, pinnedChat, previewSession\)/) - assert.match(pluginSource, /onOpen: bot => \{[\s\S]*await prepareBotSource\(bot, pinnedChat\)[\s\S]*bot\.preferred_session \|\| bot\.last_session/) + assert.doesNotMatch(pluginSource, /await host\.ensureAgent\(bot\.connectionId, bot\.name\)/) + assert.match(pluginSource, /const refreshed = await requestForBot\(bot, 'profiles\.list', \{\}\)/) + assert.match(pluginSource, /openBotCanonicalChat\(bot, pinnedChat, previewSession\)/) + assert.match(pluginSource, /newBotChat\(bot\)/) assert.match(pluginSource, /openBotSessionsWorkspace\(bot\)[\s\S]*children: 'Sessions'/) }) test('source contract: workspaces disclose the bounded recent-session inventory', () => { - assert.match(pluginSource, /queryKey: \[ID, 'profile-sessions', botName, gatewayGeneration\]/) - assert.match(pluginSource, /enabled: Boolean\(botName\)/) - assert.match(pluginSource, /host\.request\('session\.list', \{ profile: botName, limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true \}\)/) + assert.match(pluginSource, /queryKey: \[ID, 'profile-sessions', key, gatewayGeneration\]/) + assert.match(pluginSource, /enabled: Boolean\(profile\)/) + assert.match(pluginSource, /requestForBot\(owner, 'session\.list'/) assert.match(pluginSource, /Showing the \$\{PROFILE_SESSION_LIST_LIMIT\} most recent sessions\./) assert.match(pluginSource, /No matching sessions in the \$\{PROFILE_SESSION_LIST_LIMIT\} most recent\./) assert.doesNotMatch(pluginSource, /children: 'Activate profile'/) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 6ffdd1b29749..3332a7316499 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -50,6 +50,7 @@ import { $profiles, ensureGatewayAgent, ensureGatewayProfile, + newSessionInAgent, newSessionInProfile, normalizeProfileKey, refreshProfiles, @@ -65,6 +66,7 @@ import { $gatewayState, $messages, $selectedStoredSessionId, + setSessionOwnerHint, requestSessionResume, setResumeExhaustedSessionId } from '@/store/session' @@ -147,6 +149,10 @@ async function requestPluginProfile( params: Record ): Promise { if (typeof route !== 'string') { + if (!route.connectionId.trim() || !route.profile.trim() || !route.targetProfile.trim()) { + throw new Error('Profile route must include connectionId, profile, and targetProfile') + } + return requestGatewayForAgent(route.connectionId, route.profile, method, params) } @@ -201,22 +207,25 @@ const $activeConnectionId = computed($connection, connection => { const DEFAULT_SESSION_HYDRATION_TIMEOUT_MS = 20_000 let openSessionGeneration = 0 -interface PluginOpenSessionOptions { +export interface PluginOpenSessionOptions { awaitHydration?: boolean expectHistory?: boolean hydrationTimeoutMs?: number intent?: OpenSessionIntent keepAllProfilesScope?: boolean profile?: null | string + route?: PluginProfileRoute } function waitForFocusedSessionHydration({ + connectionId, expectHistory, generation, profile, storedSessionId, timeoutMs }: { + connectionId?: string expectHistory: boolean generation: number profile: string @@ -258,6 +267,7 @@ function waitForFocusedSessionHydration({ } const profileMatches = normalizeProfileKey($activeGatewayProfile.get()) === profile + const connectionMatches = !connectionId || $activeConnectionId.get() === connectionId const sessionMatches = $selectedStoredSessionId.get() === storedSessionId const runtimeReady = Boolean($activeSessionId.get()) const historyPainted = Boolean($messages.get().length) @@ -276,12 +286,13 @@ function waitForFocusedSessionHydration({ // surface is real rather than a stuck loader. const hydrated = expectHistory ? historyPainted : runtimeReady - if (profileMatches && sessionMatches && hydrated) { + if (profileMatches && connectionMatches && sessionMatches && hydrated) { finish() } } unbinds.push($activeGatewayProfile.listen(check)) + unbinds.push($activeConnectionId.listen(check)) unbinds.push($selectedStoredSessionId.listen(check)) unbinds.push($activeSessionId.listen(check)) unbinds.push($messages.listen(check)) @@ -387,27 +398,48 @@ export const host = { * entirely. When the deleted profile was the live gateway's, the app is * re-homed to the default profile — same semantics as the core dialog. * Rejects with the backend's error when the delete fails. */ - deleteProfile: async (profile: string): Promise => { - const name = (profile ?? '').trim() + deleteProfile: async (profile: string | PluginProfileRoute): Promise => { + const route = + typeof profile === 'string' + ? null + : { + ...profile, + connectionId: String(profile.connectionId || '').trim(), + profile: String(profile.profile || '').trim(), + targetProfile: String(profile.targetProfile || '').trim() + } + const name = typeof profile === 'string' ? profile.trim() : route?.profile || '' + + if (route && (!route.connectionId || !route.profile || !route.targetProfile)) { + throw new Error('deleteProfile: route requires connectionId, profile, and targetProfile') + } + + const targetProfile = route?.targetProfile || name if (!name) { throw new Error('deleteProfile: profile name required') } - if (normalizeProfileKey(name) === 'default') { + if (normalizeProfileKey(targetProfile) === 'default') { throw new Error('The default profile cannot be deleted.') } // Capture before the delete; re-home after so our write is the last one // (mirrors DeleteProfileDialog — a refreshActiveProfile racing the dying // backend can't clobber the pill back to the deleted profile). - const wasActive = normalizeProfileKey(name) === normalizeProfileKey($activeGatewayProfile.get()) + const wasActive = route + ? route.connectionId === ($activeConnectionId.get() || '') && + normalizeProfileKey(route.profile) === normalizeProfileKey($activeGatewayProfile.get()) + : normalizeProfileKey(name) === normalizeProfileKey($activeGatewayProfile.get()) // A hover-warmed Bot Mode row owns a retained renderer socket. Retire it // before Electron stops the profile backend so the socket closure cannot // schedule a reconnect that resurrects the deleted profile. - retireLocalProfileGateways(name) - await deleteProfile(name) + if (!route || route.mode === 'local') { + retireLocalProfileGateways(targetProfile) + } + + await deleteProfile(targetProfile, route ? { connectionId: route.connectionId, profile: targetProfile } : undefined) // The profile rail paints from the shared $profiles cache; without a // refresh the deleted profile's badge survives and clicking it starts a @@ -475,7 +507,8 @@ export const host = { openSession: async (storedSessionId: string, options: PluginOpenSessionOptions = {}): Promise => { const generation = ++openSessionGeneration - const profile = (options.profile ?? '').trim() + const ownerRoute = options.route ? { ...options.route } : null + const profile = (ownerRoute?.profile ?? options.profile ?? '').trim() const targetProfile = normalizeProfileKey(profile || $activeGatewayProfile.get()) const expectHistory = options.expectHistory ?? false // Wake-path phase timings. Logged ONLY on a hydration timeout (bridged @@ -485,7 +518,15 @@ export const host = { const wakeStartedAt = Date.now() let profileActiveAt = wakeStartedAt - if (profile && profile !== $activeGatewayProfile.get()) { + if (ownerRoute) { + await ensureGatewayAgent(ownerRoute.connectionId, ownerRoute.profile) + setSessionOwnerHint(storedSessionId, ownerRoute) + profileActiveAt = Date.now() + + if (options.keepAllProfilesScope !== false) { + setShowAllProfiles(true) + } + } else if (profile && profile !== $activeGatewayProfile.get()) { await ensureGatewayProfile(profile) profileActiveAt = Date.now() @@ -535,11 +576,16 @@ export const host = { (!expectHistory || $messages.get().length > 0) if (options.awaitHydration && !surfaceHealthy) { - requestSessionResume(storedSessionId) + if (ownerRoute) { + requestSessionResume(storedSessionId, ownerRoute) + } else { + requestSessionResume(storedSessionId) + } } if (options.awaitHydration) { await waitForFocusedSessionHydration({ + connectionId: ownerRoute?.connectionId, expectHistory, generation, profile: targetProfile, @@ -632,8 +678,13 @@ export const host = { /** Start a fresh chat draft, optionally pointed at another profile (its * backend spins up in the background — same door the sidebar's per-profile * "+" uses). */ - newChat: (profile?: null | string): void => { - newSessionInProfile((profile ?? '').trim() || $activeGatewayProfile.get()) + newChat: (profile?: null | string | PluginProfileRoute): void => { + if (profile && typeof profile !== 'string') { + newSessionInAgent({ ...profile }) + } else { + newSessionInProfile((profile ?? '').trim() || $activeGatewayProfile.get()) + } + window.location.hash = '#/' }, diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index 26ca655169bf..68a83d452375 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -29,6 +29,7 @@ vi.mock('@/store/session', async () => { $messages: atom([]), $selectedStoredSessionId: atom(null), requestSessionResume: vi.fn(), + setSessionOwnerHint: vi.fn(), setResumeExhaustedSessionId: vi.fn() } }) @@ -63,6 +64,7 @@ vi.mock('@/store/profile', async () => { $profiles: profiles, ensureGatewayAgent: vi.fn(), ensureGatewayProfile: vi.fn(), + newSessionInAgent: vi.fn(), newSessionInProfile: vi.fn(), normalizeProfileKey: (value: null | string | undefined) => (value ?? '').trim() || 'default', refreshProfiles: vi.fn(async () => profiles.get()), @@ -101,13 +103,19 @@ const { openSession: openSessionCore } = await import('@/app/open-session') const { deleteProfile } = await import('@/hermes') const { requestGatewayForAgent, requestGatewayForProfile, retireLocalProfileGateways } = await import('@/store/gateway') -const { $activeGatewayProfile, $gatewaySwapTarget, $profiles, ensureGatewayProfile, refreshProfiles } = +const { $activeGatewayProfile, $gatewaySwapTarget, $profiles, ensureGatewayAgent, ensureGatewayProfile, refreshProfiles } = await import('@/store/profile') const { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } = await import('@/store/session-states') -const { $activeSessionId, $messages, $selectedStoredSessionId, requestSessionResume, setResumeExhaustedSessionId } = - await import('@/store/session') +const { + $activeSessionId, + $messages, + $selectedStoredSessionId, + requestSessionResume, + setSessionOwnerHint, + setResumeExhaustedSessionId +} = await import('@/store/session') const setMockAtom = (store: unknown, value: T) => (store as { set(next: T): void }).set(value) @@ -223,6 +231,89 @@ describe('connection-aware plugin host APIs', () => { expect(requestGatewayForProfile).not.toHaveBeenCalled() }) + it('fails closed when a descriptor omits connection or target profile identity', async () => { + await expect( + host.requestProfile( + { connectionId: '', mode: 'remote', profile: 'worker', targetProfile: 'worker' }, + 'profiles.configure', + {} + ) + ).rejects.toThrow(/connectionId, profile, and targetProfile/) + await expect( + host.requestProfile( + { connectionId: 'source-a', mode: 'remote', profile: 'worker', targetProfile: '' }, + 'profiles.configure', + {} + ) + ).rejects.toThrow(/connectionId, profile, and targetProfile/) + + expect(requestGatewayForAgent).not.toHaveBeenCalled() + expect(requestGatewayForProfile).not.toHaveBeenCalled() + }) + + it('deletes a remote profile through its captured connection without retiring local pools', async () => { + const route = { + connectionId: 'source-a', + mode: 'remote' as const, + profile: 'worker', + targetProfile: 'backend-worker' + } + vi.mocked(deleteProfile).mockResolvedValueOnce({ ok: true, path: '/profiles/backend-worker' }) + + await host.deleteProfile(route) + + expect(deleteProfile).toHaveBeenCalledWith('backend-worker', { + connectionId: 'source-a', + profile: 'backend-worker' + }) + expect(retireLocalProfileGateways).not.toHaveBeenCalled() + }) + + it('rejects a remote deletion route without a connection id', async () => { + await expect( + host.deleteProfile({ + connectionId: '', + mode: 'remote', + profile: 'worker', + targetProfile: 'backend-worker' + }) + ).rejects.toThrow(/connectionId, profile, and targetProfile/) + + expect(deleteProfile).not.toHaveBeenCalled() + expect(retireLocalProfileGateways).not.toHaveBeenCalled() + }) + + it('rejects an alias to backend default before the transport helper runs', async () => { + await expect( + host.deleteProfile({ + connectionId: 'source-a', + mode: 'remote', + profile: 'worker', + targetProfile: 'default' + }) + ).rejects.toThrow(/default profile cannot be deleted/i) + + expect(deleteProfile).not.toHaveBeenCalled() + }) + + it('retires and deletes a local alias by its backend target profile', async () => { + const route = { + connectionId: 'source-local', + mode: 'local' as const, + profile: 'worker', + targetProfile: 'backend-worker' + } + vi.mocked(deleteProfile).mockResolvedValueOnce({ ok: true, path: '/profiles/backend-worker' }) + + await host.deleteProfile(route) + + expect(retireLocalProfileGateways).toHaveBeenCalledWith('backend-worker') + expect(deleteProfile).toHaveBeenCalledWith('backend-worker', { + connectionId: 'source-local', + profile: 'backend-worker' + }) + }) + it('keeps the profile-only request overload as a legacy fallback', async () => { const result = await host.requestProfile('legacy-worker', 'profiles.list', { include_sessions: true }) @@ -286,6 +377,21 @@ describe('connection-aware plugin host APIs', () => { }) describe('profile-aware plugin session opens', () => { + it('captures the full owner route before opening a remote session', async () => { + const route = { + connectionId: 'source-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'backend-default' + } + + await host.openSession('remote-chat', { route }) + + expect(ensureGatewayAgent).toHaveBeenCalledWith('source-a', 'default') + expect(setSessionOwnerHint).toHaveBeenCalledWith('remote-chat', route) + expect(openSessionCore).toHaveBeenCalledWith('remote-chat', expect.any(Function), 'in-place') + }) + it('waits until the target Bot Chat runtime and history are on main before resolving', async () => { vi.mocked(ensureGatewayProfile).mockImplementationOnce(async (target: null | string | undefined) => { $activeGatewayProfile.set(target || 'default') diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index 6e23a7b07f22..e354321227fa 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -192,6 +192,16 @@ export const $activeGatewayProfile = atom('default') // Profile for the NEXT new chat (chosen via the new-chat picker). null = primary // / default, so single-profile users are unaffected. export const $newChatProfile = atom(null) +export interface AgentProfileRoute { + connectionId: string + mode?: 'local' | 'remote' + profile: string + targetProfile?: string +} + +// A draft remembers the source it was created for. The active gateway may +// change before the first Send; the draft's owner must not change with it. +export const $newChatRoute = atom(null) // Bumped whenever the open session should be dropped for a fresh new-session // draft: a profile switch/create (below), or deleting the project that owns the @@ -517,6 +527,7 @@ export function selectProfile(name: string): void { const switching = $showAllProfiles.get() || target !== normalizeProfileKey($activeGatewayProfile.get()) $showAllProfiles.set(false) $newChatProfile.set(target) + $newChatRoute.set(null) if (switching) { requestFreshSession() @@ -534,10 +545,32 @@ export function selectProfile(name: string): void { export function newSessionInProfile(name: string): void { const target = normalizeProfileKey(name) $newChatProfile.set(target) + $newChatRoute.set(null) requestFreshSession() void ensureGatewayProfile(target) } +/** Start a draft owned by a specific registry agent. Foreground activation is + * only a presentation step; the route stays attached to the draft for the + * eventual session.create request. */ +export function newSessionInAgent(route: AgentProfileRoute): void { + const captured = { + ...route, + connectionId: route.connectionId.trim(), + profile: normalizeProfileKey(route.profile), + ...(route.targetProfile ? { targetProfile: normalizeProfileKey(route.targetProfile) } : {}) + } + + if (!captured.connectionId) { + throw new Error('Agent profile route is missing connectionId') + } + + $newChatProfile.set(captured.profile) + $newChatRoute.set(captured) + requestFreshSession() + void ensureGatewayAgent(captured.connectionId, captured.profile) +} + export function setShowAllProfiles(value: boolean): void { $showAllProfiles.set(value) } diff --git a/apps/desktop/src/store/session-request-router.test.ts b/apps/desktop/src/store/session-request-router.test.ts index f6f3ef89a871..6829b32aa6c0 100644 --- a/apps/desktop/src/store/session-request-router.test.ts +++ b/apps/desktop/src/store/session-request-router.test.ts @@ -60,6 +60,11 @@ function installDesktop(): void { getConnection: vi.fn(async (profile: null | string) => profile ? { port: 5151, profile, token: 'secondary-token' } : { port: 4242, token: 'primary-token' } ), + getConnectionFor: vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({ + port: connectionId === 'source-a' ? 6161 : 6262, + profile, + token: `${connectionId}-token` + })), touchBackend: vi.fn(async () => undefined) } } @@ -139,6 +144,68 @@ describe('sessionRpcNeedsProfileRoute', () => { }) describe('requestForSessionProfile', () => { + it('keeps concurrent same-name requests pinned while foreground activation changes', async () => { + const primary = makePrimary() + setPrimaryGateway(primary as never, 'default') + installDesktop() + await ensureGatewayForProfile('other') + const desktop = (window as unknown as { + hermesDesktop: { getConnectionFor: ReturnType } + }).hermesDesktop + const ambient = vi.fn(async () => ({ ambient: true })) + const routeA = { + connectionId: 'source-a', + profile: 'default', + targetProfile: 'backend-a' + } + const routeB = { + connectionId: 'source-b', + profile: 'default', + targetProfile: 'backend-b' + } + + const fromA = requestForSessionProfile(routeA, ambient as never, 'session.resume', { + profile: 'default', + session_id: 'stored-a' + }) + routeA.connectionId = 'source-b' + routeA.targetProfile = 'mutated-after-dispatch' + await ensureGatewayForProfile('default') + const fromB = requestForSessionProfile(routeB, ambient as never, 'session.resume', { + profile: 'default', + session_id: 'stored-b' + }) + + await Promise.all([fromA, fromB]) + + expect(desktop.getConnectionFor).toHaveBeenCalledWith({ connectionId: 'source-a', profile: 'default' }) + expect(desktop.getConnectionFor).toHaveBeenCalledWith({ connectionId: 'source-b', profile: 'default' }) + expect(secondaryGateways).toHaveLength(3) + expect(secondaryGateways[1].request).toHaveBeenCalledWith('session.resume', { + profile: 'backend-a', + session_id: 'stored-a' + }) + expect(secondaryGateways[2].request).toHaveBeenCalledWith('session.resume', { + profile: 'backend-b', + session_id: 'stored-b' + }) + expect(ambient).not.toHaveBeenCalled() + }) + + it('rejects an explicit route without a connection instead of using ambient state', async () => { + const ambient = vi.fn(async () => ({ ambient: true })) + + await expect( + requestForSessionProfile( + { connectionId: '', profile: 'default', targetProfile: 'backend-default' }, + ambient as never, + 'session.resume', + { session_id: 'stored-a' } + ) + ).rejects.toThrow(/missing connectionId/i) + expect(ambient).not.toHaveBeenCalled() + }) + it("dispatches on the owning profile's own socket when the active route moved off it (#89206)", async () => { const primary = makePrimary() setPrimaryGateway(primary as never, 'default') diff --git a/apps/desktop/src/store/session-request-router.ts b/apps/desktop/src/store/session-request-router.ts index 8c67a529163c..f739ea0bf819 100644 --- a/apps/desktop/src/store/session-request-router.ts +++ b/apps/desktop/src/store/session-request-router.ts @@ -1,4 +1,18 @@ -import { activeGatewayProfileKey, requestGatewayForProfile } from '@/store/gateway' +import { + activeGatewayConnectionId, + activeGatewayProfileKey, + requestGatewayForAgent, + requestGatewayForProfile +} from '@/store/gateway' + +export interface SessionProfileRoute { + connectionId: string + mode?: 'local' | 'remote' + profile: string + targetProfile?: string +} + +export type SessionOwnerScope = undefined | null | string | SessionProfileRoute // ── Session-scoped RPC routing (the #89206 class) ─────────────────────────── // A session-scoped RPC (session.resume / session.activate / session.usage) @@ -15,6 +29,17 @@ import { activeGatewayProfileKey, requestGatewayForProfile } from '@/store/gatew const normKey = (profile: null | string | undefined): string => (profile ?? '').trim() || 'default' +const isRoute = (owner: SessionOwnerScope): owner is SessionProfileRoute => + Boolean(owner && typeof owner === 'object' && 'connectionId' in owner) + +function routeParams(route: SessionProfileRoute, params: Record): Record { + if (!route.targetProfile || !Object.prototype.hasOwnProperty.call(params, 'profile')) { + return params + } + + return { ...params, profile: route.targetProfile } +} + /** * True when a session-scoped RPC must be pinned to `ownerProfile`'s own * socket because the active gateway currently serves a different profile. @@ -22,9 +47,17 @@ const normKey = (profile: null | string | undefined): string => (profile ?? ''). * (the pre-multi-profile behavior) rather than guessing. */ export function sessionRpcNeedsProfileRoute( - ownerProfile: null | string | undefined, - activeProfile: string = activeGatewayProfileKey() + ownerProfile: SessionOwnerScope | undefined, + activeProfile: string = activeGatewayProfileKey(), + activeConnectionId: null | string = activeGatewayConnectionId() ): boolean { + if (isRoute(ownerProfile)) { + // A descriptor is an immutable ownership claim. Even an explicitly local + // route must not collapse to the ambient request: another connection can + // expose the same profile name, and activation is UI state only. + return Boolean(ownerProfile.connectionId.trim()) + } + if (ownerProfile == null || !String(ownerProfile).trim()) { return false } @@ -39,11 +72,21 @@ export function sessionRpcNeedsProfileRoute( * The route is decided at CALL time, not at swap time. */ export function requestForSessionProfile( - ownerProfile: null | string | undefined, + ownerProfile: SessionOwnerScope | undefined, ambientRequest: (method: string, params?: Record) => Promise, method: string, params: Record = {} ): Promise { + if (isRoute(ownerProfile)) { + const connectionId = ownerProfile.connectionId.trim() + + if (!connectionId) { + return Promise.reject(new Error('Session owner route is missing connectionId')) + } + + return requestGatewayForAgent(connectionId, normKey(ownerProfile.profile), method, routeParams(ownerProfile, params)) + } + if (!sessionRpcNeedsProfileRoute(ownerProfile)) { return ambientRequest(method, params) } diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index ab7961dc0382..dcf715a3e14e 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -27,6 +27,7 @@ import { commitWorkspaceCwdForSelectedSession, getRememberedRoute, getRememberedSessionId, + getSessionOwnerHint, mergeSessionPage, rememberedSessionProfile, resolveComposerSessionKey, @@ -36,6 +37,7 @@ import { setCurrentCwdTransient, setRememberedRoute, setRememberedSessionId, + setSessionOwnerHint, setSelectedStoredSessionId, setSessions, shouldMigrateComposerScope, @@ -68,6 +70,35 @@ const session = (over: Partial): SessionInfo => ({ ...over }) +describe('session owner hints', () => { + it('keeps identical session ids separate across connection and profile owners', () => { + const sourceA = { connectionId: 'source-a', mode: 'remote' as const, profile: 'worker', targetProfile: 'backend-a' } + const sourceB = { connectionId: 'source-b', mode: 'remote' as const, profile: 'worker', targetProfile: 'backend-b' } + + setSessionOwnerHint('same-session', sourceA) + setSessionOwnerHint('same-session', sourceB) + + expect(getSessionOwnerHint('same-session', sourceA)).toEqual(sourceA) + expect(getSessionOwnerHint('same-session', sourceB)).toEqual(sourceB) + expect(getSessionOwnerHint('same-session')).toBeUndefined() + }) + + it('bounds owner hints and evicts the oldest scoped identity', () => { + for (let index = 0; index < 257; index += 1) { + setSessionOwnerHint(`bounded-${index}`, { + connectionId: 'bounded-source', + mode: 'remote', + profile: 'worker', + targetProfile: 'backend-worker' + }) + } + + const scope = { connectionId: 'bounded-source', profile: 'worker' } + expect(getSessionOwnerHint('bounded-0', scope)).toBeUndefined() + expect(getSessionOwnerHint('bounded-256', scope)).toMatchObject({ connectionId: 'bounded-source' }) + }) +}) + describe('computed $attentionSessionIds', () => { beforeEach(() => { clearAllSessionStates() diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index eff88f92fbdf..9532c42f429b 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -9,6 +9,7 @@ import { activeConnectionScopeSuffix, rescopeConnectionScopedStores } from '@/li import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage' import { syncCronModelImpactConnection } from '@/store/cron-model-impact-scope' import type { SessionInfo, UsageStats } from '@/types/hermes' +import type { SessionProfileRoute } from './session-request-router' import { clearUnreadOnOpen } from './session-unread-remote' @@ -599,11 +600,63 @@ export const $awaitingResponse = atom(false) // Null whenever the active route has a healthy (or in-flight) resume. export const $resumeFailedSessionId = atom(null) export interface SessionResumeRequest { + ownerRoute?: SessionProfileRoute sequence: number sessionId: string } let sessionResumeRequestSequence = 0 export const $sessionResumeRequest = atom(null) +const SESSION_OWNER_HINT_LIMIT = 256 +const sessionOwnerHints = new Map() + +function sessionOwnerHintKey(sessionId: string, route: Pick): string { + return JSON.stringify([route.connectionId.trim(), route.profile.trim() || 'default', sessionId]) +} + +export function setSessionOwnerHint(sessionId: string, route: SessionProfileRoute): void { + const id = sessionId.trim() + const normalized = { + ...route, + connectionId: route.connectionId.trim(), + profile: route.profile.trim() || 'default', + ...(route.targetProfile ? { targetProfile: route.targetProfile.trim() || 'default' } : {}) + } + + if (!id || !normalized.connectionId) { + return + } + + const key = sessionOwnerHintKey(id, normalized) + sessionOwnerHints.delete(key) + sessionOwnerHints.set(key, { id, route: normalized }) + + while (sessionOwnerHints.size > SESSION_OWNER_HINT_LIMIT) { + const oldest = sessionOwnerHints.keys().next().value + + if (oldest === undefined) { + break + } + + sessionOwnerHints.delete(oldest) + } +} + +export function getSessionOwnerHint( + sessionId: string, + scope?: Pick +): SessionProfileRoute | undefined { + const id = sessionId.trim() + + if (scope) { + const entry = sessionOwnerHints.get(sessionOwnerHintKey(id, scope)) + + return entry ? { ...entry.route } : undefined + } + + const matches = [...sessionOwnerHints.values()].filter(entry => entry.id === id) + + return matches.length === 1 ? { ...matches[0].route } : undefined +} // Stored-session id whose resume has EXHAUSTED its bounded auto-retries (the // terminal-failure latch above kept failing through all MAX_RESUME_RETRIES // attempts). Distinct from $resumeFailedSessionId, which is armed *during* the @@ -778,14 +831,22 @@ export const setMessages = (next: Updater) => updateAtom($message export const setFreshDraftReady = (next: Updater) => updateAtom($freshDraftReady, next) export const setResumeFailedSessionId = (next: Updater) => updateAtom($resumeFailedSessionId, next) -export const requestSessionResume = (sessionId: string) => { +export const requestSessionResume = (sessionId: string, ownerRoute?: SessionProfileRoute) => { const id = sessionId.trim() if (!id) { return } - $sessionResumeRequest.set({ sequence: ++sessionResumeRequestSequence, sessionId: id }) + if (ownerRoute) { + setSessionOwnerHint(id, ownerRoute) + } + + $sessionResumeRequest.set({ + ...(ownerRoute ? { ownerRoute: { ...ownerRoute } } : {}), + sequence: ++sessionResumeRequestSequence, + sessionId: id + }) } export const setResumeExhaustedSessionId = (next: Updater) => updateAtom($resumeExhaustedSessionId, next) diff --git a/apps/desktop/src/store/transcript-tail.ts b/apps/desktop/src/store/transcript-tail.ts index fc1794743acd..c87e615d92e2 100644 --- a/apps/desktop/src/store/transcript-tail.ts +++ b/apps/desktop/src/store/transcript-tail.ts @@ -25,14 +25,60 @@ export interface TranscriptTailState { possiblyTruncated: boolean /** Owning profile captured at hydration time, so a later backfill routes * its REST read to the same backend that served the tail. */ + profile?: TranscriptProfileScope +} + +export type TranscriptProfileScope = null | string | { + connectionId?: null | string profile?: null | string } export const $transcriptTailBySessionId = atom>({}) +const TRANSCRIPT_TAIL_LIMIT = 256 +let transcriptTailOrder: string[] = [] type TailPage = Pick -function tailStateFromPage(page: TailPage, profile?: null | string): TranscriptTailState { +function normalizedScope(profile?: TranscriptProfileScope): { connectionId: string; profile: string } | null { + if (typeof profile === 'string') { + return { connectionId: '', profile: profile.trim() || 'default' } + } + + if (!profile) { + return null + } + + return { + connectionId: String(profile.connectionId || '').trim(), + profile: String(profile.profile || '').trim() || 'default' + } +} + +function transcriptTailKey(storedSessionId: string, profile?: TranscriptProfileScope): string { + const scope = normalizedScope(profile) + + return scope ? JSON.stringify([scope.connectionId, scope.profile, storedSessionId]) : storedSessionId +} + +function matchingTailEntries( + storedSessionId: string +): Array<[string, TranscriptTailState]> { + return Object.entries($transcriptTailBySessionId.get()).filter(([key]) => { + if (key === storedSessionId) { + return true + } + + try { + const parsed = JSON.parse(key) + + return Array.isArray(parsed) && parsed.length === 3 && parsed[2] === storedSessionId + } catch { + return false + } + }) +} + +function tailStateFromPage(page: TailPage, profile?: TranscriptProfileScope): TranscriptTailState { const pagination = page.pagination // No pagination metadata is a legacy backend that ignored the paging query @@ -48,37 +94,94 @@ function tailStateFromPage(page: TailPage, profile?: null | string): TranscriptT } } +function setTranscriptTailEntry(key: string, state: TranscriptTailState): void { + const current = $transcriptTailBySessionId.get() + const existing = new Set(Object.keys(current)) + transcriptTailOrder = transcriptTailOrder.filter(candidate => candidate !== key && existing.has(candidate)) + transcriptTailOrder.push(key) + + const next = { ...current, [key]: state } + + while (transcriptTailOrder.length > TRANSCRIPT_TAIL_LIMIT) { + const oldest = transcriptTailOrder.shift() + + if (oldest !== undefined) { + delete next[oldest] + } + } + + $transcriptTailBySessionId.set(next) +} + /** Record the outcome of a tail hydration (`getLatestSessionMessages`). */ -export function recordTranscriptTail(storedSessionId: string, page: TailPage, profile?: null | string): void { +export function recordTranscriptTail(storedSessionId: string, page: TailPage, profile?: TranscriptProfileScope): void { if (!storedSessionId) { return } - const current = $transcriptTailBySessionId.get() - $transcriptTailBySessionId.set({ ...current, [storedSessionId]: tailStateFromPage(page, profile) }) + const key = transcriptTailKey(storedSessionId, profile) + setTranscriptTailEntry(key, tailStateFromPage(page, profile)) } /** Advance the bookkeeping after one older backfill page landed. */ -export function recordTranscriptBackfillPage(storedSessionId: string, page: TailPage): void { +export function recordTranscriptBackfillPage( + storedSessionId: string, + page: TailPage, + profile?: TranscriptProfileScope +): void { const current = $transcriptTailBySessionId.get() - const previous = current[storedSessionId] - $transcriptTailBySessionId.set({ - ...current, - [storedSessionId]: tailStateFromPage(page, previous?.profile) - }) + const selected: Array<[string, TranscriptTailState | undefined]> = profile === undefined + ? matchingTailEntries(storedSessionId) + : [[transcriptTailKey(storedSessionId, profile), current[transcriptTailKey(storedSessionId, profile)]]] + + if (selected.length !== 1) { + return + } + + const [key, previous] = selected[0] + + if (!previous) { + return + } + + setTranscriptTailEntry(key, tailStateFromPage(page, previous.profile)) } -export function transcriptTailState(storedSessionId: null | string | undefined): TranscriptTailState | undefined { - return storedSessionId ? $transcriptTailBySessionId.get()[storedSessionId] : undefined +export function transcriptTailState( + storedSessionId: null | string | undefined, + profile?: TranscriptProfileScope +): TranscriptTailState | undefined { + if (!storedSessionId) { + return undefined + } + + if (profile !== undefined) { + return $transcriptTailBySessionId.get()[transcriptTailKey(storedSessionId, profile)] + } + + const matches = matchingTailEntries(storedSessionId) + + return matches.length === 1 ? matches[0][1] : undefined } -export function clearTranscriptTail(storedSessionId: string): void { +export function clearTranscriptTail(storedSessionId: string, profile?: TranscriptProfileScope): void { const current = $transcriptTailBySessionId.get() + const keys = profile === undefined + ? matchingTailEntries(storedSessionId).map(([key]) => key) + : [transcriptTailKey(storedSessionId, profile)] - if (!(storedSessionId in current)) { + if (keys.length === 0) { return } - const { [storedSessionId]: _dropped, ...rest } = current - $transcriptTailBySessionId.set(rest) + const next = { ...current } + + for (const key of keys) { + delete next[key] + } + + const removed = new Set(keys) + transcriptTailOrder = transcriptTailOrder.filter(key => !removed.has(key)) + + $transcriptTailBySessionId.set(next) } From fc05cfb58e546ef95d790e15ae8826fbf56b5c20 Mon Sep 17 00:00:00 2001 From: pmos69 Date: Sat, 23 May 2026 01:38:15 +0100 Subject: [PATCH 011/666] fix nvidia tool message schema --- plugins/model-providers/nvidia/__init__.py | 27 ++++++++++++- tests/providers/test_e2e_wiring.py | 45 ++++++++++++++++++++++ tests/providers/test_provider_profiles.py | 41 ++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/plugins/model-providers/nvidia/__init__.py b/plugins/model-providers/nvidia/__init__.py index f6fdc550f620..a9e8c7382fb3 100644 --- a/plugins/model-providers/nvidia/__init__.py +++ b/plugins/model-providers/nvidia/__init__.py @@ -1,9 +1,34 @@ """NVIDIA NIM provider profile.""" +import copy +from typing import Any + from providers import register_provider from providers.base import ProviderProfile -nvidia = ProviderProfile( + +class NvidiaProviderProfile(ProviderProfile): + """NVIDIA NIM accepts a stricter ToolMessage schema than most OpenAI-compatible APIs.""" + + def prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + needs_sanitize = any( + isinstance(msg, dict) + and msg.get("role") == "tool" + and ("name" in msg or "tool_name" in msg) + for msg in messages + ) + if not needs_sanitize: + return messages + + sanitized = copy.deepcopy(messages) + for msg in sanitized: + if isinstance(msg, dict) and msg.get("role") == "tool": + msg.pop("name", None) + msg.pop("tool_name", None) + return sanitized + + +nvidia = NvidiaProviderProfile( name="nvidia", aliases=("nvidia-nim",), env_vars=("NVIDIA_API_KEY",), diff --git a/tests/providers/test_e2e_wiring.py b/tests/providers/test_e2e_wiring.py index 90549891f29f..334c4047bbf2 100644 --- a/tests/providers/test_e2e_wiring.py +++ b/tests/providers/test_e2e_wiring.py @@ -39,6 +39,51 @@ def test_nvidia_model_passed(self, transport): assert kwargs["model"] == "nvidia/test-model" + def test_nvidia_tool_messages_drop_name_fields(self, transport): + profile = get_provider_profile("nvidia") + msgs = [ + {"role": "user", "content": "run a command"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "terminal", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "name": "terminal", + "tool_name": "terminal", + "tool_call_id": "call_1", + "content": "ok", + }, + ] + kwargs = transport.build_kwargs( + model="mistralai/mistral-large-3-675b-instruct-2512", + messages=msgs, + tools=None, + provider_profile=profile, + max_tokens=None, + max_tokens_param_fn=lambda x: {"max_tokens": x} if x else {}, + timeout=300, + reasoning_config=None, + request_overrides=None, + session_id="test", + ollama_num_ctx=None, + ) + + assert kwargs["messages"][2] == { + "role": "tool", + "tool_call_id": "call_1", + "content": "ok", + } + assert msgs[2]["name"] == "terminal" + assert msgs[2]["tool_name"] == "terminal" + class TestDeepSeekProfileWiring: def test_deepseek_no_forced_max_tokens(self, transport): diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index b5fccc708421..16ca380f94b8 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -25,6 +25,47 @@ def test_base_url(self): assert "nvidia.com" in p.base_url + def test_prepare_messages_strips_tool_result_names(self): + p = get_provider_profile("nvidia") + msgs = [ + {"role": "user", "content": "run a command"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "terminal", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "name": "terminal", + "tool_name": "terminal", + "tool_call_id": "call_1", + "content": "ok", + }, + ] + + result = p.prepare_messages(msgs) + + assert "name" not in result[2] + assert "tool_name" not in result[2] + assert result[2] == { + "role": "tool", + "tool_call_id": "call_1", + "content": "ok", + } + assert msgs[2]["name"] == "terminal" + assert msgs[2]["tool_name"] == "terminal" + + def test_prepare_messages_passthrough_without_tool_result_names(self): + p = get_provider_profile("nvidia") + msgs = [{"role": "tool", "tool_call_id": "call_1", "content": "ok"}] + assert p.prepare_messages(msgs) is msgs + class TestKimiProfile: def test_temperature_omit(self): From b95f3a520251253559ae49ef578d96bae499ebe5 Mon Sep 17 00:00:00 2001 From: Riccardo Roveri Date: Wed, 19 Aug 2026 18:14:03 +0200 Subject: [PATCH 012/666] fix(providers): use copy-on-write instead of deepcopy in NVIDIA prepare_messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the unresolved review comment on #30674 — avoid deep-copying the whole conversation (including large tool outputs and attachments) to sanitize two top-level keys on a handful of tool-role messages. Switches to a shallow outer-list copy + shallow per-message dict copy, applied only to messages that actually carry name/tool_name. Matches the copy-on-write pattern already used by the shared sanitizer in agent/transports/chat_completions.py and by QwenProfile.prepare_messages(). The needs_sanitize early-return path is preserved unchanged so the existing identity check (prepare_messages returns the original list object when nothing needs sanitizing) continues to hold. --- plugins/model-providers/nvidia/__init__.py | 25 ++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/plugins/model-providers/nvidia/__init__.py b/plugins/model-providers/nvidia/__init__.py index a9e8c7382fb3..117569369217 100644 --- a/plugins/model-providers/nvidia/__init__.py +++ b/plugins/model-providers/nvidia/__init__.py @@ -1,6 +1,5 @@ """NVIDIA NIM provider profile.""" -import copy from typing import Any from providers import register_provider @@ -20,11 +19,25 @@ def prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any if not needs_sanitize: return messages - sanitized = copy.deepcopy(messages) - for msg in sanitized: - if isinstance(msg, dict) and msg.get("role") == "tool": - msg.pop("name", None) - msg.pop("tool_name", None) + # Copy-on-write: shallow outer-list copy, then a shallow dict copy + # only for the role:"tool" messages that actually need a field + # dropped. Avoids recursively deep-copying every message's content + # (including large tool outputs and attachments) for a turn that + # only ever needs to touch two top-level keys on a handful of + # messages. Matches the pattern already used by the shared + # sanitizer in agent/transports/chat_completions.py and by + # QwenProfile.prepare_messages(). + sanitized = list(messages) + for idx, msg in enumerate(messages): + if ( + isinstance(msg, dict) + and msg.get("role") == "tool" + and ("name" in msg or "tool_name" in msg) + ): + msg_copy = dict(msg) + msg_copy.pop("name", None) + msg_copy.pop("tool_name", None) + sanitized[idx] = msg_copy return sanitized From 34bdf73381ce1bcf38d2e160471d76022f5866d7 Mon Sep 17 00:00:00 2001 From: Riccardo Roveri Date: Thu, 20 Aug 2026 11:08:02 +0200 Subject: [PATCH 013/666] chore: add contributor attribution mapping for rroveri@nvidia.com --- contributors/emails/rroveri@nvidia.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/rroveri@nvidia.com diff --git a/contributors/emails/rroveri@nvidia.com b/contributors/emails/rroveri@nvidia.com new file mode 100644 index 000000000000..13057051023d --- /dev/null +++ b/contributors/emails/rroveri@nvidia.com @@ -0,0 +1 @@ +rroverin From 65a9833564d84fc41c39e75b59cbd081e3ca1de0 Mon Sep 17 00:00:00 2001 From: Riccardo Roveri Date: Thu, 20 Aug 2026 18:29:26 +0200 Subject: [PATCH 014/666] chore: kick CI From ebae0064a28181375c85e1f321394fefc9a6634f Mon Sep 17 00:00:00 2001 From: poisdahl <4091911+poisdahl@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:48:57 +0200 Subject: [PATCH 015/666] fix(compression): preserve live assistant carriers after refresh --- agent/context_compressor.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 67810a7aa07b..46a20e332e7c 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -8156,16 +8156,19 @@ def _handoff_carries_live_user_content(message: Any) -> bool: ask, leaving a non-empty remainder after ``_SUMMARY_END_MARKER``. Either shape must remain actionable (#80622 must not treat them as sole-handoff). - Delegates to the canonical live-user projection. That projection uses - ``_strip_context_summary_handoff_message`` for string and multimodal - carriers, then excludes typed synthetic rows and empty/non-actionable - remainders. Callers must still pre-filter with - ``is_compaction_summary_message`` because an ordinary human row naturally - has a live-user projection too. + Delegates to ``_strip_context_summary_handoff_message`` — the canonical + "does anything survive once the handoff is removed" logic. This helper + also applies to merged assistant carriers whose pending tool calls keep an + exchange in flight, so it must not use the user-row-only display projection. + Callers must pre-filter with ``is_compaction_summary_message`` because a + non-summary row is returned unchanged by the strip helper. """ if not isinstance(message, dict): return False - return user_originated_turn_view(message) is not None + return ( + ContextCompressor._strip_context_summary_handoff_message(message) + is not None + ) def reference_handoff_would_drive_next_model_call( From fd41164861575f5564cdb091dc16204d0f49883b Mon Sep 17 00:00:00 2001 From: poisdahl <4091911+poisdahl@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:30:35 +0200 Subject: [PATCH 016/666] fix(history): keep carrier rewinds race-safe after refresh --- cli.py | 13 ++-- gateway/session.py | 16 +++-- gateway/slash_commands.py | 13 ++-- hermes_cli/web_routers/sessions.py | 11 ++-- hermes_state.py | 16 +++++ tests/gateway/test_retry_replacement.py | 59 ++++++++++++++++++ tests/gateway/test_undo_rewind_session.py | 61 +++++++++++++++++++ tests/hermes_cli/test_web_server.py | 17 +++++- .../test_composite_carrier_rewind.py | 30 +++++++++ tui_gateway/methods_session.py | 2 +- tui_gateway/methods_tools.py | 8 ++- tui_gateway/server.py | 30 +++------ 12 files changed, 226 insertions(+), 50 deletions(-) diff --git a/cli.py b/cli.py index 0d4a5a7386e7..746773dd47da 100644 --- a/cli.py +++ b/cli.py @@ -10183,6 +10183,9 @@ def _comparison_content(message: Dict[str, Any]) -> Any: return sanitize_context(content).strip() return content + expected_active_ids = self._session_db.get_active_message_ids( + self.session_id + ) durable = self._session_db.get_messages_as_conversation( self.session_id, include_row_ids=True, @@ -10226,12 +10229,6 @@ def _comparison_content(message: Dict[str, Any]) -> Any: target_row_id = durable_target.get("_row_id") if not isinstance(target_row_id, int): raise RuntimeError("persisted rewind target has no row identity") - expected_active_ids = [ - int(message["_row_id"]) - for message in durable - if isinstance(message.get("_row_id"), int) - ] - scaffold, _ = split_user_originated_turn(durable_target) result = self._session_db.rewind_to_message( self.session_id, @@ -10262,7 +10259,7 @@ def retry_last(self): # Walk backwards to the last *real* user message. Timeline bookkeeping # rows (display_kind set) are role=user but are not user turns — match - # CLI resume counting and list_recent_user_messages. Compaction + # CLI resume counting and user_originated_turn_view. Compaction # handoffs are excluded too (durable role=user, sometimes without # display_kind on legacy sessions; #80622). from agent.context_compressor import ( @@ -10363,7 +10360,7 @@ def undo_last(self, n: int = 1, prefill: bool = True): # Walk backwards collecting the indices of the last N *real* user # messages (exclude display_kind timeline rows and compaction - # handoffs — same predicate as list_recent_user_messages, resume + # handoffs — same predicate as user_originated_turn_view, resume # turn counting, and /retry; #80622). from agent.context_compressor import ( history_before_user_originated_turn, diff --git a/gateway/session.py b/gateway/session.py index b283bfcce955..a2ea74991910 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -4065,15 +4065,11 @@ def rewind_session( ) try: + expected_active_ids = self._db.get_active_message_ids(session_id) durable = self._db.get_messages_as_conversation( session_id, include_row_ids=True, ) - expected_active_ids = [ - int(message["_row_id"]) - for message in durable - if isinstance(message.get("_row_id"), int) - ] user_indices = [ index for index, message in enumerate(durable) @@ -4089,13 +4085,15 @@ def rewind_session( handoff, target_view = split_user_originated_turn(target) if target_view is None: return None - if require_retryable_composite: - if handoff is None: - return None - target_text = retryable_user_text(target_view.get("content")) + if require_retryable_composite and handoff is None: + return None except Exception as e: logger.debug("rewind_session: failed to resolve canonical target: %s", e) return None + if require_retryable_composite: + # Keep replay-policy failures distinct from persistence errors + # so /retry can explain why the selected carrier is unsafe. + target_text = retryable_user_text(target_view.get("content")) try: result = self._db.rewind_to_message( session_id, diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index f6797240349b..c5dbf582e133 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2681,11 +2681,14 @@ async def _handle_retry_command(self, event: MessageEvent) -> str: # archive that row/tail and insert its pure scaffold atomically. # Plain turns keep the existing rewrite path below; #84078 owns # its separate archive_dropped/prefix-CAS semantics. - rewind_result = await self.async_session_store.rewind_session( - session_entry.session_id, - 1, - require_retryable_composite=True, - ) + try: + rewind_result = await self.async_session_store.rewind_session( + session_entry.session_id, + 1, + require_retryable_composite=True, + ) + except ValueError as exc: + return f"Cannot retry that message safely: {exc}" if rewind_result is None: return "Retry failed; transcript was not changed." # The store reselects and validates the latest carrier on the same diff --git a/hermes_cli/web_routers/sessions.py b/hermes_cli/web_routers/sessions.py index 2e88a3ea8e15..933db5ed5db2 100644 --- a/hermes_cli/web_routers/sessions.py +++ b/hermes_cli/web_routers/sessions.py @@ -642,23 +642,24 @@ def _read(): if result is None: raise HTTPException(status_code=404, detail="Session not found") sid, _limit, messages = result - from agent.context_compressor import split_user_originated_turn + from agent.compaction_display import project_compaction_message_for_display + from agent.context_compressor import is_compaction_summary_message projected_messages = [] for message in messages: - handoff, live_view = split_user_originated_turn(message) - if handoff is None: + if not is_compaction_summary_message(message): projected_messages.append(message) continue + display_view = project_compaction_message_for_display(message) projected = message.copy() - if live_view is None: + if display_view is None: if not projected.get("display_kind"): projected["display_kind"] = "hidden" else: # Keep the physical content for inspection/export compatibility; # Desktop consumes this display-only projection. A legacy hidden # wrapper must not hide a successfully recovered live ask. - projected["display_content"] = live_view.get("content") + projected["display_content"] = display_view.get("content") projected.pop("display_kind", None) projected_messages.append(projected) return { diff --git a/hermes_state.py b/hermes_state.py index 60c3984b29a7..b7c0304657a5 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -11745,6 +11745,22 @@ def _is_duplicate_replayed_user_message( # Rewind (soft-delete) — see /rewind slash command + issue #21910 # ========================================================================= + def get_active_message_ids(self, session_id: str) -> List[int]: + """Return the ordered physical ids pinned by rewind CAS checks. + + Conversation projections intentionally omit legacy background-review + harness rows. Destructive rewinds must nevertheless pin every active + physical row so the caller snapshot matches the transaction-local + comparison in :meth:`rewind_to_message`. + """ + with self._read_ctx() as conn: + rows = conn.execute( + "SELECT id FROM messages " + "WHERE session_id = ? AND active = 1 ORDER BY id", + (session_id,), + ).fetchall() + return [int(row[0]) for row in rows] + @staticmethod def _active_transcript_counts(conn, session_id: str) -> tuple[int, int]: """Return active message/tool-call counts inside the caller's txn.""" diff --git a/tests/gateway/test_retry_replacement.py b/tests/gateway/test_retry_replacement.py index b3f2e8d96f01..4dec6eba043d 100644 --- a/tests/gateway/test_retry_replacement.py +++ b/tests/gateway/test_retry_replacement.py @@ -110,6 +110,32 @@ def test_rewind_session_keeps_pending_recovery_state_when_lease_rejects( assert session_id not in store._transcript_append_failures +def test_rewind_session_surfaces_unretryable_media_before_mutation( + tmp_path, monkeypatch +): + import hermes_state + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", tmp_path / "state.db") + store = SessionStore(sessions_dir=tmp_path, config=GatewayConfig()) + session_id = "rewind-composite-media" + store._db.create_session(session_id=session_id, source="test") + store._db.append_message( + session_id, + "user", + [ + {"type": "text", "text": _composite_carrier()["content"]}, + {"type": "image_url", "image_url": {"url": "image"}}, + ], + ) + store._db.append_message(session_id, "assistant", "old answer") + before = store._db.get_messages(session_id, include_inactive=True) + + with pytest.raises(ValueError, match="media or unknown content"): + store.rewind_session(session_id, require_retryable_composite=True) + + assert store._db.get_messages(session_id, include_inactive=True) == before + + @pytest.mark.parametrize("operation", ["rewrite", "rewind"]) def test_transcript_mutation_serializes_pending_queue_drain( operation, tmp_path, monkeypatch @@ -383,6 +409,39 @@ async def test_gateway_retry_rejects_media_before_redispatch_or_token_reset(): facade.rewrite_transcript.assert_not_awaited() +@pytest.mark.asyncio +async def test_gateway_retry_preserves_composite_media_diagnostic_from_store(): + gw = GatewayRunner.__new__(GatewayRunner) + backing_store = MagicMock() + gw.session_store = backing_store + session_entry = SimpleNamespace(session_id="sid", last_prompt_tokens=123) + facade = SimpleNamespace( + _store=backing_store, + get_or_create_session=AsyncMock(return_value=session_entry), + load_transcript=AsyncMock( + return_value=[ + _composite_carrier(), + {"role": "assistant", "content": "old answer"}, + ] + ), + rewind_session=AsyncMock( + side_effect=ValueError("retry does not support media content") + ), + ) + gw._async_session_store = facade + gw._handle_message = AsyncMock() + + result = await gw._handle_retry_command( + MessageEvent(text="/retry", message_type=MessageType.TEXT, source=MagicMock()) + ) + + assert result == ( + "Cannot retry that message safely: retry does not support media content" + ) + assert session_entry.last_prompt_tokens == 123 + gw._handle_message.assert_not_awaited() + + @pytest.mark.asyncio async def test_gateway_retry_stops_when_transcript_rewrite_fails(): gw = GatewayRunner.__new__(GatewayRunner) diff --git a/tests/gateway/test_undo_rewind_session.py b/tests/gateway/test_undo_rewind_session.py index 10df30a4319a..3f2f518c92f6 100644 --- a/tests/gateway/test_undo_rewind_session.py +++ b/tests/gateway/test_undo_rewind_session.py @@ -54,6 +54,35 @@ def test_rewind_n_turns(store): assert len(store.load_transcript(sid)) == 2 # q1,a1 +def test_rewind_pins_raw_active_ids_when_projection_hides_review_harness(store): + sid = _seed(store, "gw-review-harness", turns=2) + store._db.append_message( + sid, + "user", + "Review the conversation above and update the skill library safely", + ) + store._db.append_message(sid, "assistant", "curator-only reply") + + # Legacy background-review rows are intentionally absent from replay, but + # they remain physical active rows that the rewind CAS must pin. + assert [message["content"] for message in store.load_transcript(sid)] == [ + "q1", + "a1", + "q2", + "a2", + ] + + result = store.rewind_session(sid) + + assert result is not None + assert result["target_text"] == "q2" + assert result["rewound_count"] == 4 + assert [message["content"] for message in store.load_transcript(sid)] == [ + "q1", + "a1", + ] + + def test_rewind_fails_closed_when_transcript_changes_after_snapshot( store, monkeypatch ): @@ -83,3 +112,35 @@ def _append_then_rewind(*args, **kwargs): ] sibling.close() + +def test_rewind_fails_closed_when_new_turn_lands_after_id_snapshot( + store, monkeypatch +): + sid = _seed(store, "gw-snapshot-order", turns=2) + sibling = SessionDB(db_path=store._db.db_path) + original_load = store._db.get_messages_as_conversation + + def _load_then_append(*args, **kwargs): + snapshot = original_load(*args, **kwargs) + sibling.append_message(sid, "user", "q3-from-other-process") + sibling.append_message(sid, "assistant", "a3-from-other-process") + return snapshot + + monkeypatch.setattr(store._db, "get_messages_as_conversation", _load_then_append) + + assert store.rewind_session(sid) is None + + rows = store._db._conn.execute( + "SELECT content, active FROM messages " + "WHERE session_id = ? ORDER BY id", + (sid,), + ).fetchall() + assert [tuple(row) for row in rows] == [ + ("q1", 1), + ("a1", 1), + ("q2", 1), + ("a2", 1), + ("q3-from-other-process", 1), + ("a3-from-other-process", 1), + ] + sibling.close() diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index b9820c127caa..0cfb7e927d7f 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2048,6 +2048,8 @@ def test_get_session_messages_projects_and_dedupes_composite_carrier(self): from agent.context_compressor import ( HISTORICAL_TASK_HEADING, SUMMARY_PREFIX, + _MERGED_PRIOR_CONTEXT_HEADER, + _MERGED_SUMMARY_DELIMITER, _SUMMARY_END_MARKER, ) from hermes_state import SessionDB @@ -2057,6 +2059,11 @@ def test_get_session_messages_projects_and_dedupes_composite_carrier(self): f"{_SUMMARY_END_MARKER}" ) carrier = f"{handoff}\n\nREAL ASK" + assistant_carrier = ( + f"{_MERGED_PRIOR_CONTEXT_HEADER}\n" + "real completed answer\n\n" + f"{_MERGED_SUMMARY_DELIMITER}\n\n{handoff}" + ) db = SessionDB() try: db.create_session(session_id="compacted-carrier-display", source="desktop") @@ -2076,6 +2083,12 @@ def test_get_session_messages_projects_and_dedupes_composite_carrier(self): handoff, timestamp=124.0, ) + db.append_message( + "compacted-carrier-display", + "assistant", + assistant_carrier, + timestamp=125.0, + ) active_id = db.get_messages("compacted-carrier-display")[0]["id"] finally: db.close() @@ -2086,13 +2099,15 @@ def test_get_session_messages_projects_and_dedupes_composite_carrier(self): ) assert resp.status_code == 200 messages = resp.json()["messages"] - assert len(messages) == 2 + assert len(messages) == 3 assert messages[0]["id"] == active_id assert messages[0]["content"] == carrier assert messages[0]["display_content"] == "REAL ASK" assert not messages[0].get("display_kind") assert messages[1]["content"] == handoff assert messages[1]["display_kind"] == "hidden" + assert messages[2]["content"] == assistant_carrier + assert messages[2]["display_content"] == "real completed answer" def test_get_session_messages_latest_page_with_compacted_rows(self): """The desktop's real read path (getLatestSessionMessages: limit + diff --git a/tests/tui_gateway/test_composite_carrier_rewind.py b/tests/tui_gateway/test_composite_carrier_rewind.py index bef256746d21..394d992b696b 100644 --- a/tests/tui_gateway/test_composite_carrier_rewind.py +++ b/tests/tui_gateway/test_composite_carrier_rewind.py @@ -403,6 +403,36 @@ def test_retry_preserves_literal_media_like_text(carrier_session): _assert_scaffold_preserved(db, session_key, session) +def test_retry_rejects_durable_media_before_rewind_when_warm_view_is_text( + carrier_session, +): + db, install = carrier_session + carrier = _composite_carrier() + handoff = carrier["content"].rsplit("\n\nREAL ASK", 1)[0] + durable_carrier = carrier.copy() + durable_carrier["content"] = [ + {"type": "text", "text": handoff}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,x"}}, + ] + sid, session_key, session = install( + [durable_carrier, {"role": "assistant", "content": "failed"}] + ) + # The warm projection can be a degraded text-only view that compares equal + # to the durable media payload. Durable retryability must still be checked + # before the physical carrier and tail are archived. + warm_carrier = carrier.copy() + warm_carrier["content"] = handoff + "\n\n[screenshot]" + session["history"][0] = warm_carrier + session["agent"]._session_messages = list(session["history"]) + before_history = [message.copy() for message in session["history"]] + + response = _dispatch(sid, "retry") + + assert response["error"]["code"] == 4018 + assert session["history"] == before_history + assert len(db.get_messages_as_conversation(session_key)) == 2 + + def test_retry_rejects_pending_attachments_before_mutating_history(carrier_session): db, install = carrier_session sid, session_key, session = install( diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 82637f4dc1cc..229f47430024 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -2694,7 +2694,7 @@ def _(rid, params: dict) -> dict: # (async_delegation_complete, model_switch, …) or compaction # handoffs as the undo target — so session.undo removed # bookkeeping instead of the last exchange (#80622). - # Match list_recent_user_messages / CLI turn counting. + # Match user_originated_turn_view / CLI turn counting. from agent.context_compressor import user_originated_turn_view user_indices = [ diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 449b336af02f..f153bc98a020 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -744,8 +744,14 @@ def _(rid, params: dict) -> dict: return _err(rid, 4018, str(exc)) try: _active, durable_live_view, _rewound_count = ( - _rewind_active_session_history(session, len(user_indices) - 1) + _rewind_active_session_history( + session, + len(user_indices) - 1, + require_retryable=True, + ) ) + except ValueError as exc: + return _err(rid, 4018, str(exc)) except Exception as exc: return _err(rid, 5008, f"retry: failed to persist history: {exc}") content = retryable_user_text(durable_live_view.get("content")) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 38a53b2a8af7..f767e60e360a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3155,7 +3155,10 @@ def _session_db(session: dict): def _rewind_active_session_history( - session: dict, user_ordinal: int + session: dict, + user_ordinal: int, + *, + require_retryable: bool = False, ) -> tuple[list[dict], dict, int]: """Rewind one canonical user turn while retaining carrier scaffolding. @@ -3167,6 +3170,7 @@ def _rewind_active_session_history( """ from agent.context_compressor import ( history_before_user_originated_turn, + retryable_user_text, split_user_originated_turn, user_originated_turn_view, ) @@ -3213,6 +3217,7 @@ def _comparison_content(message: dict) -> Any: with _session_db(session) as db: if db is None: raise RuntimeError("session database is unavailable") + expected_active_ids = db.get_active_message_ids(session_key) durable = db.get_messages_as_conversation( session_key, include_row_ids=True, @@ -3238,11 +3243,8 @@ def _comparison_content(message: dict) -> Any: target_row_id = durable_target.get("_row_id") if not isinstance(target_row_id, int): raise RuntimeError("rewind target has no durable row identity") - expected_active_ids = [ - int(message["_row_id"]) - for message in durable - if isinstance(message.get("_row_id"), int) - ] + if require_retryable: + retryable_user_text(durable_live_view.get("content")) scaffold, _ = split_user_originated_turn(durable_target) result = db.rewind_to_message( session_key, @@ -3278,6 +3280,8 @@ def _comparison_content(message: dict) -> Any: live_view = durable_live_view rewound_count = int(result.get("rewound_count", 0)) persisted = True + elif require_retryable: + retryable_user_text(live_view.get("content")) installed = [message.copy() for message in installed] session["history"] = installed @@ -7854,20 +7858,6 @@ def _history_to_messages(history: list[dict]) -> list[dict]: role = m.get("role") if role not in {"user", "assistant", "tool", "system"}: continue - if role == "user": - from agent.context_compressor import ( - is_compaction_summary_message, - user_originated_turn_view, - ) - - if is_compaction_summary_message(m): - carrier_row_id = m.get("_row_id") - live_view = user_originated_turn_view(m) - if live_view is None: - continue - if carrier_row_id is not None: - live_view["_row_id"] = carrier_row_id - m = live_view # An explicit display_kind="hidden" row is model-facing scaffolding # (compaction references, interrupted-turn checkpoints). The string # sniff below only catches the "[System:" convention; honor the From 679e9cd2943dddf59f50486c265a7ab110ad0f19 Mon Sep 17 00:00:00 2001 From: emozilla Date: Sat, 22 Aug 2026 13:38:56 -0400 Subject: [PATCH 017/666] fix(windows): stage hermes launchers in the managed binary dir, not the git checkout The installer staged the hermes/hermes-acp launcher copies at hermes-agent\bin -- inside the git working tree -- and put that dir on the user PATH (#84452). The update command's pre-pull autostash (git stash push --include-untracked) swept those untracked, unignored copies off disk, and once the desktop updater stopped re-applying stashes (--keep-stash, 5dd221d442) nothing restored them: `hermes` stopped resolving in every new terminal on every desktop-updated install. Move the canonical launcher home to the managed binary dir (%LOCALAPPDATA%\hermes\bin, next to the managed uv) -- outside the checkout, where no git operation can ever touch it. The dir is per-machine and shared by every profile, so all anchoring uses get_default_hermes_root(), never HERMES_HOME (which points inside profiles\ under `hermes -p`). The copy design also had a second latent break: managed-uv rebuilds create relocatable venvs, and a relocatable venv's exe trampoline resolves relative to its own location -- a copy outside venv\Scripts dies with 'uv trampoline failed to canonicalize script path'. Launcher form now depends on the venv (lockstep in install.ps1 and _install_repair.py): exe copy for normal venvs, a .cmd delegator invoking the in-venv exe by absolute path for relocatable ones. Either form counts as present, so pre-rebuild exe copies are left alone. Delivery to the existing fleet, per cohort: - already-broken installs cannot run the CLI, so an import-time heal in hermes_cli.main (ensure_windows_bin_launchers) re-stages missing launchers when the desktop app spawns its backend -- the one channel that still reaches them. Gates fail toward inaction: canonical dir only for the managed clone, legacy hermes-agent\bin only while the user PATH still resolves through it (some pre-managed-uv installs have no hermes\bin PATH entry; the legacy re-stage is what fixes those). Staging-name + os.replace keeps concurrent process starts from tearing a launcher; the helper never raises. - healthy old-layout installs migrate in the update tail (migrate_windows_bin_path): stage canonical launchers, verify them BEFORE touching the registry, prepend hermes\bin to the user PATH, strip the legacy entries (hermes-agent\bin and venv\Scripts, #83797), preserving REG_EXPAND_SZ and raw %VARS%. The legacy dir's files stay on purpose -- configs holding absolute launcher paths keep working; only the sweepable PATH resolution route goes. - fresh installs get the new layout from install.ps1 directly. /bin/ is gitignored so the one update that DELIVERS this fix cannot sweep pre-migration launchers a final time under the old rules; the gitignore line, the legacy re-stage branch, and the update-tail call are transition machinery with a named expiry once the fleet has migrated. Also rewrites _ensure_acp_launcher's stale Windows paragraph to match (raw docstring fixes its invalid \S escape) and updates the Windows native docs to the new layout, with a docs<->installer parity test. --- .gitignore | 1 + hermes_cli/_install_repair.py | 335 +++++++++++++++++ hermes_cli/main.py | 25 ++ hermes_cli/update_cmd.py | 30 +- scripts/install.ps1 | 65 +++- .../test_ensure_windows_bin_launchers.py | 342 ++++++++++++++++++ tests/hermes_cli/test_windows_native_docs.py | 17 +- website/docs/user-guide/windows-native.md | 12 +- 8 files changed, 791 insertions(+), 36 deletions(-) create mode 100644 tests/hermes_cli/test_ensure_windows_bin_launchers.py diff --git a/.gitignore b/.gitignore index ba347a4a7f80..fb0f25709548 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /venv/ /venv.old/ /venv.stale.runtime-*/ +/bin/ /.hermes-runtime/ /_pycache/ *.pyc* diff --git a/hermes_cli/_install_repair.py b/hermes_cli/_install_repair.py index 76e78adb9858..05da09b42f9b 100644 --- a/hermes_cli/_install_repair.py +++ b/hermes_cli/_install_repair.py @@ -117,6 +117,341 @@ def _venv_scripts_dir(root: Path) -> Path | None: return scripts if scripts.is_dir() else None +#: Launcher command names install.ps1's Set-PathVariable exposes from the +#: managed binary dir (the default Hermes root's ``bin``, next to uv.exe) +#: on the user PATH. Keep in lockstep with the launcher list in +#: scripts/install.ps1. +_WINDOWS_BIN_LAUNCHERS = ("hermes", "hermes-acp") + + +def _venv_is_relocatable(venv_dir: Path) -> bool: + """True when the venv's pyvenv.cfg declares ``relocatable = true``. + + uv writes the flag; ``hermes_cli.managed_uv`` builds its replacement + venvs with ``--relocatable`` (they are constructed aside and swapped + into place). A relocatable venv's console-script trampolines embed a + RELATIVE interpreter reference, so a COPY of one placed outside + ``venv\\Scripts`` fails at run time with ``uv trampoline failed to + canonicalize script path``. Non-relocatable venvs (fresh installs) + embed the absolute interpreter path and their trampolines survive + copying. This flag decides which launcher form a PATH dir gets. + """ + try: + cfg = (Path(venv_dir) / "pyvenv.cfg").read_text( + encoding="utf-8", errors="replace" + ) + except OSError: + return False + for line in cfg.splitlines(): + key, _, value = line.partition("=") + if key.strip().lower() == "relocatable" and value.strip().lower() == "true": + return True + return False + + +def _normalize_windows_path(value) -> str: + """Windows path equality key: backslashes, no trailing separator, lowered. + + Lowercase via ``.lower()`` (what ``ntpath.normcase`` does) rather than + ``os.path.normcase`` — that is an identity function on POSIX, and this + comparison must behave Windows-correct even when tests exercise the + Windows branch from another host (same rationale as + ``venv_bin_dir(windows=...)``). + """ + return str(value).replace("/", "\\").rstrip("\\").lower() + + +def _windows_user_path_entries() -> list[str]: + """User PATH entries from the registry — the value install.ps1 writes. + + Falls back to the process PATH when the registry is unreadable. Only + called on Windows. + """ + try: + import winreg + + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key: + raw, _kind = winreg.QueryValueEx(key, "Path") + value = os.path.expandvars(str(raw)) + except (OSError, ImportError): + value = os.environ.get("PATH", "") + return [entry for entry in value.split(";") if entry.strip()] + + +def ensure_windows_bin_launchers( + root, + *, + windows: bool | None = None, + user_path_entries: list[str] | None = None, +) -> list[str]: + """Re-stage the Windows ``hermes`` launchers when they vanish. + + On Windows, ``hermes`` resolves through launchers derived from the venv + console scripts — never ``venv\\Scripts`` itself on PATH, which would + shadow the user's ``python`` (#83797). The canonical launcher home is + the managed binary dir — the default Hermes root's ``bin`` + (``%LOCALAPPDATA%\\hermes\\bin``, next to the managed uv) — which lives + OUTSIDE the git checkout so no git operation can ever touch it. It is + a per-machine dir shared by every profile: ``get_hermes_home()`` would + point inside ``profiles\\`` under ``hermes -p``, so the anchor + here is :func:`hermes_constants.get_default_hermes_root`. + + Earlier installer versions staged them at ``\\bin`` instead — + inside the git working tree — where ``hermes update``'s pre-update + autostash (``git stash push --include-untracked``) swept them off disk; + once the desktop updater stopped re-applying stashes (``--keep-stash``) + nothing restored them and ``hermes`` stopped resolving in every new + terminal. That legacy location is re-staged too, during the transition, + for installs whose user PATH still resolves through it. + + The launcher FORM depends on the venv (see :func:`_venv_is_relocatable`): + a normal venv's exe trampoline embeds an absolute interpreter path and + survives copying, so it is copied as ``.exe``; a relocatable + venv's trampoline resolves relative to its own location and a copy + dies with ``uv trampoline failed to canonicalize script path``, so a + ``.cmd`` delegator invoking the in-venv exe by absolute path is + written instead. A name counts as present when EITHER form exists — + exe copies staged before a venv rebuild keep working (they embed the + swapped-in-place venv's absolute path) and are left alone. + + Two targets, two gates, both failing toward inaction: + + - canonical managed binary dir: only when *root* is the managed clone + (``root.parent == get_default_hermes_root()``), so source checkouts + elsewhere never gain launchers; + - legacy ``\\bin``: only when that dir is on the user PATH + (registry value, process PATH as fallback), i.e. the install opted + into the old layout and still resolves through it. + + Writes go through a staging name + ``os.replace`` so concurrent process + starts cannot tear a launcher. Never raises; returns the restored paths. + + *windows* and *user_path_entries* are injectable for tests, same pattern + as ``hermes_constants.venv_bin_dir``. + """ + if windows is None: + windows = _is_windows() + if not windows: + return [] + + root = Path(root) + + # Per-machine anchor: the DEFAULT Hermes root, not get_hermes_home() — + # under ``hermes -p `` that returns ``profiles\\``, which + # would fail the managed-clone gate below and silently skip the heal + # for profile users. The launcher dir serves the whole machine. + from hermes_constants import get_default_hermes_root + + try: + home = Path(get_default_hermes_root()) + except Exception: + return [] + + def _launcher_present(target: Path, name: str) -> bool: + return (target / f"{name}.exe").exists() or (target / f"{name}.cmd").exists() + + targets: list[Path] = [] + + # Canonical target — gate on the managed-clone shape. This runs at + # every hermes_cli.main process start (right after the profile + # override), so the healthy path must stay at a couple of stat calls. + if _normalize_windows_path(root.parent) == _normalize_windows_path(home): + canonical = home / "bin" + if any(not _launcher_present(canonical, name) for name in _WINDOWS_BIN_LAUNCHERS): + targets.append(canonical) + + # Legacy transition target — the pre-migration in-checkout dir. Only + # re-staged while the user PATH still points at it (consent), compared + # as normalized literal strings: the installer wrote the long literal + # path, and realpath'ing arbitrary PATH entries could hang on dead + # network shares. An entry stored some other way (8.3 short path, + # subst drive) misses the re-stage, which fails safe: no-op. + legacy = root / "bin" + if any(not _launcher_present(legacy, name) for name in _WINDOWS_BIN_LAUNCHERS): + if user_path_entries is None: + user_path_entries = _windows_user_path_entries() + configured = {_normalize_windows_path(entry) for entry in user_path_entries} + if _normalize_windows_path(legacy) in configured: + targets.append(legacy) + + if not targets: + return [] + + from hermes_constants import project_venv_dir, venv_bin_dir + + venv_dir = project_venv_dir(root) + if venv_dir is None: + return [] + scripts_dir = venv_bin_dir(venv_dir, windows=windows) + sources = [ + (name, scripts_dir / f"{name}.exe") + for name in _WINDOWS_BIN_LAUNCHERS + if (scripts_dir / f"{name}.exe").is_file() + ] + if not sources: + return [] + relocatable = _venv_is_relocatable(venv_dir) + + import shutil + + restored: list[str] = [] + for target in targets: + try: + target.mkdir(parents=True, exist_ok=True) + except OSError: + continue + for name, source in sources: + if _launcher_present(target, name): + continue + final = target / (f"{name}.cmd" if relocatable else f"{name}.exe") + staging = target / f"{final.name}.heal.{os.getpid()}" + try: + if relocatable: + staging.write_text( + "@echo off\r\n" f'"{source}" %*\r\n', encoding="ascii" + ) + else: + shutil.copy2(source, staging) + os.replace(staging, final) + restored.append(str(final)) + except OSError: + with contextlib.suppress(OSError): + staging.unlink() + if restored: + # Guarded like everything else in this never-raises helper: a + # closed/broken stderr must not turn a successful heal into a crash. + with contextlib.suppress(OSError, ValueError): + print( + " ✓ Restored hermes launcher(s): " + ", ".join(restored), + file=sys.stderr, + ) + return restored + + +def _read_user_path_raw() -> tuple[list[str], int]: + """Raw (unexpanded) user PATH entries + registry value type. + + Raw so a rewrite preserves ``%VARS%`` exactly as the user stored them + (same discipline as ``hermes_cli.uninstall``). Only called on Windows. + """ + import winreg + + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key: + try: + raw, kind = winreg.QueryValueEx(key, "Path") + except FileNotFoundError: + return [], winreg.REG_EXPAND_SZ + return [entry for entry in str(raw).split(";") if entry], int(kind) + + +def _write_user_path_raw(entries: list[str], kind: int) -> None: + """Write the user PATH back, preserving the registry value type.""" + import winreg + + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE + ) as key: + winreg.SetValueEx(key, "Path", 0, kind, ";".join(entries)) + + +def migrate_windows_bin_path( + root, + *, + windows: bool | None = None, + read_user_path=None, + write_user_path=None, +) -> bool: + """One-time PATH migration to the ``HERMES_HOME\\bin`` launcher layout. + + Runs from the ``hermes update`` tail (and mirrors what install.ps1's + Set-PathVariable does on fresh installs/repairs, which never reach + existing installs — updates don't run install.ps1): + + 1. stage the launcher copies into the managed binary dir (via + :func:`ensure_windows_bin_launchers`); + 2. verify both launchers are present there — otherwise STOP, leaving + the user PATH untouched (never strip a working entry before its + replacement is proven); + 3. ensure the managed binary dir is on the user PATH (prepend); + 4. strip the legacy entries: ``\\bin`` (in-checkout launcher dir + the update autostash could sweep) and ``\\venv\\Scripts`` + (shadowed the user's ``python``, #83797). + + The legacy ``\\bin`` FILES are deliberately left in place: editor + and ACP configs that captured absolute launcher paths keep working + (the launchers run fine from there — only PATH resolution through a + dir git could sweep was the bug), and the dir is git-ignored so it + cannot dirty the tree. + + Registry writes preserve the stored value type and raw ``%VARS%``. + Never raises; returns True when the canonical layout is in place. + + *read_user_path*/*write_user_path* are injectable for tests. + """ + if windows is None: + windows = _is_windows() + if not windows: + return False + + root = Path(root) + + # Same per-machine anchor as ensure_windows_bin_launchers (see there). + from hermes_constants import get_default_hermes_root + + try: + home = Path(get_default_hermes_root()) + except Exception: + return False + if _normalize_windows_path(root.parent) != _normalize_windows_path(home): + return False # not the managed clone — nothing to migrate + + ensure_windows_bin_launchers(root, windows=windows, user_path_entries=[]) + + home_bin = home / "bin" + if any( + not ((home_bin / f"{name}.exe").is_file() or (home_bin / f"{name}.cmd").is_file()) + for name in _WINDOWS_BIN_LAUNCHERS + ): + return False # staging incomplete — leave the PATH alone + + if read_user_path is None: + read_user_path = _read_user_path_raw + if write_user_path is None: + write_user_path = _write_user_path_raw + + try: + entries, kind = read_user_path() + except (OSError, ImportError): + return False + + legacy_keys = { + _normalize_windows_path(root / "bin"), + _normalize_windows_path(root / "venv" / "Scripts"), + } + home_bin_key = _normalize_windows_path(home_bin) + + def _entry_key(entry: str) -> str: + return _normalize_windows_path(os.path.expandvars(entry)) + + kept = [e for e in entries if _entry_key(e) not in legacy_keys] + have_home_bin = any(_entry_key(e) == home_bin_key for e in kept) + if not have_home_bin: + kept = [str(home_bin)] + kept + + if kept != entries: + try: + write_user_path(kept, kind) + except (OSError, ImportError): + return False + with contextlib.suppress(OSError, ValueError): + print( + f" ✓ hermes launchers now resolve from {home_bin} " + "(legacy PATH entries removed)", + file=sys.stderr, + ) + return True + + def _load_console_script_names(root: Path) -> list[str]: """``[project.scripts]`` names from pyproject.toml (tomllib, 3.11+).""" try: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9f5e1ec93421..4582b31e0232 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -693,6 +693,31 @@ def _resolve_sudo_user_profile_env(name: str) -> str | None: _apply_profile_override() +# Windows launcher self-heal — the ``hermes`` command users run is a COPY of +# the venv console script, staged into the managed binary dir (the default +# Hermes root's ``bin``, next to the managed uv) by install.ps1. That dir +# lives OUTSIDE the git checkout precisely because an earlier layout staged +# the copies at ``\bin``, where ``hermes update``'s autostash +# (``git stash push --include-untracked``) swept them off disk; with the +# desktop updater's ``--keep-stash`` nothing restored them and ``hermes`` +# stopped resolving in every new terminal (venv\Scripts itself must stay off +# PATH — it shadows the user's ``python``, #83797). Re-staging at process +# start reaches already-broken installs through the one channel that still +# works there: the desktop app spawning its backend via +# ``python -m hermes_cli.main``. Costs a few stat calls when healthy; gates +# fail toward inaction so source checkouts are untouched. Sits AFTER the +# profile override on purpose — no hermes module may be imported before +# profiles resolve. The launcher dir itself is per-machine (the helper +# anchors on the DEFAULT root, not HERMES_HOME), so profile sessions heal +# the same shared dir. +if sys.platform == "win32": + try: + from hermes_cli import _install_repair as _install_repair_mod + + _install_repair_mod.ensure_windows_bin_launchers(_bootstrap_root) + except Exception: + pass + # Load .env from ~/.hermes/.env first, then project root as dev fallback. # User-managed env files should override stale shell exports on restart. from hermes_cli.config import get_hermes_home diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index f41a37fb67bc..4fd762c70d5e 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -3181,7 +3181,7 @@ def _ensure_fhs_path_guard() -> None: print(" (reload your shell or run 'source ~/.bashrc' to pick it up)") def _ensure_acp_launcher() -> None: - """Self-heal: install a ``hermes-acp`` launcher next to the ``hermes`` one. + r"""Self-heal: install a ``hermes-acp`` launcher next to the ``hermes`` one. Mirrors the launcher block in ``scripts/install.sh`` so existing installs gain the ACP command on ``hermes update`` without a reinstall. ACP hosts @@ -3197,10 +3197,12 @@ def _ensure_acp_launcher() -> None: No-op on Windows (install.ps1 copies ``hermes.exe`` + ``hermes-acp.exe`` into ``$InstallDir\bin`` and puts THAT on the user PATH — never the whole - ``venv\Scripts`` dir, which would shadow the user's ``python`` (#83797) — - so ``hermes-acp.exe`` already resolves) and wherever a ``hermes-acp`` is - already present next to the ``hermes`` command. Unwritable directories - (e.g. ``/usr/local/bin`` as non-root) are skipped silently. Idempotent. + ``venv\Scripts`` dir, which would shadow the user's ``python`` (#83797); + when those copies go missing, ``hermes_cli._install_repair. + ensure_windows_bin_launchers`` re-stages them) and wherever a + ``hermes-acp`` is already present next to the ``hermes`` command. + Unwritable directories (e.g. ``/usr/local/bin`` as non-root) are skipped + silently. Idempotent. """ if _m().sys.platform == "win32": return @@ -6486,6 +6488,24 @@ def _print_items(items, label, key, fallback_key=None): except Exception as e: logger.debug("hermes-acp launcher self-heal failed: %s", e) + # Migrate the Windows hermes launchers to the managed binary dir + # (the default Hermes root's bin, next to the managed uv) and repair + # them if they are missing. Earlier layouts put them inside the git + # checkout (hermes-agent\bin) or put venv\Scripts itself on PATH; the + # in-checkout copies were swept by this command's own pre-update + # autostash (git stash push --include-untracked) and, with + # --keep-stash (the desktop updater), never restored — `hermes` + # stopped resolving in every new terminal. Updates never run + # install.ps1, so this tail call is how existing installs reach the + # new layout. No-op on POSIX and on source checkouts (root is not + # the managed clone under the default Hermes root). + try: + from hermes_cli._install_repair import migrate_windows_bin_path + + migrate_windows_bin_path(_m().PROJECT_ROOT) + except Exception as e: + logger.debug("Windows bin launcher migration failed: %s", e) + # Refresh the cua-driver binary used by the Computer Use toolset. # The upstream installer is gated on supported platforms and on the # binary already being on PATH, so this is a no-op for users who diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 38c2012a9724..48dd10e8adcd 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -2987,31 +2987,60 @@ function Set-PathVariable { # venv\Scripts directory. venv\Scripts contains python.exe / # pythonw.exe / pip.exe, and putting it on the user PATH silently # hijacks the `python` command in every terminal on the machine - # (#83797): unrelated projects start resolving python to Hermes' - # runtime interpreter. A dedicated bin dir with copies of the - # launcher exes keeps `hermes` globally available without - # shadowing anything. (Launcher exes embed the venv interpreter - # path, so they work from any location and survive updates.) - $hermesBin = "$InstallDir\bin" + # (#83797). And never a directory inside the git checkout: + # `hermes update`'s autostash (git stash push --include-untracked) + # deletes untracked files from the working tree, which silently + # removed the launchers an earlier installer staged under + # hermes-agent\bin. $HermesHome\bin is the managed binary dir + # (shared with the managed uv), outside the checkout, where no git + # operation can ever touch it. (Launcher exes embed the venv + # interpreter path, so they work from any location and survive + # updates.) + $hermesBin = "$HermesHome\bin" New-Item -ItemType Directory -Force -Path $hermesBin | Out-Null - foreach ($launcher in @("hermes.exe", "hermes-acp.exe")) { - $src = "$InstallDir\venv\Scripts\$launcher" - if (Test-Path $src) { - Copy-Item -Force $src "$hermesBin\$launcher" + # Launcher form depends on the venv (keep in lockstep with + # hermes_cli/_install_repair.py): a normal venv's exe trampoline + # embeds an absolute interpreter path and survives copying; a + # relocatable venv's trampoline (managed_uv rebuilds use + # --relocatable) resolves relative to its own location, and a copy + # dies with 'uv trampoline failed to canonicalize script path' -- + # those get a .cmd delegator invoking the in-venv exe instead. + $pyvenvCfg = "$InstallDir\venv\pyvenv.cfg" + $venvRelocatable = $false + if (Test-Path $pyvenvCfg) { + $venvRelocatable = [bool](Select-String -Path $pyvenvCfg -Pattern '^\s*relocatable\s*=\s*true\s*$' -Quiet) + } + foreach ($launcher in @("hermes", "hermes-acp")) { + $src = "$InstallDir\venv\Scripts\$launcher.exe" + if (-not (Test-Path $src)) { continue } + if ($venvRelocatable) { + Remove-Item "$hermesBin\$launcher.exe" -Force -ErrorAction SilentlyContinue + Set-Content -Path "$hermesBin\$launcher.cmd" -Value "@echo off`r`n`"$src`" %*" -Encoding Ascii + } else { + Remove-Item "$hermesBin\$launcher.cmd" -Force -ErrorAction SilentlyContinue + Copy-Item -Force $src "$hermesBin\$launcher.exe" } } } $currentPath = [Environment]::GetEnvironmentVariable("Path", "User") - # Migrate installs that got venv\Scripts onto PATH from earlier - # installer versions -- remove it so the python shadowing stops. - $legacyBin = "$InstallDir\venv\Scripts" - if ((-not $NoVenv) -and $currentPath -like "*$legacyBin*") { - $cleaned = ($currentPath -split ';' | Where-Object { $_ -and $_ -ne $legacyBin }) -join ';' - [Environment]::SetEnvironmentVariable("Path", $cleaned, "User") - $currentPath = $cleaned - Write-Info "Removed legacy venv\Scripts from user PATH (kept hermes via $hermesBin)" + # Migrate older layouts off the user PATH: + # venv\Scripts -- shadowed the user's python (#83797) + # hermes-agent\bin -- lived inside the git checkout, where the update + # autostash could sweep the launchers off disk + # The hermes-agent\bin FILES are left in place on purpose: editor/ACP + # configs that captured absolute launcher paths keep working, and the + # dir is git-ignored so it cannot dirty the checkout. + if (-not $NoVenv) { + $legacyEntries = @("$InstallDir\venv\Scripts", "$InstallDir\bin") + $items = @(($currentPath -split ';') | Where-Object { $_ }) + $cleaned = @($items | Where-Object { $legacyEntries -notcontains $_ }) + if ($cleaned.Count -ne $items.Count) { + $currentPath = $cleaned -join ";" + [Environment]::SetEnvironmentVariable("Path", $currentPath, "User") + Write-Info "Removed legacy launcher entries from user PATH (kept hermes via $hermesBin)" + } } if ($currentPath -notlike "*$hermesBin*") { diff --git a/tests/hermes_cli/test_ensure_windows_bin_launchers.py b/tests/hermes_cli/test_ensure_windows_bin_launchers.py new file mode 100644 index 000000000000..e2001cbf7d20 --- /dev/null +++ b/tests/hermes_cli/test_ensure_windows_bin_launchers.py @@ -0,0 +1,342 @@ +"""``hermes`` must survive git operations on the checkout (launcher layout). + +The Windows ``hermes`` command is a launcher derived from the venv console +script. Its canonical home is the managed binary dir ``HERMES_HOME\\bin`` — +OUTSIDE the git checkout — because the earlier in-checkout home +(``hermes-agent\\bin``) was swept by ``hermes update``'s autostash +(``git stash push --include-untracked``) and, with the desktop updater's +``--keep-stash``, never restored: ``hermes`` stopped resolving in every new +terminal (``venv\\Scripts`` itself must stay off PATH — it shadows the +user's ``python``, #83797). + +``ensure_windows_bin_launchers`` re-stages missing launchers (canonical dir +always for the managed clone; legacy dir only while the user PATH still +points at it), choosing the form by venv kind: exe copy for normal venvs, +``.cmd`` delegator for relocatable venvs whose exe trampolines die when +copied out of ``venv\\Scripts``. ``migrate_windows_bin_path`` moves an +existing install's PATH to the canonical layout from the ``hermes update`` +tail. Platform verdict, PATH values, and registry I/O are injected +parameters (same pattern as ``hermes_constants.venv_bin_dir``), so these +tests are host-independent input→output checks, not host fakes. +""" + +from pathlib import Path + +import pytest + +from hermes_cli._install_repair import ( + _WINDOWS_BIN_LAUNCHERS, + _normalize_windows_path, + ensure_windows_bin_launchers, + migrate_windows_bin_path, +) + + +def _make_managed(tmp_path, monkeypatch, *, relocatable: bool = False): + """Fake managed layout: HERMES_HOME/hermes-agent/venv/Scripts + launchers.""" + home = tmp_path / "hermes" + root = home / "hermes-agent" + scripts = root / "venv" / "Scripts" + scripts.mkdir(parents=True) + for name in _WINDOWS_BIN_LAUNCHERS: + (scripts / f"{name}.exe").write_bytes(b"MZ console script: " + name.encode()) + cfg = "home = X\nversion_info = 3.11.15\n" + if relocatable: + cfg += "relocatable = true\n" + (root / "venv" / "pyvenv.cfg").write_text(cfg, encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home)) + return home, root + + +@pytest.fixture +def managed_install(tmp_path, monkeypatch): + return _make_managed(tmp_path, monkeypatch) + + +def test_managed_clone_heals_canonical_home_bin(managed_install): + home, root = managed_install + + restored = ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) + + assert len(restored) == len(_WINDOWS_BIN_LAUNCHERS) + for name in _WINDOWS_BIN_LAUNCHERS: + assert (home / "bin" / f"{name}.exe").read_bytes() == ( + root / "venv" / "Scripts" / f"{name}.exe" + ).read_bytes() + + +def test_relocatable_venv_gets_cmd_delegators_not_exe_copies(tmp_path, monkeypatch): + """A copied relocatable-venv trampoline dies ('uv trampoline failed to + canonicalize script path') — the heal must emit .cmd delegators.""" + home, root = _make_managed(tmp_path, monkeypatch, relocatable=True) + + restored = ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) + + assert {Path(p).suffix for p in restored} == {".cmd"} + for name in _WINDOWS_BIN_LAUNCHERS: + body = (home / "bin" / f"{name}.cmd").read_text(encoding="ascii") + # Delegates to the in-venv exe by absolute path, forwarding args. + assert str(root / "venv" / "Scripts" / f"{name}.exe") in body + assert "%*" in body + assert not (home / "bin" / f"{name}.exe").exists() + + +def test_existing_exe_counts_as_present_for_relocatable_venv(tmp_path, monkeypatch): + """Exe copies staged before a venv rebuild embed the swapped-in-place + venv's absolute path and keep working — never replaced with .cmd.""" + home, root = _make_managed(tmp_path, monkeypatch, relocatable=True) + (home / "bin").mkdir() + for name in _WINDOWS_BIN_LAUNCHERS: + (home / "bin" / f"{name}.exe").write_bytes(b"pre-rebuild copy") + + assert ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) == [] + for name in _WINDOWS_BIN_LAUNCHERS: + assert (home / "bin" / f"{name}.exe").read_bytes() == b"pre-rebuild copy" + assert not (home / "bin" / f"{name}.cmd").exists() + + +def test_healthy_canonical_layout_is_a_noop(managed_install): + home, root = managed_install + (home / "bin").mkdir() + for name in _WINDOWS_BIN_LAUNCHERS: + (home / "bin" / f"{name}.exe").write_bytes(b"present") + + assert ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) == [] + + +def test_legacy_bin_restaged_only_while_on_user_path(managed_install): + home, root = managed_install + legacy = root / "bin" + + restored = ensure_windows_bin_launchers( + root, windows=True, user_path_entries=[str(legacy)] + ) + + stems = {Path(p).stem for p in restored} + assert set(_WINDOWS_BIN_LAUNCHERS) <= stems + for name in _WINDOWS_BIN_LAUNCHERS: + assert (legacy / f"{name}.exe").is_file() # legacy consent honored + assert (home / "bin" / f"{name}.exe").is_file() # canonical healed too + + +def test_legacy_bin_not_restaged_without_path_consent(managed_install): + home, root = managed_install + + ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) + + assert not (root / "bin").exists() + + +def test_source_checkout_untouched(tmp_path, monkeypatch): + """A checkout NOT under HERMES_HOME gains nothing anywhere.""" + home = tmp_path / "hermes-home" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + root = tmp_path / "src" / "hermes-agent" + scripts = root / "venv" / "Scripts" + scripts.mkdir(parents=True) + for name in _WINDOWS_BIN_LAUNCHERS: + (scripts / f"{name}.exe").write_bytes(b"MZ") + + assert ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) == [] + assert not (home / "bin").exists() + assert not (root / "bin").exists() + + +def test_noop_on_posix(managed_install): + home, root = managed_install + + assert ensure_windows_bin_launchers(root, windows=False) == [] + assert not (home / "bin").exists() + + +def test_profile_session_still_heals_the_shared_bin(tmp_path, monkeypatch): + """Under ``hermes -p `` HERMES_HOME points inside profiles/; + the launcher dir is per-machine, so the heal must anchor on the default + root and fire anyway — a habitual profile user gets the same repair.""" + home = tmp_path / "hermes" + root = home / "hermes-agent" + scripts = root / "venv" / "Scripts" + scripts.mkdir(parents=True) + for name in _WINDOWS_BIN_LAUNCHERS: + (scripts / f"{name}.exe").write_bytes(b"MZ") + (root / "venv" / "pyvenv.cfg").write_text("home = X\n", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(home / "profiles" / "work")) + + restored = ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) + + assert len(restored) == len(_WINDOWS_BIN_LAUNCHERS) + for name in _WINDOWS_BIN_LAUNCHERS: + assert (home / "bin" / f"{name}.exe").is_file() + assert not (home / "profiles" / "work" / "bin").exists() + + +def test_noop_when_console_scripts_missing(tmp_path, monkeypatch): + """A venv mid-repair has no console scripts — nothing to copy, no error.""" + home = tmp_path / "hermes" + root = home / "hermes-agent" + (root / "venv" / "Scripts").mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + + assert ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) == [] + + +def test_no_staging_litter_left_behind(managed_install): + home, root = managed_install + + ensure_windows_bin_launchers(root, windows=True, user_path_entries=[]) + + leftovers = [p.name for p in (home / "bin").iterdir() if ".heal." in p.name] + assert leftovers == [] + + +# --------------------------------------------------------------------------- +# migrate_windows_bin_path — the `hermes update` tail migration +# --------------------------------------------------------------------------- + + +def _fake_registry(initial: list[str]): + """In-memory user-PATH store standing in for the HKCU registry value.""" + state = {"entries": list(initial), "kind": 2, "writes": 0} + + def read(): + return list(state["entries"]), state["kind"] + + def write(entries, kind): + state["entries"] = list(entries) + state["kind"] = kind + state["writes"] += 1 + + return state, read, write + + +def test_migration_moves_path_to_home_bin_and_strips_legacy(managed_install): + home, root = managed_install + legacy_bin = str(root / "bin") + legacy_scripts = str(root / "venv" / "Scripts") + state, read, write = _fake_registry( + [legacy_bin, legacy_scripts, r"C:\Windows\system32"] + ) + (root / "bin").mkdir() + (root / "bin" / "hermes.exe").write_bytes(b"legacy copy") + + ok = migrate_windows_bin_path( + root, windows=True, read_user_path=read, write_user_path=write + ) + + assert ok + keys = [_normalize_windows_path(e) for e in state["entries"]] + assert _normalize_windows_path(home / "bin") in keys + assert _normalize_windows_path(legacy_bin) not in keys + assert _normalize_windows_path(legacy_scripts) not in keys + assert _normalize_windows_path(r"C:\Windows\system32") in keys # untouched + for name in _WINDOWS_BIN_LAUNCHERS: + assert (home / "bin" / f"{name}.exe").is_file() + # Legacy FILES stay: editor/ACP configs holding absolute launcher paths + # keep working. Only the PATH entry (the sweepable resolution route) goes. + assert (root / "bin" / "hermes.exe").read_bytes() == b"legacy copy" + + +def test_migration_works_for_relocatable_venv(tmp_path, monkeypatch): + home, root = _make_managed(tmp_path, monkeypatch, relocatable=True) + state, read, write = _fake_registry([str(root / "bin")]) + + ok = migrate_windows_bin_path( + root, windows=True, read_user_path=read, write_user_path=write + ) + + assert ok + for name in _WINDOWS_BIN_LAUNCHERS: + assert (home / "bin" / f"{name}.cmd").is_file() + keys = [_normalize_windows_path(e) for e in state["entries"]] + assert _normalize_windows_path(home / "bin") in keys + + +def test_migration_is_idempotent(managed_install): + home, root = managed_install + state, read, write = _fake_registry([str(home / "bin"), r"C:\Windows\system32"]) + + assert migrate_windows_bin_path( + root, windows=True, read_user_path=read, write_user_path=write + ) + first_entries = list(state["entries"]) + first_writes = state["writes"] + + assert migrate_windows_bin_path( + root, windows=True, read_user_path=read, write_user_path=write + ) + assert state["entries"] == first_entries + assert state["writes"] == first_writes # no redundant registry write + + +def test_migration_never_strips_path_when_staging_fails(tmp_path, monkeypatch): + """No venv sources → launchers can't stage → PATH must stay untouched.""" + home = tmp_path / "hermes" + root = home / "hermes-agent" + (root / "venv" / "Scripts").mkdir(parents=True) # no launcher exes inside + monkeypatch.setenv("HERMES_HOME", str(home)) + legacy_bin = str(root / "bin") + state, read, write = _fake_registry([legacy_bin]) + + ok = migrate_windows_bin_path( + root, windows=True, read_user_path=read, write_user_path=write + ) + + assert not ok + assert state["entries"] == [legacy_bin] # working entry preserved + assert state["writes"] == 0 + + +def test_migration_skips_source_checkouts(tmp_path, monkeypatch): + home = tmp_path / "hermes-home" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + root = tmp_path / "src" / "hermes-agent" + scripts = root / "venv" / "Scripts" + scripts.mkdir(parents=True) + for name in _WINDOWS_BIN_LAUNCHERS: + (scripts / f"{name}.exe").write_bytes(b"MZ") + state, read, write = _fake_registry([r"C:\Windows\system32"]) + + assert not migrate_windows_bin_path( + root, windows=True, read_user_path=read, write_user_path=write + ) + assert state["writes"] == 0 + + +def test_migration_noop_on_posix(managed_install): + home, root = managed_install + + assert not migrate_windows_bin_path(root, windows=False) + + +def test_normalize_windows_path_equivalences(): + assert ( + _normalize_windows_path(r"C:\Users\Me\AppData\Local\hermes\bin") + == _normalize_windows_path("c:/users/me/appdata/local/HERMES/BIN/") + ) + + +def test_repo_gitignores_the_legacy_bin_dir(): + """Transition safety: legacy in-checkout launchers must not be stash-swept. + + Until every install has migrated, pre-migration checkouts still carry + launchers at ``/bin``. ``hermes update`` autostashes with + ``git stash push --include-untracked``; anything untracked and NOT + ignored inside the checkout gets swept off disk. Exercises git's real + ignore machinery rather than reading .gitignore text. + """ + import subprocess + + repo_root = Path(__file__).resolve().parents[2] + if not (repo_root / ".git").exists(): + pytest.skip("not running from a git checkout") + + result = subprocess.run( + ["git", "-C", str(repo_root), "check-ignore", "-q", "bin/hermes.exe"], + capture_output=True, + ) + assert result.returncode == 0, ( + "bin/hermes.exe is not gitignored — hermes update's autostash " + "(--include-untracked) would sweep pre-migration launchers off disk" + ) diff --git a/tests/hermes_cli/test_windows_native_docs.py b/tests/hermes_cli/test_windows_native_docs.py index 6e53ff7a0f05..2594ff8d48d1 100644 --- a/tests/hermes_cli/test_windows_native_docs.py +++ b/tests/hermes_cli/test_windows_native_docs.py @@ -5,15 +5,18 @@ def test_windows_native_install_path_docs_match_installer() -> None: doc = Path("website/docs/user-guide/windows-native.md").read_text() install = Path("scripts/install.ps1").read_text() - # The launchers live in a dedicated bin/ dir on PATH — NOT the whole - # venv\Scripts (which would shadow the user's python, #83797). - assert "%LOCALAPPDATA%\\hermes\\hermes-agent\\bin" in doc + # The launchers live in the managed binary dir OUTSIDE the git checkout + # (HERMES_HOME\bin, next to the managed uv) — NOT the whole venv\Scripts + # (which would shadow the user's python, #83797) and NOT a dir inside + # the checkout (which `hermes update`'s autostash swept off disk). + assert "%LOCALAPPDATA%\\hermes\\bin" in doc assert ( "Get-Command hermes # should print " - "C:\\Users\\\\AppData\\Local\\hermes\\hermes-agent\\bin\\hermes.exe" + "C:\\Users\\\\AppData\\Local\\hermes\\bin\\hermes.exe" ) in doc - # Installer exposes $InstallDir\bin, and must copy the launchers into it. - assert '$hermesBin = "$InstallDir\\bin"' in install + # Installer exposes $HermesHome\bin, and must copy the launchers into it. + assert '$hermesBin = "$HermesHome\\bin"' in install assert "hermes.exe" in install and "hermes-acp.exe" in install - # Guard against a regression back to putting venv\Scripts on PATH. + # Guard against regressions to either legacy layout. assert '$hermesBin = "$InstallDir\\venv\\Scripts"' not in install + assert '$hermesBin = "$InstallDir\\bin"' not in install diff --git a/website/docs/user-guide/windows-native.md b/website/docs/user-guide/windows-native.md index 4e0a48c0b060..f703dfe286a2 100644 --- a/website/docs/user-guide/windows-native.md +++ b/website/docs/user-guide/windows-native.md @@ -75,7 +75,7 @@ Top-to-bottom, in order: 6. **Tiered `uv pip install`** — tries `.[all]` first, falls back to progressively smaller sets (`[messaging,dashboard,ext]` → `[messaging]` → `.`) if a `git+https` dep flakes on rate-limited GitHub. Prevents "single flake drops you to a bare install" failure mode. 7. **Auto-installs messaging SDKs** keyed off `.env` — if `TELEGRAM_BOT_TOKEN` / `DISCORD_BOT_TOKEN` / `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` / `WHATSAPP_ENABLED` are present, runs `python -m ensurepip --upgrade` and targeted `pip install` calls so each platform's SDK is actually importable. 8. **Sets `HERMES_GIT_BASH_PATH`** to the resolved `bash.exe` so Hermes finds it deterministically in fresh shells. -9. **Adds `%LOCALAPPDATA%\hermes\hermes-agent\bin` to User PATH and sets `HERMES_HOME=%LOCALAPPDATA%\hermes`** — exposes the `hermes` command (and points it at your data dir) after you open a new terminal. Only the `hermes.exe` / `hermes-acp.exe` launchers are copied into this `bin` directory; the full `venv\Scripts` is deliberately **not** placed on PATH so Hermes never shadows your own `python` command. +9. **Adds `%LOCALAPPDATA%\hermes\bin` to User PATH and sets `HERMES_HOME=%LOCALAPPDATA%\hermes`** — exposes the `hermes` command (and points it at your data dir) after you open a new terminal. Only the `hermes.exe` / `hermes-acp.exe` launchers are copied into this `bin` directory; the full `venv\Scripts` is deliberately **not** placed on PATH so Hermes never shadows your own `python` command. 10. **Runs `hermes setup`** — the normal first-run wizard (model, provider, toolsets). Skip with `-SkipSetup`. :::tip Skip provider hunting on Windows @@ -202,10 +202,10 @@ Services require admin rights to install and tie the gateway's lifecycle to mach | Path | Contents | |---|---| -| `%LOCALAPPDATA%\hermes\hermes-agent\` | Git checkout + venv. The `bin\hermes.exe` launcher (copied from `venv\Scripts\hermes.exe`) is the command added to User PATH. Safe to `Remove-Item -Recurse` and reinstall. | +| `%LOCALAPPDATA%\hermes\hermes-agent\` | Git checkout + venv. Safe to `Remove-Item -Recurse` and reinstall. | | `%LOCALAPPDATA%\hermes\git\` | PortableGit (only if the installer provisioned it). | | `%LOCALAPPDATA%\hermes\node\` | Portable Node.js (only if the installer provisioned it). | -| `%LOCALAPPDATA%\hermes\bin\` | Hermes's managed `uv.exe` (the Python manager it uses for updates). | +| `%LOCALAPPDATA%\hermes\bin\` | The `hermes` / `hermes-acp` launchers and Hermes's managed `uv.exe` (the Python manager it uses for updates). | | `%LOCALAPPDATA%\hermes\` (root) | Your config, auth, skills, sessions, logs (`config.yaml`, `.env`, `skills\`, `sessions\`, `logs\`, …). **Survives reinstalls.** | On native Windows the installer sets `HERMES_HOME=%LOCALAPPDATA%\hermes`, so your data and the disposable install live under the **same** `%LOCALAPPDATA%\hermes` root: the install/runtime is the `hermes-agent\`, `git\`, `node\`, and `bin\` subdirectories, while your data files sit directly in `%LOCALAPPDATA%\hermes`. Reinstalling only replaces the `hermes-agent\` checkout, so your data survives — but because the two share a root, **don't** `Remove-Item -Recurse %LOCALAPPDATA%\hermes` if you want to keep your data; delete the `hermes-agent\` subdirectory instead. Your data directory is identical in shape to a Linux `~/.hermes`, so you can mirror it between machines. @@ -224,12 +224,12 @@ The browser tool uses `agent-browser` (a Node helper) to drive Chromium. On Wind ### PATH after install -The installer adds `%LOCALAPPDATA%\hermes\hermes-agent\bin` to your **User PATH** via `[Environment]::SetEnvironmentVariable`. Existing terminals don't pick this up — open a new PowerShell window (or Windows Terminal tab) after installation. Close-and-reopen, don't `$env:PATH += …` by hand unless you know what you're doing. +The installer adds `%LOCALAPPDATA%\hermes\bin` to your **User PATH** via `[Environment]::SetEnvironmentVariable`. Existing terminals don't pick this up — open a new PowerShell window (or Windows Terminal tab) after installation. Close-and-reopen, don't `$env:PATH += …` by hand unless you know what you're doing. Verify: ```powershell -Get-Command hermes # should print C:\Users\\AppData\Local\hermes\hermes-agent\bin\hermes.exe +Get-Command hermes # should print C:\Users\\AppData\Local\hermes\bin\hermes.exe hermes --version ``` @@ -288,7 +288,7 @@ Consequence: any codepath that said "check if this PID is alive" via `os.kill(pi ## Common pitfalls **`hermes: command not found` right after install.** -Open a new PowerShell window. The installer added `%LOCALAPPDATA%\hermes\hermes-agent\bin` to User PATH, but existing shells need to be restarted to pick it up. In the meantime you can run `& "$env:LOCALAPPDATA\hermes\hermes-agent\bin\hermes.exe"`. +Open a new PowerShell window. The installer added `%LOCALAPPDATA%\hermes\bin` to User PATH, but existing shells need to be restarted to pick it up. In the meantime you can run `& "$env:LOCALAPPDATA\hermes\bin\hermes.exe"`. **`WinError 193: %1 is not a valid Win32 application` when running a tool.** You hit a shebang-script invocation that bypassed the `.cmd` shim. Hermes resolves commands through `shutil.which(cmd, path=local_bin)` so PATHEXT picks up `.CMD` — if you're invoking the tool via a hardcoded path instead, switch to the `.cmd` variant (e.g., `npx.cmd`, not `npx`). From 5d5179d7a9c0b1535cb11254835f9dbb53bd7e80 Mon Sep 17 00:00:00 2001 From: emozilla Date: Sat, 22 Aug 2026 13:39:04 -0400 Subject: [PATCH 018/666] fix(windows): remove the hermes launchers on uninstall Every uninstall mode deletes the code checkout, but the launchers in the managed binary dir (%LOCALAPPDATA%\hermes\bin) live outside it and survived -- so `hermes` in a new terminal resolved to a launcher whose venv target was gone and errored, which reads worse than command-not-found. remove_windows_bin_launchers deletes both launcher forms (.exe/.cmd) from the managed binary dir in every uninstall mode, anchored on the default Hermes root so profile sessions cannot redirect the sweep into profiles\\bin. When the uninstall itself runs through the launcher, that exe is mandatory-locked against deletion but not rename (the same fact _quarantine_running_hermes_exe relies on), so it falls back to renaming the launcher aside. The managed uv (uv*.exe) in the same dir survives, and the hermes\bin PATH entry is swept only on a full wipe from the default root (include_managed_bin) -- a keep-data uninstall keeps the still-working uv resolvable for reinstalls. A lockstep test parses install.ps1's staging loop so the swept names cannot drift from the staged names silently. --- hermes_cli/uninstall.py | 96 ++++++++++++++- .../test_uninstall_windows_bin_launchers.py | 110 ++++++++++++++++++ 2 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 tests/hermes_cli/test_uninstall_windows_bin_launchers.py diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index efc546d8eb51..fbced3bab7f2 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -336,12 +336,20 @@ def uninstall_gateway_service(): # or open a new terminal anyway). -def _hermes_path_markers(hermes_home: Path) -> list[str]: - """Path-entry substrings that identify Hermes-owned User-PATH entries.""" +def _hermes_path_markers(hermes_home: Path, *, include_managed_bin: bool = False) -> list[str]: + """Path-entry substrings that identify Hermes-owned User-PATH entries. + + ``include_managed_bin`` adds the managed binary dir (``\\bin``, + holding the hermes launchers and the managed uv) — only wanted when + that dir is about to be deleted (full uninstall from the default root), + so a keep-data uninstall leaves the still-working managed uv resolvable. + """ root = str(hermes_home).rstrip("\\/") # Match on prefix so sub-entries (git\cmd, git\bin, git\usr\bin, node, etc.) # all get swept. Also match the bare hermes-agent install dir. markers = [root + "\\hermes-agent", root + "\\git", root + "\\node", root + "\\venv"] + if include_managed_bin: + markers.append(root + "\\bin") # Also match if HERMES_HOME was customised to somewhere else — find-and-nuke # any entry whose path component contains "hermes". We don't want to catch # unrelated entries like "chermes-foo" or "ephermeral", so we look for @@ -349,11 +357,17 @@ def _hermes_path_markers(hermes_home: Path) -> list[str]: return markers -def remove_path_from_windows_registry(hermes_home: Path) -> list[str]: +def remove_path_from_windows_registry(hermes_home: Path, *, include_managed_bin: bool = False) -> list[str]: """Strip Hermes-owned entries from User-scope PATH in the registry. Returns the list of removed path entries. Operates on HKCU\\Environment, same key the installer wrote to via ``[Environment]::SetEnvironmentVariable``. + + ``include_managed_bin`` adds ``\\bin`` (the managed binary + dir holding the hermes launchers and the managed uv) to the sweep. Only + pass it when that dir is actually being deleted — full uninstall from + the default root — so a keep-data uninstall leaves the still-working + managed uv resolvable. """ try: import winreg @@ -371,7 +385,7 @@ def remove_path_from_windows_registry(hermes_home: Path) -> list[str]: return [] # Preserve REG_EXPAND_SZ vs REG_SZ so unexpanded %VARS% survive. entries = [e for e in path_value.split(";") if e] - markers = _hermes_path_markers(hermes_home) + markers = _hermes_path_markers(hermes_home, include_managed_bin=include_managed_bin) kept: list[str] = [] for entry in entries: entry_norm = entry.rstrip("\\/") @@ -430,6 +444,58 @@ def remove_portable_tooling_windows(hermes_home: Path) -> list[Path]: return removed +def remove_windows_bin_launchers(*, windows: bool | None = None) -> list[Path]: + """Delete the ``hermes`` launchers install.ps1 staged in the managed + binary dir (the default Hermes root's ``bin``, next to the managed uv). + + Every uninstall mode deletes the code checkout, so the launchers — + which invoke ``\\venv\\Scripts`` — would otherwise dangle: + ``hermes`` in a new terminal resolves to a launcher whose target is + gone and errors, which reads worse than command-not-found. The managed + uv (uv*.exe) in the same dir is left for keep-data reinstalls. + + A launcher that IS this process's own trampoline is mandatory-locked + against deletion but not rename (same fact + ``_install_repair._quarantine_running_hermes_exe`` relies on), so + deletion falls back to renaming it aside with a non-executable suffix. + + *windows* is an injectable platform verdict for tests (same pattern as + ``_install_repair.ensure_windows_bin_launchers``). + """ + if windows is None: + windows = _is_windows() + if not windows: + return [] + try: + # Lockstep launcher-name list — the same names install.ps1 and the + # startup heal stage into this dir. + from hermes_cli._install_repair import _WINDOWS_BIN_LAUNCHERS + from hermes_constants import get_default_hermes_root + + bin_dir = get_default_hermes_root() / "bin" + except Exception as e: + log_warn(f"Could not locate the managed binary dir: {e}") + return [] + + removed: list[Path] = [] + for name in _WINDOWS_BIN_LAUNCHERS: + for suffix in (".exe", ".cmd"): + launcher = bin_dir / f"{name}{suffix}" + if not launcher.exists(): + continue + try: + launcher.unlink() + removed.append(launcher) + except OSError: + aside = launcher.with_name(f"{launcher.name}.uninstalled.{os.getpid()}") + try: + os.rename(launcher, aside) + removed.append(launcher) + except OSError as e: + log_warn(f"Could not remove {launcher}: {e}") + return removed + + def _is_windows() -> bool: import sys return sys.platform == "win32" @@ -794,7 +860,14 @@ def _perform_uninstall( # Expand %LOCALAPPDATA% etc. in hermes_home so the marker matching is # against fully resolved paths — installer writes literal strings # like C:\Users\\AppData\Local\hermes\git\cmd, not %LOCALAPPDATA%. - removed_path_entries = remove_path_from_windows_registry(Path(os.path.expandvars(str(hermes_home)))) + # The managed binary dir (hermes\bin: launchers + managed uv) leaves + # the PATH only when the full wipe below is about to delete it; + # keep-data mode keeps the dir and the still-working uv resolvable. + sweep_managed_bin = full_uninstall and _is_default_hermes_home(hermes_home) + removed_path_entries = remove_path_from_windows_registry( + Path(os.path.expandvars(str(hermes_home))), + include_managed_bin=sweep_managed_bin, + ) if removed_path_entries: for entry in removed_path_entries: log_success(f"Removed from User PATH: {entry}") @@ -818,6 +891,19 @@ def _perform_uninstall( else: log_info("No wrapper script found") + # 3a. Remove the Windows launchers from the managed binary dir. Both + # modes delete the code checkout below, so a surviving launcher + # would dangle — `hermes` in a new terminal would resolve and then + # error on its missing venv target, worse than command-not-found. + if _is_windows(): + log_info("Removing Windows hermes launchers...") + removed_launchers = remove_windows_bin_launchers() + if removed_launchers: + for launcher in removed_launchers: + log_success(f"Removed {launcher}") + else: + log_info("No Windows hermes launchers found") + # 3b. Remove node/npm/npx symlinks the installer left in ~/.local/bin # (only when they still point into this Hermes home's node dir, so we # never clobber an existing nvm / user-managed Node). diff --git a/tests/hermes_cli/test_uninstall_windows_bin_launchers.py b/tests/hermes_cli/test_uninstall_windows_bin_launchers.py new file mode 100644 index 000000000000..fc2eba3aafa0 --- /dev/null +++ b/tests/hermes_cli/test_uninstall_windows_bin_launchers.py @@ -0,0 +1,110 @@ +"""Uninstall must not leave a dangling ``hermes`` command on Windows. + +Every uninstall mode deletes the code checkout, but the launchers install.ps1 +staged in the managed binary dir (the default Hermes root's ``bin``, shared +with the managed uv) live outside it. A surviving launcher makes ``hermes`` +in a new terminal resolve and then error on its missing venv target — worse +than command-not-found. The managed uv next to them must survive keep-data +uninstalls, so the PATH sweep takes the ``bin`` entry only on a full wipe. + +Platform verdicts are injected parameters (input→output, not host fakes). +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from hermes_cli import uninstall +from hermes_cli._install_repair import _WINDOWS_BIN_LAUNCHERS + + +@pytest.fixture +def managed_bin(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Default-root ``bin`` holding launchers of both forms plus managed uv.""" + home = tmp_path / "hermes" + bin_dir = home / "bin" + bin_dir.mkdir(parents=True) + (bin_dir / "hermes.exe").write_bytes(b"MZ launcher") + (bin_dir / "hermes-acp.cmd").write_text("@echo off\r\n", encoding="ascii") + (bin_dir / "uv.exe").write_bytes(b"MZ managed uv") + (bin_dir / "uvx.exe").write_bytes(b"MZ managed uvx") + monkeypatch.setenv("HERMES_HOME", str(home)) + return bin_dir + + +def test_removes_both_launcher_forms_and_keeps_managed_uv(managed_bin: Path): + removed = uninstall.remove_windows_bin_launchers(windows=True) + + assert sorted(p.name for p in removed) == ["hermes-acp.cmd", "hermes.exe"] + assert not (managed_bin / "hermes.exe").exists() + assert not (managed_bin / "hermes-acp.cmd").exists() + # The managed uv stays — keep-data reinstalls still need it. + assert (managed_bin / "uv.exe").exists() + assert (managed_bin / "uvx.exe").exists() + + +def test_anchors_on_default_root_not_profile_home( + managed_bin: Path, monkeypatch: pytest.MonkeyPatch +): + """The launcher dir is per-machine; a profile HERMES_HOME must not + redirect the sweep into ``profiles//bin``.""" + home = managed_bin.parent + monkeypatch.setenv("HERMES_HOME", str(home / "profiles" / "work")) + + removed = uninstall.remove_windows_bin_launchers(windows=True) + + assert sorted(p.name for p in removed) == ["hermes-acp.cmd", "hermes.exe"] + assert not (managed_bin / "hermes.exe").exists() + + +def test_noop_on_posix(managed_bin: Path): + assert uninstall.remove_windows_bin_launchers(windows=False) == [] + assert (managed_bin / "hermes.exe").exists() + + +def test_noop_when_no_launchers_staged(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + home = tmp_path / "hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + assert uninstall.remove_windows_bin_launchers(windows=True) == [] + + +def test_launcher_names_stay_in_lockstep_with_install_ps1(): + """The sweep must cover exactly the names install.ps1 stages, and no + generic name it could clobber. Reads the real installer list so the two + sides cannot drift apart silently.""" + import re + + install_ps1 = ( + Path(uninstall.__file__).resolve().parents[1] / "scripts" / "install.ps1" + ).read_text(encoding="ascii") + match = re.search(r"foreach \(\$launcher in @\(([^)]*)\)\)", install_ps1) + assert match, "launcher staging loop not found in install.ps1" + staged = set(re.findall(r'"([^"]+)"', match.group(1))) + + assert staged == set(_WINDOWS_BIN_LAUNCHERS) + for name in _WINDOWS_BIN_LAUNCHERS: + assert name.startswith("hermes") # never a generic name it could clobber + + +class TestManagedBinPathMarker: + """The managed ``bin`` PATH entry goes only when the dir itself goes.""" + + def test_keep_data_markers_spare_the_managed_bin(self, tmp_path: Path): + home = tmp_path / "hermes" + markers = [m.lower() for m in uninstall._hermes_path_markers(home)] + + bin_key = str(home / "bin").lower() + assert not any(bin_key.startswith(m) for m in markers) + + def test_full_wipe_markers_take_the_managed_bin(self, tmp_path: Path): + home = tmp_path / "hermes" + markers = [ + m.lower() + for m in uninstall._hermes_path_markers(home, include_managed_bin=True) + ] + + bin_key = str(home / "bin").lower() + assert any(bin_key.startswith(m) for m in markers) From 0605279b358f0ca048a61b48e5af948769ccf095 Mon Sep 17 00:00:00 2001 From: emozilla Date: Sat, 22 Aug 2026 13:39:04 -0400 Subject: [PATCH 019/666] test: guard os.geteuid() for Windows in the ACP launcher tests os.geteuid() does not exist on Windows, so collecting the module crashed with AttributeError before any test ran. Branch on hasattr(os, "geteuid") the same way the code under test does. --- tests/hermes_cli/test_ensure_acp_launcher.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/hermes_cli/test_ensure_acp_launcher.py b/tests/hermes_cli/test_ensure_acp_launcher.py index 587e726fad24..fac777858d4a 100644 --- a/tests/hermes_cli/test_ensure_acp_launcher.py +++ b/tests/hermes_cli/test_ensure_acp_launcher.py @@ -52,6 +52,13 @@ def test_does_not_follow_symlink_into_venv(fake_home, tmp_path): def test_unwritable_bin_dir_is_skipped(fake_home): (fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8") + if not hasattr(os, "geteuid"): + # Windows: no geteuid, and chmod can't make a directory unwritable + # anyway. _ensure_acp_launcher is an explicit no-op there, so the + # assertion below still holds — vacuously. + _ensure_acp_launcher() + assert not (fake_home / "hermes-acp").exists() + return if os.geteuid() == 0: pytest.skip("root ignores directory write permissions") fake_home.chmod(0o555) From 7a54ab22e648a0d2d7e740fad908dda93f025820 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:39:49 -0700 Subject: [PATCH 020/666] fix(gateway): control-socket hardening from #92447 post-merge review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bind under umask 0o177 so the socket is never world-connectable, even pre-chmod (review pt 3) - verb handlers run in an executor: state-file reads stay off the adapter event loop (pt 2) - inventory dedupes one multiplex gateway answering identify for several homes — one runtime record per pid, with regression test (pt 1) - v1 wire contract (one request per connection) documented in the module docstring (pt 5); /tmp-unwritable skip in the short-home test Live-verified: perms 600 at bind, identify 4.3ms via executor path, 20 rapid queries healthy. --- gateway/control_socket.py | 27 +++++++++++++++++--- hermes_cli/update_inventory.py | 5 ++++ tests/gateway/test_control_socket.py | 38 +++++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/gateway/control_socket.py b/gateway/control_socket.py index 3962de58a53f..383b3c87699f 100644 --- a/gateway/control_socket.py +++ b/gateway/control_socket.py @@ -31,6 +31,11 @@ Never a TCP port. Filesystem/pipe ACLs are the auth boundary — the same trust model as ``gateway_state.json`` today. +v1 wire contract: ONE request per connection — a single JSON line in, a +single JSON line out, then the server closes. Clients must not rely on +keep-alive or pipelining. Verb handlers may touch disk (they run in an +executor server-side) but must stay fast; the client budget is small. + Consumers (``hermes update --plan`` inventory, the post-update fleet version matrix) PREFER the socket when it answers and fall back to the existing state-file/scan layer when it doesn't — old gateways mid-upgrade and crashed @@ -271,9 +276,16 @@ async def _start_posix(self) -> bool: with contextlib.suppress(OSError): if bind_path.exists(): bind_path.unlink() - self._server = await asyncio.start_unix_server( - self._handle_connection, path=str(bind_path) - ) + # Bind under a restrictive umask so the socket is never + # world-connectable, even for the instant before an explicit chmod + # could run. Restore the process umask immediately after. + old_umask = os.umask(0o177) + try: + self._server = await asyncio.start_unix_server( + self._handle_connection, path=str(bind_path) + ) + finally: + os.umask(old_umask) with contextlib.suppress(OSError): os.chmod(bind_path, 0o600) self._bind_path = bind_path @@ -379,7 +391,14 @@ async def _handle_connection( ) if not raw or len(raw) > _MAX_REQUEST_BYTES: return - writer.write(self.handle_request_line(raw.rstrip(b"\n"))) + # Handlers read state files from disk; keep that off the + # gateway's event loop (the same loop drives every platform + # adapter), so a fast-polling consumer can't stall heartbeats. + loop = asyncio.get_running_loop() + response = await loop.run_in_executor( + None, self.handle_request_line, raw.rstrip(b"\n") + ) + writer.write(response) await writer.drain() except (asyncio.TimeoutError, ConnectionError, OSError): pass diff --git a/hermes_cli/update_inventory.py b/hermes_cli/update_inventory.py index e38ddf4829fe..f978bfbf3de6 100644 --- a/hermes_cli/update_inventory.py +++ b/hermes_cli/update_inventory.py @@ -199,6 +199,11 @@ def collect_runtime_inventory() -> UpdatePlan: except (TypeError, ValueError): sock_pid = None if sock_pid is not None: + if sock_pid in seen_pids: + # One multiplex gateway can answer identify for + # several profile homes — one runtime record per + # process, not per home. + continue seen_pids.add(sock_pid) declared = identity.get("supervisor") supervisor = ( diff --git a/tests/gateway/test_control_socket.py b/tests/gateway/test_control_socket.py index 2e8a7bb1dfc3..7dba7588f36a 100644 --- a/tests/gateway/test_control_socket.py +++ b/tests/gateway/test_control_socket.py @@ -50,7 +50,10 @@ def test_short_home_binds_in_home(tmp_path: Path): # system temp root directly. import tempfile - short_root = Path(tempfile.mkdtemp(prefix="hgw-", dir="/tmp")) + try: + short_root = Path(tempfile.mkdtemp(prefix="hgw-", dir="/tmp")) + except OSError: + pytest.skip("/tmp not writable on this host") try: short_home = short_root / ".hermes" short_home.mkdir() @@ -337,6 +340,39 @@ def test_collect_fleet_versions_falls_back_to_state_file(tmp_path: Path, monkeyp assert "source" not in fleet[0] +def test_runtime_inventory_dedupes_same_pid_across_homes(tmp_path: Path, monkeypatch): + """One multiplex gateway answering identify for two profile homes must + yield exactly ONE runtime record (reviewer point on #92447).""" + import hermes_cli.update_inventory as ui + + home = tmp_path / ".hermes" + home.mkdir() + profiles_root = tmp_path / "profiles" + (profiles_root / "coder").mkdir(parents=True) + + monkeypatch.setattr( + "hermes_cli.profiles._get_default_hermes_home", lambda: home + ) + monkeypatch.setattr( + "hermes_cli.profiles._get_profiles_root", lambda: profiles_root + ) + monkeypatch.setattr( + "hermes_cli.gateway._get_service_pids", lambda all_profiles=False: set() + ) + monkeypatch.setattr( + "hermes_cli.gateway.find_profile_gateway_processes", lambda: [] + ) + monkeypatch.setattr( + "gateway.control_socket.identify_gateway", + lambda h, **kw: _fake_identity(777, "SHA777"), + ) + + plan = ui.collect_runtime_inventory() + gws = [r for r in plan.runtimes if r.kind == "gateway"] + assert len(gws) == 1, [r.__dict__ for r in gws] + assert gws[0].pid == 777 + + def test_runtime_inventory_prefers_socket_supervisor(tmp_path: Path, monkeypatch): import hermes_cli.update_inventory as ui From 83864c0b5d8464bdd1afa3b6bae2d9b57332055c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:40:34 -0700 Subject: [PATCH 021/666] =?UTF-8?q?fix(update):=20a=20contended=20venv=20i?= =?UTF-8?q?s=20never=20mutated=20=E2=80=94=20failed=20shim=20quarantine=20?= =?UTF-8?q?now=20refuses=20instead=20of=20warning=20(#87331)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #87331 remaining half: when hermes.exe (or a sibling shim) could not be renamed aside, the updater printed a warning and ran the installer anyway — which died partway on the same locks and stranded the venv between versions. - _run_quarantined_install gains strict_quarantine: any shim whose rename failed every retry aborts BEFORE the install command runs (successful renames rolled back), raising ShimQuarantineError. - The update dependency sync passes strict_quarantine=True. The update boundary turns the error into a refusal: defer via the update-incomplete marker, exit 2 (recorded as refused by the receipt net), never ZIP-fallback. Post-sync repair installs keep warn-and-try (their venv is already mutated; refusing buys nothing). - The recovery installer (_install_repair._run_install_cmd) is strict unconditionally: marker survives, next launch retries after the holder exits. - Live Windows E2E for the wine2e lane: a real child holds hermes.exe without FILE_SHARE_DELETE (the exact field lock shape), strict path refuses with zero installer invocations, releases roll back, and the same path proceeds once the holder exits. Sabotage-verified: reverting the strict wiring makes both fail-closed tests fail. --- hermes_cli/_install_repair.py | 42 +++- hermes_cli/main.py | 51 ++++- hermes_cli/update_cmd.py | 54 ++++- .../test_shim_fail_closed_windows_live.py | 140 ++++++++++++ .../test_update_shim_fail_closed.py | 210 ++++++++++++++++++ 5 files changed, 490 insertions(+), 7 deletions(-) create mode 100644 tests/hermes_cli/test_shim_fail_closed_windows_live.py create mode 100644 tests/hermes_cli/test_update_shim_fail_closed.py diff --git a/hermes_cli/_install_repair.py b/hermes_cli/_install_repair.py index 848d400b0bc7..39e5e2968464 100644 --- a/hermes_cli/_install_repair.py +++ b/hermes_cli/_install_repair.py @@ -185,13 +185,35 @@ def _load_console_script_names(root: Path) -> list[str]: return [] -def _quarantine_running_hermes_exe(scripts_dir: Path) -> list[tuple[Path, Path]]: +class ShimQuarantineError(RuntimeError): + """A live shim could not be renamed aside — the venv is contended (#87331). + + Raised BEFORE the install command runs. Callers (early-pass recovery, + core-marker recovery) catch it like any install failure: the + update-incomplete marker survives and a later launch retries once the + holder exits — the contended venv is never mutated. + """ + + def __init__(self, failed_shims: list[str]): + self.failed_shims = list(failed_shims) + super().__init__( + "could not quarantine live shim(s): " + ", ".join(self.failed_shims) + ) + + +def _quarantine_running_hermes_exe( + scripts_dir: Path, *, failed_out: list[str] | None = None +) -> list[tuple[Path, Path]]: """Rename live hermes*.exe shims aside so the installer can rewrite them. Windows blocks REPLACE on a running .exe but allows RENAME. Best-effort: silently skips anything that cannot be renamed. Returns (original, quarantined) pairs. stdlib-only — the console-script set comes from pyproject ``[project.scripts]`` (fallback: the well-known trio). + + ``failed_out``: when provided, names of shims that could not be renamed + are appended so the caller can refuse instead of mutating a contended + venv (#87331 fail-closed). """ if not _is_windows(): return [] @@ -211,7 +233,8 @@ def _quarantine_running_hermes_exe(scripts_dir: Path) -> list[tuple[Path, Path]] os.rename(shim, quarantined) moved.append((shim, quarantined)) except OSError: - pass + if failed_out is not None: + failed_out.append(shim.name) return moved @@ -229,11 +252,24 @@ def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None: def _run_install_cmd(cmd: list[str], *, env: dict | None, root: Path) -> None: """Run an install command with quarantine protection for venv shims. + Fail-closed (#87331): when any live shim cannot be renamed aside, the + venv is contended and the installer would die partway on the same locks + — raise :class:`ShimQuarantineError` WITHOUT running it. The caller's + marker-keeping failure handling turns that into "retry next launch". + Raises CalledProcessError on install failure (callers implement the per-extra fallback ladder). """ scripts_dir = _venv_scripts_dir(root) if _is_windows() else None - moved = _quarantine_running_hermes_exe(scripts_dir) if scripts_dir else [] + failed: list[str] = [] + moved = ( + _quarantine_running_hermes_exe(scripts_dir, failed_out=failed) + if scripts_dir + else [] + ) + if failed: + _restore_quarantined_exes(moved) + raise ShimQuarantineError(failed) try: subprocess.run(cmd, cwd=root, check=True, env=env) finally: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 448261c6c200..f188279d28ea 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8984,7 +8984,8 @@ def _hermes_exe_shims(scripts_dir: Path) -> list[Path]: def _quarantine_running_hermes_exe( - scripts_dir: Path, *, max_attempts: int = 4 + scripts_dir: Path, *, max_attempts: int = 4, + failed_out: list[str] | None = None, ) -> list[tuple[Path, Path]]: """Pre-empt Windows file lock on the running ``hermes.exe``. @@ -9014,6 +9015,11 @@ def _quarantine_running_hermes_exe( Returns the list of (original, quarantined) pairs so the caller can roll back if the install itself fails before uv writes a replacement. + + ``failed_out``: when provided, the names of shims whose rename failed on + every attempt are appended — callers that must not mutate a contended + venv (the update dependency sync, #87331) check it and refuse instead of + letting the install run into a half-broken state. """ moved: list[tuple[Path, Path]] = [] if not _is_windows(): @@ -9064,6 +9070,8 @@ def _quarantine_running_hermes_exe( " Close Hermes Desktop, exit other `hermes` REPLs, stop the " "gateway, or pause AV scanning, then re-run `hermes update`." ) + if failed_out is not None: + failed_out.append(shim.name) return moved @@ -9152,11 +9160,29 @@ def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None: pass +class ShimQuarantineError(RuntimeError): + """A live ``hermes*.exe`` shim could not be renamed aside (#87331). + + Raised by :func:`_run_quarantined_install` in ``strict_quarantine`` mode + BEFORE the install command runs. A shim that cannot even be renamed means + another process holds the venv hard enough that the dependency sync would + die partway and strand the install half-updated — the update must refuse, + not warn-and-continue. + """ + + def __init__(self, failed_shims: list[str]): + self.failed_shims = list(failed_shims) + super().__init__( + "could not quarantine live shim(s): " + ", ".join(self.failed_shims) + ) + + def _run_quarantined_install( cmd: list[str], *, env: dict[str, str] | None = None, scripts_dir: Path | None = None, + strict_quarantine: bool = False, ) -> None: """Run an editable install, quarantining the running ``hermes.exe`` first. @@ -9171,11 +9197,24 @@ def _run_quarantined_install( :func:`_verify_core_dependencies_installed`, which previously called ``_run_install_with_heartbeat`` directly and bypassed quarantine. + ``strict_quarantine=True`` (the update dependency sync, #87331): a shim + whose rename failed every retry means a process is holding the venv + without ``FILE_SHARE_DELETE`` — the install WILL hit the same lock on + .pyd files and strand the venv between versions. Roll the successful + renames back and raise :class:`ShimQuarantineError` WITHOUT running the + install. Non-strict callers (post-sync entry-point repair) keep the old + warn-and-try behavior: their venv is already mutated, so refusing buys + nothing. + Off-Windows (``scripts_dir is None``) this is a thin pass-through. """ moved: list[tuple[Path, Path]] = [] + failed: list[str] = [] if scripts_dir is not None: - moved = _quarantine_running_hermes_exe(scripts_dir) + moved = _quarantine_running_hermes_exe(scripts_dir, failed_out=failed) + if strict_quarantine and failed: + _restore_quarantined_exes(moved) + raise ShimQuarantineError(failed) try: _run_install_with_heartbeat(cmd, env=env) finally: @@ -9450,8 +9489,14 @@ def _install_python_dependencies_with_optional_fallback( scripts_dir = _venv_scripts_dir() if _is_windows() else None def _install(args: list[str]) -> None: + # strict_quarantine: this is the UPDATE dependency sync. A shim that + # cannot be renamed aside proves a hard venv hold; running uv anyway + # is how installs strand half-updated (#87331). ShimQuarantineError + # propagates to the update's sync boundary, which defers via the + # update-incomplete marker instead of mutating a contended venv. _run_quarantined_install( - install_cmd_prefix + args, env=env, scripts_dir=scripts_dir + install_cmd_prefix + args, env=env, scripts_dir=scripts_dir, + strict_quarantine=True, ) try: diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 8bdecc944690..2509e207eb0b 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -1216,6 +1216,48 @@ def _format_update_failure_stage(exc: subprocess.CalledProcessError) -> str: return "Update step failed" +def _shim_quarantine_error_type() -> "type[BaseException]": + """The strict-quarantine refusal type, resolved lazily through ``_m()``. + + Falls back to a never-raised private type when main.py lacks it (torn + mid-update tree), so the ``except`` clause stays valid. + """ + cls = getattr(_m(), "ShimQuarantineError", None) + if isinstance(cls, type) and issubclass(cls, BaseException): + return cls + + class _Never(Exception): + pass + + return _Never + + +def _refuse_update_for_contended_shims(exc: BaseException) -> None: + """Refuse the dependency sync when live shims could not be quarantined. + + #87331 fail-closed half: a shim rename that failed every retry proves a + process holds the venv without FILE_SHARE_DELETE — running the installer + anyway is exactly how the venv ends up stranded between versions. The + code swap (when one happened) is already committed; only the dependency + install is deferred, via the update-incomplete marker, to the next fresh + launch after the holder exits. Exits 2 (refused) so the command-boundary + receipt net records it as a refusal, not a failure. + """ + print("✗ Cannot continue the update: live Hermes launcher(s) could not be") + print(" moved aside:") + for name in getattr(exc, "failed_shims", []) or ["hermes.exe"]: + print(f" {name}") + print(" Another process is holding this install's venv open (Hermes") + print(" Desktop, a gateway, or another hermes REPL) — mutating the venv") + print(" now would strand it half-updated.") + print(" The dependency install has been deferred: close the process(es)") + print(" above, then run any `hermes` command to finish it automatically.") + # Idempotent: the git path already dropped the marker before the sync; + # this covers the ZIP/repair paths so the deferral is never silent. + _write_update_incomplete_marker() + sys.exit(2) + + def _should_zip_fallback_on_update_error(exc: BaseException) -> bool: """ZIP fallback is for Windows git file-I/O breakage, not later stages. @@ -1627,7 +1669,13 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False) -> boo if _m()._is_termux_env(uv_env): uv_env.pop("PYTHONPATH", None) uv_env.pop("PYTHONHOME", None) - _m()._install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env) + try: + _m()._install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env) + except _shim_quarantine_error_type() as _sqe: + # #87331: this runs inside the ZIP-fallback error handler, so the + # boundary except clause in cmd_update cannot catch it — refuse + # here with the same defer-via-marker contract. + _refuse_update_for_contended_shims(_sqe) else: # Use sys.executable to explicitly call the venv's pip module, # avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu. @@ -7990,6 +8038,10 @@ def _on_unit_timeout(svc_name: str, exc: subprocess.TimeoutExpired) -> None: # automation / operators do not treat the fleet as healthy. sys.exit(1) + except _shim_quarantine_error_type() as e: + # Fail-closed shim contention (#87331): strict quarantine refused + # BEFORE any installer ran — defer via marker, exit 2, no ZIP. + _refuse_update_for_contended_shims(e) except subprocess.CalledProcessError as e: stage = _format_update_failure_stage(e) if _should_zip_fallback_on_update_error(e): diff --git a/tests/hermes_cli/test_shim_fail_closed_windows_live.py b/tests/hermes_cli/test_shim_fail_closed_windows_live.py new file mode 100644 index 000000000000..a3474fc10acb --- /dev/null +++ b/tests/hermes_cli/test_shim_fail_closed_windows_live.py @@ -0,0 +1,140 @@ +"""LIVE Windows E2E for the fail-closed shim quarantine (#87331). + +Runs ONLY on a real Windows host (the on-demand ``windows-venv-e2e.yml`` +lane). Reproduces the REAL lock shape from the field report: a process +holding ``hermes.exe`` open WITHOUT FILE_SHARE_DELETE, exactly like a +running launcher — then proves the strict quarantine refuses before any +installer runs, and that the non-contended path still installs. + +No mocks: real files, a real child process holding a real Windows handle, +the real rename attempt hitting the real sharing violation. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.skipif( + sys.platform != "win32", reason="live Windows shim-lock E2E" +) + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + +# Child that opens a file with GENERIC_READ and NO FILE_SHARE_DELETE — +# the exact sharing mode a running .exe image / desktop backend exhibits. +_HOLDER_CODE = r""" +import ctypes, sys, time +GENERIC_READ = 0x80000000 +FILE_SHARE_READ = 0x1 # note: NO FILE_SHARE_DELETE +OPEN_EXISTING = 3 +h = ctypes.windll.kernel32.CreateFileW( + sys.argv[1], GENERIC_READ, FILE_SHARE_READ, None, OPEN_EXISTING, 0, None +) +if h == -1 or h == 0xFFFFFFFF: + print("OPEN_FAILED", flush=True) + sys.exit(1) +print("HOLDING", flush=True) +time.sleep(120) +""" + + +@pytest.fixture() +def held_shim(tmp_path: Path): + scripts = tmp_path / "venv" / "Scripts" + scripts.mkdir(parents=True) + shim = scripts / "hermes.exe" + shim.write_bytes(b"MZ fake shim") + (scripts / "hermes-gateway.exe").write_bytes(b"MZ fake shim") + holder = subprocess.Popen( + [sys.executable, "-c", _HOLDER_CODE, str(shim)], + stdout=subprocess.PIPE, + text=True, + ) + line = holder.stdout.readline().strip() + if line != "HOLDING": + holder.kill() + pytest.fail(f"lock-holder child failed: {line!r}") + yield scripts, shim + holder.kill() + holder.wait() + + +def test_locked_shim_really_cannot_be_renamed(held_shim): + """Premise check: the no-FILE_SHARE_DELETE handle blocks rename.""" + _scripts, shim = held_shim + with pytest.raises(OSError): + os.rename(shim, shim.with_name("hermes.exe.old.premise")) + + +def test_strict_quarantine_refuses_against_real_lock(held_shim, monkeypatch): + import hermes_cli.main as cli_main + + scripts, _shim = held_shim + install_ran: list = [] + monkeypatch.setattr( + cli_main, + "_run_install_with_heartbeat", + lambda cmd, env=None: install_ran.append(cmd), + ) + + with pytest.raises(cli_main.ShimQuarantineError) as exc_info: + cli_main._run_quarantined_install( + ["would-be", "uv", "pip", "install"], + scripts_dir=scripts, + strict_quarantine=True, + ) + + assert install_ran == [], "installer ran against a contended venv" + assert "hermes.exe" in exc_info.value.failed_shims + # The unlocked sibling's rename was rolled back — venv untouched. + assert (scripts / "hermes-gateway.exe").exists() + assert not list(scripts.glob("*.old.*")) + + +def test_recovery_installer_refuses_against_real_lock(held_shim, monkeypatch): + import hermes_cli._install_repair as ir + + scripts, _shim = held_shim + monkeypatch.setattr(ir, "_venv_scripts_dir", lambda root: scripts) + run_calls: list = [] + monkeypatch.setattr(ir.subprocess, "run", lambda *a, **k: run_calls.append(a)) + + with pytest.raises(ir.ShimQuarantineError): + ir._run_install_cmd(["fake"], env=None, root=scripts.parent.parent) + assert run_calls == [] + + +def test_release_then_strict_quarantine_succeeds(tmp_path, monkeypatch): + """After the holder exits, the same strict path proceeds normally.""" + import hermes_cli.main as cli_main + + scripts = tmp_path / "venv" / "Scripts" + scripts.mkdir(parents=True) + (scripts / "hermes.exe").write_bytes(b"MZ fake shim") + + holder = subprocess.Popen( + [sys.executable, "-c", _HOLDER_CODE, str(scripts / "hermes.exe")], + stdout=subprocess.PIPE, + text=True, + ) + assert holder.stdout.readline().strip() == "HOLDING" + holder.kill() + holder.wait() + time.sleep(0.3) # handle teardown + + install_ran: list = [] + monkeypatch.setattr( + cli_main, + "_run_install_with_heartbeat", + lambda cmd, env=None: install_ran.append(cmd), + ) + cli_main._run_quarantined_install( + ["fake"], scripts_dir=scripts, strict_quarantine=True + ) + assert install_ran == [["fake"]] diff --git a/tests/hermes_cli/test_update_shim_fail_closed.py b/tests/hermes_cli/test_update_shim_fail_closed.py new file mode 100644 index 000000000000..7b04dbe189f4 --- /dev/null +++ b/tests/hermes_cli/test_update_shim_fail_closed.py @@ -0,0 +1,210 @@ +"""Fail-closed shim quarantine (#87331): a contended venv is never mutated. + +The bug class: on Windows, when `hermes.exe`/sibling shims could not be +renamed aside (another process holds them without FILE_SHARE_DELETE), the +updater printed a warning and ran the installer anyway — which then died +partway on the same locks and stranded the venv between versions (3x field +reports on one machine, #87331). + +Contract pinned here: +- `_run_quarantined_install(strict_quarantine=True)` raises + ShimQuarantineError BEFORE running any install command, and rolls back the + renames that did succeed. +- Non-strict callers keep the old warn-and-try behavior. +- The recovery installer's `_run_install_cmd` is strict unconditionally. +- The update boundary turns the error into a refusal (exit 2) + marker, + never a ZIP fallback. +""" + +import os +import sys +from pathlib import Path +from unittest import mock + +import pytest + +import hermes_cli.main as cli_main +import hermes_cli._install_repair as ir +import hermes_cli.update_cmd as update_cmd + + +def _make_shims(scripts_dir: Path, names=("hermes", "hermes-gateway")) -> list[Path]: + scripts_dir.mkdir(parents=True, exist_ok=True) + shims = [] + for name in names: + p = scripts_dir / f"{name}.exe" + p.write_bytes(b"MZ fake") + shims.append(p) + return shims + + +@pytest.fixture() +def windows(monkeypatch): + monkeypatch.setattr(cli_main, "_is_windows", lambda: True) + monkeypatch.setattr(ir, "_is_windows", lambda: True) + + +# --------------------------------------------------------------------------- +# main.py: _run_quarantined_install strict mode +# --------------------------------------------------------------------------- + +def test_strict_quarantine_refuses_before_install(windows, tmp_path, monkeypatch): + scripts = tmp_path / "venv" / "Scripts" + shims = _make_shims(scripts) + # hermes.exe cannot be renamed; hermes-gateway.exe can + real_rename = Path.rename + + def deny_hermes(self, target): + if self.name == "hermes.exe": + raise PermissionError(13, "held open") + return real_rename(self, target) + + monkeypatch.setattr(Path, "rename", deny_hermes) + monkeypatch.setattr(cli_main, "_hermes_exe_shims", lambda d: shims) + + install_ran = [] + monkeypatch.setattr( + cli_main, "_run_install_with_heartbeat", + lambda cmd, env=None: install_ran.append(cmd), + ) + + with pytest.raises(cli_main.ShimQuarantineError) as exc_info: + cli_main._run_quarantined_install( + ["uv", "pip", "install", "-e", "."], + scripts_dir=scripts, + strict_quarantine=True, + ) + + # The installer NEVER ran — that is the whole fix. + assert install_ran == [] + assert "hermes.exe" in exc_info.value.failed_shims + # The successful rename (hermes-gateway.exe) was rolled back. + assert (scripts / "hermes-gateway.exe").exists() + assert not list(scripts.glob("hermes-gateway.exe.old.*")) + + +def test_non_strict_keeps_warn_and_try(windows, tmp_path, monkeypatch): + scripts = tmp_path / "venv" / "Scripts" + shims = _make_shims(scripts, names=("hermes",)) + monkeypatch.setattr( + Path, "rename", + mock.Mock(side_effect=PermissionError(13, "held open")), + ) + monkeypatch.setattr(cli_main, "_hermes_exe_shims", lambda d: shims) + + install_ran = [] + monkeypatch.setattr( + cli_main, "_run_install_with_heartbeat", + lambda cmd, env=None: install_ran.append(cmd), + ) + + # Default (non-strict): installer still runs — old behavior for repair + # paths whose venv is already mutated. + cli_main._run_quarantined_install(["fake"], scripts_dir=scripts) + assert install_ran == [["fake"]] + + +def test_strict_all_renames_ok_runs_install(windows, tmp_path, monkeypatch): + scripts = tmp_path / "venv" / "Scripts" + shims = _make_shims(scripts) + monkeypatch.setattr(cli_main, "_hermes_exe_shims", lambda d: shims) + + install_ran = [] + monkeypatch.setattr( + cli_main, "_run_install_with_heartbeat", + lambda cmd, env=None: install_ran.append(cmd), + ) + cli_main._run_quarantined_install( + ["fake"], scripts_dir=scripts, strict_quarantine=True + ) + assert install_ran == [["fake"]] + + +def test_update_sync_installs_are_strict(windows, tmp_path, monkeypatch): + """_install_python_dependencies_with_optional_fallback must pass + strict_quarantine=True — the #87331 site.""" + seen = {} + + def spy(cmd, *, env=None, scripts_dir=None, strict_quarantine=False): + seen["strict"] = strict_quarantine + + monkeypatch.setattr(cli_main, "_run_quarantined_install", spy) + monkeypatch.setattr(cli_main, "_venv_scripts_dir", lambda: tmp_path) + monkeypatch.setattr( + cli_main, "_verify_console_scripts_installed", + lambda prefix, env=None: None, + ) + monkeypatch.setattr( + cli_main, "_verify_core_dependencies_installed", + lambda prefix, env=None, group="all": None, + ) + cli_main._install_python_dependencies_with_optional_fallback(["uv", "pip"]) + assert seen["strict"] is True + + +# --------------------------------------------------------------------------- +# _install_repair.py: recovery installer is strict unconditionally +# --------------------------------------------------------------------------- + +def test_recovery_install_cmd_fail_closed(windows, tmp_path, monkeypatch): + root = tmp_path + scripts = root / "venv" / "Scripts" + _make_shims(scripts, names=("hermes",)) + + monkeypatch.setattr(ir, "_venv_scripts_dir", lambda r: scripts) + monkeypatch.setattr( + Path.__module__ and os, "rename", + mock.Mock(side_effect=PermissionError(13, "held open")), + ) + + run_calls = [] + monkeypatch.setattr( + ir.subprocess, "run", lambda *a, **k: run_calls.append(a) + ) + + with pytest.raises(ir.ShimQuarantineError): + ir._run_install_cmd(["fake"], env=None, root=root) + assert run_calls == [] # contended venv never mutated + + +def test_recovery_install_cmd_ok_when_uncontended(windows, tmp_path, monkeypatch): + root = tmp_path + scripts = root / "venv" / "Scripts" + _make_shims(scripts, names=("hermes",)) + monkeypatch.setattr(ir, "_venv_scripts_dir", lambda r: scripts) + + run_calls = [] + monkeypatch.setattr( + ir.subprocess, "run", + lambda *a, **k: run_calls.append(a) or mock.Mock(returncode=0), + ) + ir._run_install_cmd(["fake"], env=None, root=root) + assert len(run_calls) == 1 + + +# --------------------------------------------------------------------------- +# update_cmd.py: boundary refusal — marker + exit 2, no ZIP fallback +# --------------------------------------------------------------------------- + +def test_refusal_writes_marker_and_exits_2(monkeypatch, capsys): + wrote = [] + monkeypatch.setattr( + update_cmd, "_write_update_incomplete_marker", lambda: wrote.append(1) + ) + exc = cli_main.ShimQuarantineError(["hermes.exe"]) + with pytest.raises(SystemExit) as exit_info: + update_cmd._refuse_update_for_contended_shims(exc) + assert exit_info.value.code == 2 + assert wrote == [1] + out = capsys.readouterr().out + assert "hermes.exe" in out + assert "deferred" in out + + +def test_shim_error_type_resolves_real_class(): + assert update_cmd._shim_quarantine_error_type() is cli_main.ShimQuarantineError + + +def test_shim_error_is_not_a_zip_fallback_trigger(): + exc = cli_main.ShimQuarantineError(["hermes.exe"]) + assert update_cmd._should_zip_fallback_on_update_error(exc) is False From 0c435f4601c009eee91357cd9f46557dfb10f127 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:50:41 -0700 Subject: [PATCH 022/666] =?UTF-8?q?fix(update):=20reword=20refusal=20messa?= =?UTF-8?q?ge=20=E2=80=94=20footgun=20linter=20matched=20prose=20'venv=20o?= =?UTF-8?q?pen=20('=20as=20bare=20open()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hermes_cli/update_cmd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 2509e207eb0b..2676cd9e509b 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -1247,8 +1247,8 @@ def _refuse_update_for_contended_shims(exc: BaseException) -> None: print(" moved aside:") for name in getattr(exc, "failed_shims", []) or ["hermes.exe"]: print(f" {name}") - print(" Another process is holding this install's venv open (Hermes") - print(" Desktop, a gateway, or another hermes REPL) — mutating the venv") + print(" Another process is holding this install's venv — typically Hermes") + print(" Desktop, a gateway, or another hermes REPL — and mutating the venv") print(" now would strand it half-updated.") print(" The dependency install has been deferred: close the process(es)") print(" above, then run any `hermes` command to finish it automatically.") From fd760435c6688a2b6c6b7436dde30e267237baef Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:06:45 -0700 Subject: [PATCH 023/666] =?UTF-8?q?test(bedrock):=20make=20the=20botocore?= =?UTF-8?q?=20stub=20windows=20airtight=20=E2=80=94=20kills=20the=20vendor?= =?UTF-8?q?ed-import=20flake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI flake mechanism (PR #92617 red, reproduced standalone): tests plant fake botocore modules via patch.dict; when the REAL botocore.exceptions is first imported in an interpreter state where a fake parent is (or was) installed, its 'from botocore.vendored import requests' resolves against a module with no __path__ and every exception test in the worker dies with "No module named 'botocore.vendored'" — ordering-dependent, so green locally, red in CI workers. Defenses (both, in depth): - test_bedrock_adapter.py pre-imports the real botocore.exceptions at module scope, before any test can stub sys.modules — later imports are cache hits that can never re-execute the vendored import under a poisoned parent. Proven standalone: fake-parent repro fails without the pre-import, succeeds with it. - autouse _boto_sys_modules_hygiene fixtures in all three files that plant fake boto* modules (adapter, integration, model-picker): snapshot every boto* sys.modules entry before each test, evict+restore after — no stub window can leak state into a later test regardless of worker ordering. - importorskip targets botocore.exceptions (the module the tests actually need) instead of bare botocore, so a torn install skips instead of erroring. 148/148 across the four affected suites. --- tests/agent/test_bedrock_adapter.py | 61 +++++++++++++++++-- tests/agent/test_bedrock_integration.py | 27 ++++++++ tests/hermes_cli/test_bedrock_model_picker.py | 28 +++++++++ tests/run_agent/test_streaming.py | 8 +-- 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/tests/agent/test_bedrock_adapter.py b/tests/agent/test_bedrock_adapter.py index e6c2c3c3c488..ff6a8b1329de 100644 --- a/tests/agent/test_bedrock_adapter.py +++ b/tests/agent/test_bedrock_adapter.py @@ -16,6 +16,57 @@ import pytest +# --------------------------------------------------------------------------- +# botocore import hygiene (anti-flake, Aug 2026) +# +# Several tests in this file plant fake ``botocore`` modules in sys.modules +# (to run without the real package / without touching the AWS credential +# chain). The real package's ``botocore.exceptions`` lazily executes +# ``from botocore.vendored import requests`` on FIRST import — if that first +# import happens while a fake parent is (or was) installed, the chain +# resolves against a module with no real __path__ and the whole file's +# exception tests die with ``No module named 'botocore.vendored'`` — but +# only in interpreter states where nothing imported it earlier (the exact +# CI-vs-local flake on PR #92617). +# +# Two defenses, both required: +# 1. Import the real exception types HERE, at module scope, before any +# test can stub sys.modules. Once cached, later ``from +# botocore.exceptions import X`` is a dict hit and can never +# re-execute the vendored import under a poisoned parent. +# 2. An autouse fixture snapshots every boto* sys.modules entry before +# each test and restores it after, so no stub window can leak state +# into a later test regardless of ordering. +# --------------------------------------------------------------------------- + +try: # pragma: no cover - exercised implicitly by every exception test + from botocore.exceptions import ( # noqa: F401 + ClientError as _RealClientError, + ConnectionClosedError as _RealConnectionClosedError, + ) +except Exception: # botocore genuinely not installed / torn — tests skip + _RealClientError = _RealConnectionClosedError = None + +_BOTO_PREFIXES = ("botocore", "boto3") + + +@pytest.fixture(autouse=True) +def _boto_sys_modules_hygiene(): + """Restore every boto* sys.modules entry after each test (see above).""" + import sys as _sys + + saved = { + name: mod + for name, mod in _sys.modules.items() + if name.split(".", 1)[0] in _BOTO_PREFIXES + } + yield + for name in [ + n for n in _sys.modules if n.split(".", 1)[0] in _BOTO_PREFIXES + ]: + _sys.modules.pop(name, None) + _sys.modules.update(saved) + @contextmanager def _mock_botocore_session(*, return_value=None, side_effect=None): @@ -923,7 +974,7 @@ class TestIsStaleConnectionError: def test_detects_botocore_read_timeout(self): - pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests") + pytest.importorskip("botocore.exceptions", reason="botocore (with working exceptions module) required") from agent.bedrock_adapter import is_stale_connection_error from botocore.exceptions import ReadTimeoutError exc = ReadTimeoutError(endpoint_url="https://bedrock.example") @@ -962,7 +1013,7 @@ class TestCallConverseInvalidatesOnStaleError: def test_converse_stream_evicts_client_on_stale_error(self): - pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests") + pytest.importorskip("botocore.exceptions", reason="botocore (with working exceptions module) required") from agent.bedrock_adapter import ( _bedrock_runtime_client_cache, call_converse_stream, @@ -988,7 +1039,7 @@ def test_converse_stream_evicts_client_on_stale_error(self): def test_converse_does_not_evict_on_non_stale_error(self): """Non-stale errors (e.g. ValidationException) leave the client cache alone.""" - pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests") + pytest.importorskip("botocore.exceptions", reason="botocore (with working exceptions module) required") from agent.bedrock_adapter import ( _bedrock_runtime_client_cache, call_converse, @@ -1040,7 +1091,7 @@ def _denied_client_error(self): ) def test_matches_access_denied_client_error(self): - pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests") + pytest.importorskip("botocore.exceptions", reason="botocore (with working exceptions module) required") from agent.bedrock_adapter import is_streaming_access_denied_error assert is_streaming_access_denied_error(self._denied_client_error()) is True @@ -1060,7 +1111,7 @@ class TestCallConverseStreamIamFallback: streaming action — InvokeModel-only policies keep working.""" def test_falls_back_to_converse_on_streaming_denial(self): - pytest.importorskip("botocore", reason="botocore required for Bedrock exception tests") + pytest.importorskip("botocore.exceptions", reason="botocore (with working exceptions module) required") from agent.bedrock_adapter import ( _bedrock_runtime_client_cache, call_converse_stream, diff --git a/tests/agent/test_bedrock_integration.py b/tests/agent/test_bedrock_integration.py index c8fc15d3b787..bd9da1cfe0bd 100644 --- a/tests/agent/test_bedrock_integration.py +++ b/tests/agent/test_bedrock_integration.py @@ -14,6 +14,33 @@ import pytest +_BOTO_PREFIXES = ("botocore", "boto3") + + +@pytest.fixture(autouse=True) +def _boto_sys_modules_hygiene(): + """Snapshot/restore boto* sys.modules around every test. + + Tests here plant fake botocore/boto3 modules; a fake that leaks (or a + real submodule first-imported inside a stub window) poisons later + imports of the real ``botocore.exceptions`` with + ``No module named 'botocore.vendored'`` (PR #92617 CI flake). This + fixture makes stub windows airtight regardless of test ordering. + """ + import sys as _sys + + saved = { + name: mod + for name, mod in _sys.modules.items() + if name.split(".", 1)[0] in _BOTO_PREFIXES + } + yield + for name in [n for n in _sys.modules if n.split(".", 1)[0] in _BOTO_PREFIXES]: + _sys.modules.pop(name, None) + _sys.modules.update(saved) + + + class TestProviderRegistry: """Verify Bedrock is registered in PROVIDER_REGISTRY.""" diff --git a/tests/hermes_cli/test_bedrock_model_picker.py b/tests/hermes_cli/test_bedrock_model_picker.py index 8022ea9ec6ba..f20d6e929741 100644 --- a/tests/hermes_cli/test_bedrock_model_picker.py +++ b/tests/hermes_cli/test_bedrock_model_picker.py @@ -20,6 +20,34 @@ from types import ModuleType from unittest.mock import MagicMock, patch +import pytest + +_BOTO_PREFIXES = ("botocore", "boto3") + + +@pytest.fixture(autouse=True) +def _boto_sys_modules_hygiene(): + """Snapshot/restore boto* sys.modules around every test. + + Tests here plant fake botocore/boto3 modules; a fake that leaks (or a + real submodule first-imported inside a stub window) poisons later + imports of the real ``botocore.exceptions`` with + ``No module named 'botocore.vendored'`` (PR #92617 CI flake). This + fixture makes stub windows airtight regardless of test ordering. + """ + import sys as _sys + + saved = { + name: mod + for name, mod in _sys.modules.items() + if name.split(".", 1)[0] in _BOTO_PREFIXES + } + yield + for name in [n for n in _sys.modules if n.split(".", 1)[0] in _BOTO_PREFIXES]: + _sys.modules.pop(name, None) + _sys.modules.update(saved) + + from agent.bedrock_adapter import BEDROCK_OPENAI_RESPONSES_MODEL_IDS _MANTLE_MODELS = list(BEDROCK_OPENAI_RESPONSES_MODEL_IDS) diff --git a/tests/run_agent/test_streaming.py b/tests/run_agent/test_streaming.py index b971f9ec82a2..da6014320d9a 100644 --- a/tests/run_agent/test_streaming.py +++ b/tests/run_agent/test_streaming.py @@ -1490,7 +1490,7 @@ def _make_bedrock_agent(self): return agent def test_iam_denial_falls_back_inline_and_disables_streaming(self): - pytest.importorskip("botocore", reason="botocore required for Bedrock tests") + pytest.importorskip("botocore.exceptions", reason="botocore (with working exceptions module) required") from botocore.exceptions import ClientError agent = self._make_bedrock_agent() @@ -1614,7 +1614,7 @@ def test_stalled_stream_bumps_streak_and_aborts(self, monkeypatch): """A Bedrock stream that opens then stops yielding events trips the watchdog: it bumps the cross-turn stale streak and raises TimeoutError instead of hanging forever.""" - pytest.importorskip("botocore", reason="botocore required for Bedrock tests") + pytest.importorskip("botocore.exceptions", reason="botocore (with working exceptions module) required") import threading as _t # Tiny stale timeout so the watchdog trips quickly; give-up threshold @@ -1647,7 +1647,7 @@ def test_stalled_stream_bumps_streak_and_aborts(self, monkeypatch): def test_pre_elevated_streak_aborts_before_streaming(self, monkeypatch): """A streak already past the give-up threshold aborts at entry with RuntimeError — Bedrock never even opens a stream (cross-turn breaker).""" - pytest.importorskip("botocore", reason="botocore required for Bedrock tests") + pytest.importorskip("botocore.exceptions", reason="botocore (with working exceptions module) required") monkeypatch.setenv("HERMES_STREAM_STALE_GIVEUP", "5") @@ -1669,7 +1669,7 @@ def test_pre_elevated_streak_aborts_before_streaming(self, monkeypatch): def test_successful_stream_resets_streak(self, monkeypatch): """A Bedrock stream that completes normally clears any prior stale streak so a recovered provider doesn't carry it into later turns.""" - pytest.importorskip("botocore", reason="botocore required for Bedrock tests") + pytest.importorskip("botocore.exceptions", reason="botocore (with working exceptions module) required") monkeypatch.setenv("HERMES_STREAM_STALE_TIMEOUT", "60") From 0b01599eefcb2ec8d464c8c7b2b11289bb37c207 Mon Sep 17 00:00:00 2001 From: emozilla Date: Sat, 22 Aug 2026 23:10:32 -0400 Subject: [PATCH 024/666] fix(tests): repair the two Linux-lane CI failures on this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both failed only in the full Linux suite, which the targeted local battery never ran: - test_update_zip_two_phase.py's AST guard (#76105) flags any code literal "Scripts" in hermes_cli as an open-coded venv layout. migrate_windows_bin_path's legacy PATH key now derives it via venv_bin_dir(root / "venv", windows=True) — same value, canonical helper. The literal `venv` component stays: the key must match what the pre-#83797 installer wrote to the registry, not where the venv lives now. - The managed-bin marker tests built expected PATH entries from tmp_path, so on a POSIX host they compared forward-slash strings against the backslash markers and could never match. Markers match Windows registry PATH entries, so the tests now feed Windows-shaped literals — host-independent, same contract. --- hermes_cli/_install_repair.py | 8 ++++-- .../test_uninstall_windows_bin_launchers.py | 28 +++++++++++-------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/hermes_cli/_install_repair.py b/hermes_cli/_install_repair.py index e8f520ef1298..3949b3c41b36 100644 --- a/hermes_cli/_install_repair.py +++ b/hermes_cli/_install_repair.py @@ -395,7 +395,7 @@ def migrate_windows_bin_path( root = Path(root) # Same per-machine anchor as ensure_windows_bin_launchers (see there). - from hermes_constants import get_default_hermes_root + from hermes_constants import get_default_hermes_root, venv_bin_dir try: home = Path(get_default_hermes_root()) @@ -425,7 +425,11 @@ def migrate_windows_bin_path( legacy_keys = { _normalize_windows_path(root / "bin"), - _normalize_windows_path(root / "venv" / "Scripts"), + # The pre-#83797 installer put the venv's Scripts dir itself on PATH, + # always at the literal `venv` layout (never `.venv`) — this strips + # that stale entry, so it must match what the installer wrote then, + # not where the venv lives now. + _normalize_windows_path(venv_bin_dir(root / "venv", windows=True)), } home_bin_key = _normalize_windows_path(home_bin) diff --git a/tests/hermes_cli/test_uninstall_windows_bin_launchers.py b/tests/hermes_cli/test_uninstall_windows_bin_launchers.py index fc2eba3aafa0..179f92fec5e8 100644 --- a/tests/hermes_cli/test_uninstall_windows_bin_launchers.py +++ b/tests/hermes_cli/test_uninstall_windows_bin_launchers.py @@ -90,21 +90,27 @@ def test_launcher_names_stay_in_lockstep_with_install_ps1(): class TestManagedBinPathMarker: - """The managed ``bin`` PATH entry goes only when the dir itself goes.""" + """The managed ``bin`` PATH entry goes only when the dir itself goes. - def test_keep_data_markers_spare_the_managed_bin(self, tmp_path: Path): - home = tmp_path / "hermes" - markers = [m.lower() for m in uninstall._hermes_path_markers(home)] + Markers match against Windows registry PATH entries, so the inputs here + are Windows-shaped path strings regardless of the host — feeding + ``tmp_path`` would make the test pass only on Windows hosts. + """ - bin_key = str(home / "bin").lower() - assert not any(bin_key.startswith(m) for m in markers) + HOME = r"C:\Users\me\AppData\Local\hermes" + BIN_ENTRY = r"C:\Users\me\AppData\Local\hermes\bin" - def test_full_wipe_markers_take_the_managed_bin(self, tmp_path: Path): - home = tmp_path / "hermes" + def test_keep_data_markers_spare_the_managed_bin(self): + markers = [m.lower() for m in uninstall._hermes_path_markers(Path(self.HOME))] + + assert not any(self.BIN_ENTRY.lower().startswith(m) for m in markers) + + def test_full_wipe_markers_take_the_managed_bin(self): markers = [ m.lower() - for m in uninstall._hermes_path_markers(home, include_managed_bin=True) + for m in uninstall._hermes_path_markers( + Path(self.HOME), include_managed_bin=True + ) ] - bin_key = str(home / "bin").lower() - assert any(bin_key.startswith(m) for m in markers) + assert any(self.BIN_ENTRY.lower().startswith(m) for m in markers) From 87b645f52ccc807df6304bcf1a1d4daa7e53f2ed Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:11:01 -0700 Subject: [PATCH 025/666] fix(desktop): a failed Bot Chat registry lookup no longer forks the bot's forever chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findExistingCanonicalChat() swallowed every lookup error and returned null — indistinguishable from 'this bot has no Bot Chat yet' — so a transient RPC failure against a just-restarted backend (the exact post-desktop-update window) sent createCanonicalChat() straight to session.create, minting a fresh 'Bot Chat' while the real one (data intact, hidden) still held the canonical title. Users experienced this as bots losing all context after every desktop update. The lookup now fails CLOSED: a failed registry consultation throws, both open paths surface their existing 'try again' toast, and session.create can never fire off an unknown ownership state. Tests: two new VM-executed regression tests (sabotage-verified — both fail with the old fail-open catch); hide-bot-chats source-shape regex updated for the new layout. 364/364 plugin tests green. --- .../desktop/src/plugins/hermes-bots/plugin.js | 23 +++++++-- .../tests/canonical-chat-registry.test.mjs | 47 +++++++++++++++++++ .../hermes-bots/tests/hide-bot-chats.test.mjs | 2 +- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index eed110e190e5..c332c44ae75c 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -4096,18 +4096,31 @@ function isCanonicalBotChatHistory(history) { * (window-free; a busy profile can push the forever-chat past any recency * window). include_hidden is required (canonical chats are always hidden). */ async function findExistingCanonicalChat(name) { + // FAIL CLOSED. A failed registry lookup MUST NOT read as "no Bot Chat + // exists" — that is the one remaining way to fork a bot's forever chat. + // The failure lives exactly in the post-update window: the desktop + // restarts every profile backend, the first bot click races the warm-up, + // the lookup RPC fails transiently, and a swallowed error here sent + // createCanonicalChat() straight to session.create — minting a fresh + // "Bot Chat" while the real one (data intact, hidden) still held the + // canonical title. Users read that as "my bot lost all context after the + // update". Both open paths catch and toast "try again", which is the + // correct outcome for a transient lookup failure: retry, never mint. + let res try { - const res = await host.request('session.list', { + res = await host.request('session.list', { profile: name, title: CANONICAL_CHAT_TITLE, limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true }) - const rows = res?.sessions ?? [] - return rows.find(row => isCanonicalBotChatHistory(row)) || null - } catch { - return null + } catch (error) { + const detail = error instanceof Error && error.message ? ` (${error.message})` : '' + throw new Error(`Could not check ${name}'s Bot Chat registry${detail} — not starting a new chat`) } + + const rows = res?.sessions ?? [] + return rows.find(row => isCanonicalBotChatHistory(row)) || null } /** Create the bot's ONE forever chat: a real session titled "Bot Chat", diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs index 4901439cc27f..f5d242ae4ecd 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs @@ -175,3 +175,50 @@ test('an ordinary titled session never satisfies the registry lookup', async () 'no row titled "Bot Chat" → create; never adopt an ordinary conversation') assert.ok(!runtime.opened.some(o => o.id === 'scratch')) }) + +// ── 4. a failed lookup fails CLOSED — never "no chat exists" ──────────────── +// +// The post-update window: the desktop restarts every profile backend, the +// first bot click races the warm-up, and the registry lookup RPC fails +// transiently. Swallowing that error and returning null made the failure +// indistinguishable from "this bot has no Bot Chat yet", so the create path +// minted a fresh forever-chat while the real one (data intact, hidden) still +// held the canonical title — read by users as "my bot lost everything after +// the update". A lookup failure must surface (the open paths toast +// "try again"), never resolve to mint. + +test('a failed registry lookup rejects instead of minting a replacement chat', async () => { + const runtime = loadOpenPath({ + request: async method => { + if (method === 'session.list') { + throw new Error('gateway not ready') + } + if (method === 'session.create') { + throw new Error('must not create: a failed lookup is not "no chat exists"') + } + return {} + } + }) + + await assert.rejects(() => runtime.openBotCanonicalChat('ops'), /Bot Chat registry/) + assert.ok(!runtime.requests.some(r => r.method === 'session.create'), + 'session.create must never fire off a failed lookup') + assert.equal(runtime.opened.length, 0) +}) + +test('createCanonicalChat also refuses to mint when the adoption lookup fails', async () => { + const runtime = loadOpenPath({ + request: async method => { + if (method === 'session.list') { + throw new Error('backend warming up') + } + if (method === 'session.create') { + throw new Error('must not create: adoption check failed, ownership is unknown') + } + return {} + } + }) + + await assert.rejects(() => runtime.createCanonicalChat('ops'), /Bot Chat registry/) + assert.ok(!runtime.requests.some(r => r.method === 'session.create')) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs index 7a7df457e7b9..8d973a82c008 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs @@ -157,5 +157,5 @@ test('the canonical-chat adoption scan lists with include_hidden', () => { // findExistingCanonicalChat (the registry lookup) — canonical Bot Chats // are born hidden, so a visible-only scan would miss the very row that IS // the bot's identity. - assert.match(source, /include_hidden: true\s*\}\)\s*const rows = res\?\.sessions \?\? \[\]\s*return rows\.find\(row => isCanonicalBotChatHistory\(row\)\)/) + assert.match(source, /include_hidden: true\s*\}\)\s*\} catch \(error\) \{[\s\S]{0,400}?const rows = res\?\.sessions \?\? \[\]\s*return rows\.find\(row => isCanonicalBotChatHistory\(row\)\)/) }) From 4ccc4b69319879faf568b38aea140ea443e0e8de Mon Sep 17 00:00:00 2001 From: 686f6c61 Date: Sat, 22 Aug 2026 09:38:58 +0200 Subject: [PATCH 026/666] fix(update): skip Windows gateway cold-start when Desktop owns lifecycle Vestigial autostart is not proof the user wants a standalone gateway run. When Desktop currently supervises this install's control plane, the updater must not spawn a competing messaging daemon. Serve is not treated as gateway-equivalent. --- hermes_cli/main.py | 1 + hermes_cli/update_cmd.py | 95 ++++++++++++++++ ...ws_gateway_cold_start_desktop_lifecycle.py | 101 ++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ea289aa5d9f3..3d6242f8078a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -4893,6 +4893,7 @@ def _remove_custom_provider(config): "_dependency_sync_would_rewrite", "_detect_self_loaded_native_modules", "_detect_venv_python_processes", + "_desktop_owns_gateway_lifecycle", "_defer_update_for_self_lock", "_discard_lockfile_churn", "_discard_stashed_changes", diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 475eee5afd74..836aba3818fb 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -4674,6 +4674,71 @@ def _stop_process_trees(pids: list[int]) -> None: logger.debug("Could not stop process tree %s: %s", pid, exc) +def _looks_like_desktop_control_plane(cmdline: str) -> bool: + """True for this-install ``hermes serve`` / ``hermes dashboard`` argv. + + That is the Desktop control plane, not the messaging gateway. Serve and + dashboard do not host platform adapters (#92091); do not feed this into + ``looks_like_gateway_command_line``. + """ + low = (cmdline or "").lower() + return "hermes_cli.main" in low and (" serve" in low or " dashboard" in low) + + +def _desktop_owns_gateway_lifecycle() -> bool: + """True when Desktop currently supervises this install's control plane. + + The updater must not steal gateway start in that case: Desktop owns + start/stop via ``/api/gateway/*``. This is *not* proof messaging is + already served — a live serve process is the control plane, and the + gateway is a detached sibling (#76129 / #92091). + + Prefer the spawn ledger (owned identity). Fall back to the install-scoped + venv-holder scan already used by the lock guard; an orphaned control-plane + process (supervisor gone) does not count. + """ + try: + from hermes_cli.process_identity import ledger_entries, spawner_is_dead + + for entry in ledger_entries(): + if entry.get("purpose") not in ("serve", "dashboard"): + continue + if spawner_is_dead(entry) is False: + return True + except Exception as exc: + logger.debug("Desktop-lifecycle ledger probe failed: %s", exc) + + try: + import psutil + except Exception: + psutil = None + + try: + holders = _m()._detect_venv_python_processes() + except Exception as exc: + logger.debug("Desktop-lifecycle holder scan failed: %s", exc) + return False + + for pid, _name, cmdline in holders: + if not _looks_like_desktop_control_plane(cmdline): + continue + if psutil is None: + # Cannot prove orphanhood; a live this-install control plane is + # enough to refuse stealing gateway start. + return True + try: + proc = psutil.Process(int(pid)) + parent = proc.parent() + if parent is None or not parent.is_running(): + continue + if parent.create_time() > proc.create_time(): + continue + return True + except Exception: + continue + return False + + def _pause_windows_gateways_for_update() -> dict | None: """Stop running Windows gateways before mutating the checkout or venv. @@ -4712,6 +4777,24 @@ def _pause_windows_gateways_for_update() -> dict | None: # gateways that were running when the update began. Cold-start one after # the update so an installed gateway is actually up post-update. Users # who run gateway-less (no autostart entry) get nothing forced on them. + # + # Exception: Desktop currently owns this install's gateway lifecycle + # (live supervised serve/dashboard). A vestigial Startup/Scheduled + # Task is not the owner — spawning ``gateway run`` beside Desktop + # races ports/state (#76129). Serve is the control plane, not proof + # messaging is served; the skip is ownership, not liveness (#92091). + try: + if _desktop_owns_gateway_lifecycle(): + logger.debug( + "Skipping Windows gateway cold-start plan: " + "Desktop owns gateway lifecycle" + ) + return None + except Exception as exc: + logger.debug( + "Could not check Desktop gateway-lifecycle ownership before update: %s", + exc, + ) try: from hermes_cli import gateway_windows @@ -4864,6 +4947,18 @@ def _cold_start_windows_gateway_after_update() -> None: logger.debug("Could not re-check gateway liveness before cold-start: %s", exc) return + try: + if _desktop_owns_gateway_lifecycle(): + logger.debug( + "Skipping Windows gateway cold-start: Desktop owns gateway lifecycle" + ) + return + except Exception as exc: + logger.debug( + "Could not re-check Desktop gateway-lifecycle ownership before cold-start: %s", + exc, + ) + try: pid = gateway_windows._spawn_detached() except Exception as exc: diff --git a/tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py b/tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py new file mode 100644 index 000000000000..d3d129e094de --- /dev/null +++ b/tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py @@ -0,0 +1,101 @@ +"""#76129: post-update Windows cold-start must not steal Desktop-owned lifecycle. + +A vestigial Startup/Scheduled-Task autostart is not proof the user wants a +standalone ``gateway run``. When Desktop currently supervises this install's +control plane, the updater must not spawn a competing messaging daemon. + +Serve/dashboard are the control plane, not the messaging gateway (#92091). +``looks_like_gateway_command_line`` stays strict; ownership is a separate +predicate. +""" + +from __future__ import annotations + +from hermes_cli import gateway as hermes_gateway +from hermes_cli import gateway_windows +from hermes_cli import main as cli_main +from hermes_cli import process_identity +from hermes_cli import update_cmd + + +def _live_serve_ledger_entry() -> dict: + return { + "pid": 111, + "create_time": 1.0, + "purpose": "serve", + "install": "abc", + "spawner_pid": 99, + "spawner_create": 0.5, + } + + +def test_control_plane_argv_is_not_a_gateway(): + from gateway.status import looks_like_gateway_command_line + + serve = "C:\\Hermes\\.venv\\Scripts\\python.exe -m hermes_cli.main serve --host 127.0.0.1" + run = "C:\\Hermes\\.venv\\Scripts\\python.exe -m hermes_cli.main gateway run" + + assert update_cmd._looks_like_desktop_control_plane(serve) is True + assert looks_like_gateway_command_line(serve) is False + assert update_cmd._looks_like_desktop_control_plane(run) is False + assert looks_like_gateway_command_line(run) is True + + +def test_ledger_live_serve_with_live_spawner_owns_lifecycle(monkeypatch): + monkeypatch.setattr( + process_identity, "ledger_entries", lambda **_k: [_live_serve_ledger_entry()] + ) + monkeypatch.setattr(process_identity, "spawner_is_dead", lambda _e: False) + monkeypatch.setattr(cli_main, "_detect_venv_python_processes", lambda: []) + + assert update_cmd._desktop_owns_gateway_lifecycle() is True + + +def test_orphaned_control_plane_does_not_own_lifecycle(monkeypatch): + monkeypatch.setattr( + process_identity, "ledger_entries", lambda **_k: [_live_serve_ledger_entry()] + ) + monkeypatch.setattr(process_identity, "spawner_is_dead", lambda _e: True) + monkeypatch.setattr(cli_main, "_detect_venv_python_processes", lambda: []) + + assert update_cmd._desktop_owns_gateway_lifecycle() is False + + +def test_pause_skips_cold_start_plan_when_desktop_owns_lifecycle(monkeypatch): + monkeypatch.setattr(cli_main, "_is_windows", lambda: True) + monkeypatch.setattr(hermes_gateway, "find_gateway_pids", lambda **_k: []) + monkeypatch.setattr(gateway_windows, "is_installed", lambda: True) + monkeypatch.setattr(update_cmd, "_desktop_owns_gateway_lifecycle", lambda: True) + + assert update_cmd._pause_windows_gateways_for_update() is None + + +def test_pause_still_cold_starts_when_autostart_and_no_desktop_owner(monkeypatch): + monkeypatch.setattr(cli_main, "_is_windows", lambda: True) + monkeypatch.setattr(hermes_gateway, "find_gateway_pids", lambda **_k: []) + monkeypatch.setattr(gateway_windows, "is_installed", lambda: True) + monkeypatch.setattr(update_cmd, "_desktop_owns_gateway_lifecycle", lambda: False) + + token = update_cmd._pause_windows_gateways_for_update() + + assert token == { + "resume_needed": True, + "profiles": {}, + "unmapped_pids": [], + "unmapped": [], + "cold_start_if_installed": True, + } + + +def test_cold_start_aborts_when_desktop_owns_lifecycle(monkeypatch): + spawned = [] + monkeypatch.setattr(cli_main, "_is_windows", lambda: True) + monkeypatch.setattr(hermes_gateway, "find_gateway_pids", lambda **_k: []) + monkeypatch.setattr(update_cmd, "_desktop_owns_gateway_lifecycle", lambda: True) + monkeypatch.setattr( + gateway_windows, "_spawn_detached", lambda: spawned.append(1) or 4242 + ) + + update_cmd._cold_start_windows_gateway_after_update() + + assert spawned == [] From f4067774aa45f1d86bdc49f5319d7eeb080ca7be Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:19:24 -0700 Subject: [PATCH 027/666] fix(update): token-based control-plane classifier + live E2E for the Desktop-lifecycle cold-start skip (#76129 salvage follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On top of @686f6c61's premise-corrected #76745: - _looks_like_desktop_control_plane now uses the parser-derived _hermes_holder_subcommand instead of substring matching — the #90778/#91869 class ('-m dashboard chat' and 'kanban --preserve-cache' argv no longer read as control planes). Regression test added, sabotage-verified (reverting to substrings fails it). - Live E2E (this host, real processes + real spawn ledger): live supervised serve owns lifecycle; killed spawner (orphan) does not; dead serve entry excluded; empty ledger does not. - Live Windows E2E for the wine2e lane: real self-registered ledger entry suppresses the actual cold-start plan; dead serve restores it; holder-scan fallback rung proves the token classifier live. Co-authored-by: 686f6c61 --- hermes_cli/update_cmd.py | 11 +- .../test_desktop_lifecycle_windows_live.py | 144 ++++++++++++++++++ ...ws_gateway_cold_start_desktop_lifecycle.py | 23 +++ 3 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_desktop_lifecycle_windows_live.py diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 836aba3818fb..62d94198c69b 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -4680,9 +4680,16 @@ def _looks_like_desktop_control_plane(cmdline: str) -> bool: That is the Desktop control plane, not the messaging gateway. Serve and dashboard do not host platform adapters (#92091); do not feed this into ``looks_like_gateway_command_line``. + + Token-based via the parser-derived subcommand classifier — never + substring (#90778/#91869: ``kanban --preserve-cache`` contains "serve", + ``-m dashboard chat`` contains " dashboard"; both are NOT control + planes). A cmdline whose subcommand cannot be determined is NOT a + control plane — callers must not guess ownership. """ - low = (cmdline or "").lower() - return "hermes_cli.main" in low and (" serve" in low or " dashboard" in low) + if "hermes_cli.main" not in (cmdline or "").lower(): + return False + return _hermes_holder_subcommand(cmdline) in ("serve", "dashboard") def _desktop_owns_gateway_lifecycle() -> bool: diff --git a/tests/hermes_cli/test_desktop_lifecycle_windows_live.py b/tests/hermes_cli/test_desktop_lifecycle_windows_live.py new file mode 100644 index 000000000000..4a462ea03d61 --- /dev/null +++ b/tests/hermes_cli/test_desktop_lifecycle_windows_live.py @@ -0,0 +1,144 @@ +"""LIVE Windows E2E for the Desktop-lifecycle cold-start skip (#76129/#76745). + +Runs ONLY on a real Windows host (the on-demand ``windows-venv-e2e.yml`` +lane). Exercises the REAL ownership predicate against REAL processes: + + 1. A real child process self-registers in the REAL spawn ledger as a + ``serve`` purpose with THIS process as its live spawner — ownership + must hold, and ``_pause_windows_gateways_for_update`` must return None + (no cold-start plan) even with an autostart artifact present. + 2. Kill the child (dead serve) — ownership drops, the pause plan carries + ``cold_start_if_installed`` again. + 3. Venv-holder fallback rung: a real venv-python process with true + ``serve`` argv is detected by the scan and, having a live parent, + confers ownership; the token classifier rejects a ``kanban + --preserve-cache`` lookalike (the #90778 class the salvage fixed). +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.skipif( + sys.platform != "win32", reason="live Windows lifecycle E2E" +) + +PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture() +def sleeper(): + procs: list[subprocess.Popen] = [] + + def _spawn(*tail: str) -> subprocess.Popen: + p = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(120)", *tail], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + procs.append(p) + time.sleep(0.5) + assert p.poll() is None + return p + + yield _spawn + for p in procs: + if p.poll() is None: + p.kill() + p.wait() + + +def _write_ledger(entries: list[dict]) -> None: + from hermes_cli import process_identity as pid_mod + + path = pid_mod._ledger_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(entries), encoding="utf-8") + + +def _entry(proc: subprocess.Popen, purpose: str = "serve") -> dict: + import psutil + + from hermes_cli import process_identity as pid_mod + + return { + "install": pid_mod.install_id(None), + "pid": proc.pid, + "create_time": psutil.Process(proc.pid).create_time(), + "purpose": purpose, + "spawner_pid": os.getpid(), + "spawner_create": psutil.Process(os.getpid()).create_time(), + } + + +def test_live_supervised_serve_suppresses_cold_start(sleeper, monkeypatch, tmp_path): + from hermes_cli import gateway as hermes_gateway + from hermes_cli import gateway_windows + from hermes_cli import update_cmd + + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir() + + serve = sleeper() + _write_ledger([_entry(serve)]) + + assert update_cmd._desktop_owns_gateway_lifecycle() is True + + # Autostart artifact present + no gateway running: WITHOUT ownership the + # pause phase would plan a cold start; WITH it, no plan. + monkeypatch.setattr(hermes_gateway, "find_gateway_pids", lambda **_k: []) + monkeypatch.setattr(gateway_windows, "is_installed", lambda: True) + assert update_cmd._pause_windows_gateways_for_update() is None + + # Dead serve → ownership drops → plan returns. + serve.kill() + serve.wait() + time.sleep(0.5) + assert update_cmd._desktop_owns_gateway_lifecycle() is False + token = update_cmd._pause_windows_gateways_for_update() + assert token is not None and token.get("cold_start_if_installed") is True + + +def test_holder_scan_fallback_respects_token_classifier(sleeper, monkeypatch, tmp_path): + from hermes_cli import update_cmd + + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir() + _write_ledger([]) # force the fallback rung + + # Real process whose argv carries genuine serve shape, visible to psutil. + serve_like = sleeper("-m", "hermes_cli.main", "serve") + # Lookalike from the #90778 class — must NOT confer ownership. + kanban_like = sleeper("-m", "hermes_cli.main", "kanban", "--preserve-cache") + + def fake_holders(): + import psutil + + out = [] + for p in (serve_like, kanban_like): + proc = psutil.Process(p.pid) + out.append((p.pid, proc.name(), " ".join(proc.cmdline()))) + return out + + monkeypatch.setattr( + "hermes_cli.main._detect_venv_python_processes", fake_holders + ) + + # serve-shaped holder with a live parent (us) → owns + assert update_cmd._desktop_owns_gateway_lifecycle() is True + + # Only the kanban lookalike left → classifier rejects → does not own + serve_like.kill() + serve_like.wait() + monkeypatch.setattr( + "hermes_cli.main._detect_venv_python_processes", + lambda: [fake_holders()[1]], + ) + assert update_cmd._desktop_owns_gateway_lifecycle() is False diff --git a/tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py b/tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py index d3d129e094de..196c54c13a15 100644 --- a/tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py +++ b/tests/hermes_cli/test_windows_gateway_cold_start_desktop_lifecycle.py @@ -41,6 +41,29 @@ def test_control_plane_argv_is_not_a_gateway(): assert looks_like_gateway_command_line(run) is True +def test_control_plane_classifier_is_token_based_not_substring(): + """#90778/#91869 class: flag values and lookalike tokens must not read + as a control plane. The salvage swapped the original substring check + for the parser-derived subcommand classifier.""" + py = "C:\\Hermes\\.venv\\Scripts\\python.exe -m hermes_cli.main" + # "dashboard" as a FLAG VALUE, real subcommand is chat + assert update_cmd._looks_like_desktop_control_plane(f"{py} -m dashboard chat") is False + # "--preserve-cache" contains "serve"; real subcommand is kanban + assert ( + update_cmd._looks_like_desktop_control_plane(f"{py} kanban --preserve-cache") + is False + ) + # profile selector before the real subcommand still classifies correctly + assert ( + update_cmd._looks_like_desktop_control_plane(f"{py} --profile serve dashboard") + is True + ) + # dashboard as the real subcommand + assert update_cmd._looks_like_desktop_control_plane(f"{py} dashboard") is True + # undeterminable subcommand → NOT a control plane (never guess ownership) + assert update_cmd._looks_like_desktop_control_plane("python.exe -c import time") is False + + def test_ledger_live_serve_with_live_spawner_owns_lifecycle(monkeypatch): monkeypatch.setattr( process_identity, "ledger_entries", lambda **_k: [_live_serve_ledger_entry()] From 4edb24276fb334a64ef310f29b13aaca9baeb9df Mon Sep 17 00:00:00 2001 From: chelsealong Date: Sun, 23 Aug 2026 03:09:22 +0000 Subject: [PATCH 028/666] fix(tools): don't pre-check keyless local backends in Tool Gateway checklist get_gateway_eligible_tools() classified a tool as "unconfigured" whenever it found no direct API-key credential, ignoring an explicit non-nous selection already stored in config (e.g. web.backend: searxng, a keyless self-hosted backend). Every unconfigured tool is pre-checked in the `hermes model` Tool Gateway checklist, so a single Enter during Nous model setup silently rewrote web.backend (and similarly browser.cloud_provider, tts/stt/image_gen.provider) to "nous" for users who had deliberately configured a keyless local backend. get_gateway_eligible_tools() now also resolves each tool's stored selection via the existing _selected_provider() helper and routes an explicit non-nous selection into a new explicit_configured bucket instead of unconfigured. prompt_enable_tool_gateway() never offers those tools, so they can no longer be pre-checked or accidentally overwritten. Fixes #92647 --- hermes_cli/nous_subscription.py | 39 ++++++++++++++++++--- tests/hermes_cli/test_nous_subscription.py | 40 ++++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index 3ef724b35031..a6fe82ac0ef1 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -1034,16 +1034,34 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]: _ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "stt", "browser") +# Config section + selection field for each gateway key, matching the +# field names ``apply_gateway_defaults`` writes and +# ``get_nous_subscription_features`` reads (web uses "backend", browser +# uses "cloud_provider", everything else uses "provider"). +_GATEWAY_SECTION_FIELDS = { + "web": ("web", "backend"), + "image_gen": ("image_gen", "provider"), + "video_gen": ("video_gen", "provider"), + "tts": ("tts", "provider"), + "stt": ("stt", "provider"), + "browser": ("browser", "cloud_provider"), +} + def get_gateway_eligible_tools( config: Optional[Dict[str, object]] = None, *, force_fresh: bool = False, -) -> tuple[list[str], list[str], list[str]]: - """Return (unconfigured, has_direct, already_managed) tool key lists. +) -> tuple[list[str], list[str], list[str], list[str]]: + """Return (unconfigured, has_direct, explicit_configured, already_managed) + tool key lists. - - unconfigured: tools with no direct credentials (easy switch) + - unconfigured: tools with no direct credentials and no explicit + non-nous selection (easy switch, safe to pre-check) - has_direct: tools where the user has their own API keys + - explicit_configured: tools with an explicit non-nous selection stored + (e.g. ``web.backend: searxng``), including keyless backends that would + otherwise look unconfigured - already_managed: tools already routed through the gateway All lists are empty when the user is not a paid Nous subscriber or @@ -1084,6 +1102,7 @@ def get_gateway_eligible_tools( unconfigured: list[str] = [] has_direct: list[str] = [] + explicit_configured: list[str] = [] already_managed: list[str] = [] for key in _ALL_GATEWAY_KEYS: # Only offer tools the user's entitlement actually covers. For a free @@ -1093,13 +1112,20 @@ def get_gateway_eligible_tools( MANAGED_FEATURE_COVERAGE_CATEGORY[key] ): continue + section_key, field = _GATEWAY_SECTION_FIELDS[key] + selected = _selected_provider(config.get(section_key), field) if opted_in.get(key): already_managed.append(key) + elif selected is not None and selected != "nous": + # An explicit non-nous selection (e.g. a keyless local backend + # like SearXNG or Camofox) is configured on purpose, even + # though it has no direct credentials to detect. + explicit_configured.append(key) elif direct.get(key): has_direct.append(key) else: unconfigured.append(key) - return unconfigured, has_direct, already_managed + return unconfigured, has_direct, explicit_configured, already_managed def apply_gateway_defaults( @@ -1193,7 +1219,10 @@ def prompt_enable_tool_gateway( Returns the set of tools that were enabled, or empty set if the user declined or no tools were eligible. """ - unconfigured, has_direct, already_managed = get_gateway_eligible_tools( + # explicit_configured tools (e.g. an explicit `web.backend: searxng`) are + # configured on purpose and are never offered here — same treatment as + # already_managed, just for a non-nous vendor. + unconfigured, has_direct, _explicit_configured, already_managed = get_gateway_eligible_tools( config, force_fresh=force_fresh, ) diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index db602ff42cc9..7a83150113cb 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -206,6 +206,46 @@ def test_prompt_enable_tool_gateway_pool_offers_covered_tools_only(monkeypatch): assert "free" in captured["title"].lower() and "pool" in captured["title"].lower() +def test_get_gateway_eligible_tools_treats_explicit_backend_as_configured(monkeypatch): + """A keyless local backend (e.g. searxng) has no credentials to detect, + but an explicit non-nous selection must still keep it out of + 'unconfigured' — regression for #92647, where it was pre-checked and a + single Enter during `hermes model` overwrote it to `web.backend: nous`. + """ + monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=True)) + monkeypatch.setattr( + ns, + "_get_gateway_direct_credentials", + lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False}, + ) + + config = {"model": {"provider": "nous"}, "web": {"backend": "searxng"}} + unconfigured, has_direct, explicit_configured, already_managed = ns.get_gateway_eligible_tools(config) + + assert "web" not in unconfigured + assert "web" not in has_direct + assert "web" in explicit_configured + assert "web" not in already_managed + + +def test_prompt_enable_tool_gateway_never_offers_explicit_backend(monkeypatch): + """The checklist itself must not list (let alone pre-check) a tool with + an explicit non-nous selection, so it can never be silently overwritten + by an accidental Enter.""" + monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=True)) + monkeypatch.setattr( + ns, + "_get_gateway_direct_credentials", + lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False}, + ) + captured = _capture_checklist(monkeypatch, selected_idx=[]) + + config = {"model": {"provider": "nous"}, "web": {"backend": "searxng"}} + ns.prompt_enable_tool_gateway(config) + + blob = " ".join(captured["items"]).lower() + assert "firecrawl" not in blob # web (searxng-configured) NOT offered + assert "image" in blob # other unconfigured tools still offered def test_apply_nous_managed_defaults_writes_video_gen_config(monkeypatch): From ce9ddd35e7922594ef6fc2a468bbf3b3b5765c86 Mon Sep 17 00:00:00 2001 From: chelsealong Date: Sun, 23 Aug 2026 03:15:43 +0000 Subject: [PATCH 029/666] fix(gateway): fix 3-tuple/4-tuple arity crash in get_gateway_eligible_tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The early "fail closed" returns (account fetch error, not entitled, non-nous provider) still returned 3-element tuples after the function's happy path and every caller moved to a 4-tuple, crashing prompt_enable_tool_gateway with ValueError for any logged-in Nous account that isn't paid or pool-entitled — the common case, hit unconditionally from `hermes model`. --- hermes_cli/nous_subscription.py | 6 +++--- tests/hermes_cli/test_nous_subscription.py | 23 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index a6fe82ac0ef1..115865ea8b5e 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -1073,9 +1073,9 @@ def get_gateway_eligible_tools( try: account_info = get_nous_portal_account_info(force_fresh=force_fresh) except Exception: - return [], [], [] + return [], [], [], [] if not (account_info and account_info.logged_in and account_info.tool_gateway_entitled): - return [], [], [] + return [], [], [], [] if config is None: config = load_config() or {} @@ -1083,7 +1083,7 @@ def get_gateway_eligible_tools( # Quick provider check without the heavy get_nous_subscription_features call model_cfg = config.get("model") if not isinstance(model_cfg, dict) or str(model_cfg.get("provider") or "").strip().lower() != "nous": - return [], [], [] + return [], [], [], [] direct = _get_gateway_direct_credentials() diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index 7a83150113cb..5ba44836d697 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -228,6 +228,29 @@ def test_get_gateway_eligible_tools_treats_explicit_backend_as_configured(monkey assert "web" not in already_managed +def test_get_gateway_eligible_tools_not_entitled_returns_four_empty_lists(monkeypatch): + """A logged-in Nous account with no paid access and no free tool pool + must fail closed with a 4-tuple, not a 3-tuple — regression for a crash + where the early 'not entitled' return still had the pre-refactor arity + while the happy path and every caller had moved to 4 values.""" + monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=False)) + + config = {"model": {"provider": "nous"}} + result = ns.get_gateway_eligible_tools(config) + + assert result == ([], [], [], []) + + +def test_prompt_enable_tool_gateway_not_entitled_does_not_crash(monkeypatch): + """The unconditional call site in model_setup_flows (no try/except) must + not raise when a Nous account is logged in but not entitled to the Tool + Gateway (i.e. an ordinary non-paid, non-pool account).""" + monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=False)) + + config = {"model": {"provider": "nous"}} + assert ns.prompt_enable_tool_gateway(config) == set() + + def test_prompt_enable_tool_gateway_never_offers_explicit_backend(monkeypatch): """The checklist itself must not list (let alone pre-check) a tool with an explicit non-nous selection, so it can never be silently overwritten From f93593d165f9150763b5d89a0194427736e1c40b Mon Sep 17 00:00:00 2001 From: chelsealong Date: Sun, 23 Aug 2026 04:01:03 +0000 Subject: [PATCH 030/666] test(gateway): cover browser-use as an explicit non-nous selection Addresses maintainer follow-up on PR #92665: confirm _selected_provider correctly excludes an explicit BYOK browser.cloud_provider: browser-use selection from get_gateway_eligible_tools' unconfigured/has_direct buckets, same protection already covered for web.backend: searxng. --- tests/hermes_cli/test_nous_subscription.py | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index 5ba44836d697..e71f11456339 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -228,6 +228,30 @@ def test_get_gateway_eligible_tools_treats_explicit_backend_as_configured(monkey assert "web" not in already_managed +def test_get_gateway_eligible_tools_treats_browser_use_selection_as_explicit(monkeypatch): + """An explicit BYOK `browser.cloud_provider: browser-use` selection must + land in explicit_configured, not unconfigured/has_direct — the same + protection as searxng above. This is distinct from the gateway's own + managed selection, which is always stored as `cloud_provider: nous` + (see apply_gateway_defaults); "browser-use" only appears here when the + user picked it directly, so it must never be treated as up for grabs. + """ + monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=True)) + monkeypatch.setattr( + ns, + "_get_gateway_direct_credentials", + lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": True}, + ) + + config = {"model": {"provider": "nous"}, "browser": {"cloud_provider": "browser-use"}} + unconfigured, has_direct, explicit_configured, already_managed = ns.get_gateway_eligible_tools(config) + + assert "browser" not in unconfigured + assert "browser" not in has_direct + assert "browser" in explicit_configured + assert "browser" not in already_managed + + def test_get_gateway_eligible_tools_not_entitled_returns_four_empty_lists(monkeypatch): """A logged-in Nous account with no paid access and no free tool pool must fail closed with a 4-tuple, not a 3-tuple — regression for a crash From 8b86097a62ad9d35c74a6a62e2cf233c5ceb038d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:22:08 -0700 Subject: [PATCH 031/666] fix(gateway): honor env-configured local backends and persist Tool Gateway declines Follow-ups on top of the salvaged #92665 commits, closing the two gaps called out in #92647: - SEARXNG_URL / CAMOFOX_URL now count as direct web/browser configuration in _get_gateway_direct_credentials(), so env-only keyless local setups are offered unchecked instead of pre-checked. - Submitting the checklist with unconfigured tools left unchecked records them in tool_gateway_declined_tools (new known root key) and stops pre-checking them on subsequent Nous model swaps; opting in later clears the decline. Cancel (Ctrl-C/ESC) records nothing. - has_direct labels mention SearXNG/Camofox when that's what's detected. - Docs: tool-gateway.md gains an enablement-checklist section. --- hermes_cli/config.py | 1 + hermes_cli/nous_subscription.py | 48 ++++++++++- tests/hermes_cli/test_nous_subscription.py | 80 +++++++++++++++++++ .../docs/user-guide/features/tool-gateway.md | 9 +++ 4 files changed, 134 insertions(+), 4 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5b166427a712..8afa4f931365 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1999,6 +1999,7 @@ def check_config_version() -> Tuple[int, int]: "platform_toolsets", # written by the setup wizard (hermes_cli/setup.py) "known_plugin_toolsets", # written/read by hermes_cli/tools_config.py toolset-save flow "known_builtin_toolsets", # ditto — which builtin toolsets a platform's checklist has offered + "tool_gateway_declined_tools", # per-tool Tool Gateway offer declines (hermes_cli/nous_subscription.py, #92647) "session_reset", # top-level form read by gateway/config.py + setup "group_sessions_per_user", # top-level form bridged by gateway/config.py "thread_sessions_per_user", # top-level form bridged by gateway/config.py diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index 115865ea8b5e..bdd6f9532e0f 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -1000,6 +1000,11 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]: or get_env_value("PARALLEL_API_KEY") or get_env_value("TAVILY_API_KEY") or get_env_value("EXA_API_KEY") + # Env-configured keyless local backend: a reachable self-hosted + # SearXNG is a working web setup even with no stored selection + # (the autodetect cascade in tools/web_tools.py picks it up), so + # it must not be classified "unconfigured" and pre-checked (#92647). + or get_env_value("SEARXNG_URL") ), "image_gen": fal_direct, "video_gen": fal_direct, @@ -1019,17 +1024,22 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]: "browser": bool( get_env_value("BROWSER_USE_API_KEY") or (get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID")) + # Env-configured keyless local backend: CAMOFOX_URL activates the + # Camofox browser via never-configured autodetect (see + # _resolve_browser_state above), so it counts as configured even + # with no stored cloud_provider selection (#92647). + or get_env_value("CAMOFOX_URL") ), } _GATEWAY_DIRECT_LABELS = { - "web": "Firecrawl/Exa/Parallel/Tavily key", + "web": "Firecrawl/Exa/Parallel/Tavily key or SearXNG", "image_gen": "FAL key", "video_gen": "FAL key", "tts": "OpenAI/ElevenLabs key", "stt": "OpenAI/Groq/Mistral key", - "browser": "Browser Use/Browserbase key", + "browser": "Browser Use/Browserbase key or Camofox", } _ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "stt", "browser") @@ -1251,13 +1261,26 @@ def prompt_enable_tool_gateway( # Per-tool checklist: unconfigured tools first (pre-checked for new users), # then tools where the user already has their own key (left unchecked so we # don't override their own setup unless they ask). + # + # Decline persistence (#92647): tools the user has previously seen offered + # and left unchecked are recorded in ``tool_gateway_declined_tools`` and + # are never pre-checked again — the offer downgrades to opt-in-only. + # Acceptance used to be sticky while refusal was not, so the identical + # pre-checked checklist re-fired on every Nous model swap. + declined_raw = config.get("tool_gateway_declined_tools") + declined: set[str] = ( + {str(k) for k in declined_raw} if isinstance(declined_raw, list) else set() + ) + offer_keys: list[str] = list(unconfigured) + list(has_direct) labels: list[str] = [_GATEWAY_TOOL_LABELS[k] for k in unconfigured] labels += [ f"{_GATEWAY_TOOL_LABELS[k]} — keep using your {_GATEWAY_DIRECT_LABELS[k]}" for k in has_direct ] - pre_selected = list(range(len(unconfigured))) + pre_selected = [ + i for i, k in enumerate(unconfigured) if k not in declined + ] if pool_only: title = "Your free Nous tool pool — pick the tools to enable:" @@ -1273,11 +1296,28 @@ def prompt_enable_tool_gateway( return set() chosen_keys = [offer_keys[i] for i in chosen_idx if 0 <= i < len(offer_keys)] + + # Persist per-tool declines: every unconfigured tool that was offered and + # NOT chosen was actively left (or unchecked) by the user — remember that + # so the next Nous model swap doesn't pre-check it again. Cancel paths + # (Ctrl-C/ESC above) return before this and record nothing. Choosing a + # previously-declined tool clears its decline. + newly_declined = [k for k in unconfigured if k not in chosen_keys and k not in declined] + undeclined = declined & set(chosen_keys) + if newly_declined or undeclined: + config["tool_gateway_declined_tools"] = sorted( + (declined | set(newly_declined)) - set(chosen_keys) + ) + if not chosen_keys: + if newly_declined: + from hermes_cli.config import save_config + + save_config(config) return set() changed = apply_gateway_defaults(config, chosen_keys) - if changed: + if changed or newly_declined: from hermes_cli.config import save_config save_config(config) diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index e71f11456339..c9ffaa931a0d 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -295,6 +295,86 @@ def test_prompt_enable_tool_gateway_never_offers_explicit_backend(monkeypatch): assert "image" in blob # other unconfigured tools still offered +def test_gateway_direct_credentials_honor_env_configured_local_backends(monkeypatch): + """SEARXNG_URL / CAMOFOX_URL are env-configured keyless local backends + with no stored selection — they must still count as direct credentials + so the tool is offered unchecked, never pre-checked (#92647).""" + monkeypatch.setattr( + ns, + "get_env_value", + lambda name: "http://localhost:9377" if name in ("SEARXNG_URL", "CAMOFOX_URL") else "", + ) + monkeypatch.setattr(ns, "fal_key_is_configured", lambda: False) + monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: None) + + direct = ns._get_gateway_direct_credentials() + + assert direct["web"] is True + assert direct["browser"] is True + assert direct["image_gen"] is False + + +def test_prompt_enable_tool_gateway_persists_decline(monkeypatch): + """Submitting the checklist with a tool left unchecked records it in + tool_gateway_declined_tools and never pre-checks it again (#92647: + acceptance was sticky, refusal was not).""" + monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=True)) + monkeypatch.setattr( + ns, + "_get_gateway_direct_credentials", + lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "stt": False, "browser": False}, + ) + saved = [] + captured = _capture_checklist(monkeypatch, selected_idx=[]) + monkeypatch.setattr( + "hermes_cli.config.save_config", lambda cfg: saved.append(dict(cfg)), raising=False + ) + + config = {"model": {"provider": "nous"}} + assert ns.prompt_enable_tool_gateway(config) == set() + + # First offer: everything pre-checked, decline recorded and saved. + assert captured["pre_selected"] == list(range(len(captured["items"]))) + declined = config.get("tool_gateway_declined_tools") + assert isinstance(declined, list) and "web" in declined and "browser" in declined + assert saved, "decline must be persisted via save_config" + + # Second offer with the recorded declines: nothing is pre-checked. + captured2 = _capture_checklist(monkeypatch, selected_idx=[]) + monkeypatch.setattr( + "hermes_cli.config.save_config", lambda cfg: saved.append(dict(cfg)), raising=False + ) + ns.prompt_enable_tool_gateway(config) + assert captured2["pre_selected"] == [] + + +def test_prompt_enable_tool_gateway_choosing_declined_tool_clears_decline(monkeypatch): + """Opting in to a previously-declined tool removes it from the decline + list, so state tracks the user's latest explicit choice.""" + monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _account(logged_in=True, paid=True)) + monkeypatch.setattr( + ns, + "_get_gateway_direct_credentials", + lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "stt": False, "browser": False}, + ) + captured = _capture_checklist(monkeypatch, selected_idx=[0]) + + config = { + "model": {"provider": "nous"}, + "tool_gateway_declined_tools": ["browser", "web"], + } + ns.prompt_enable_tool_gateway(config) + + # The first offered key was chosen; it must leave the decline list. + chosen_key = None + for key, label in ns._GATEWAY_TOOL_LABELS.items(): + if captured["items"][0].startswith(label): + chosen_key = key + break + assert chosen_key is not None + assert chosen_key not in config["tool_gateway_declined_tools"] + + def test_apply_nous_managed_defaults_writes_video_gen_config(monkeypatch): """apply_nous_managed_defaults must store the managed 'nous' selection when a Nous subscriber selects video_gen without a direct FAL_KEY.""" diff --git a/website/docs/user-guide/features/tool-gateway.md b/website/docs/user-guide/features/tool-gateway.md index 247b4ed383b7..ee828cbb4663 100644 --- a/website/docs/user-guide/features/tool-gateway.md +++ b/website/docs/user-guide/features/tool-gateway.md @@ -84,6 +84,15 @@ The Tool Gateway is a **paid-subscription** feature. Free-tier Nous accounts can Some accounts are also entitled to a **free tool pool** — a small managed-tool allowance that covers gateway tool calls without a paid subscription. When a free pool is available, the gateway surfaces it and shows a setup prompt on first use, so you can opt in and start using managed tools right away. +## The enablement checklist + +Picking a Nous model (`hermes model`) offers a per-tool checklist of gateway backends. Its behavior respects your existing setup: + +- Tools you've explicitly pointed at another backend (e.g. `web.backend: searxng`, `browser.cloud_provider: camofox`) are **never offered** — your selection can't be accidentally overwritten. +- Tools configured via environment variables alone (e.g. `SEARXNG_URL`, `CAMOFOX_URL`) are offered **unchecked**, labeled to keep your own backend. +- Only genuinely unconfigured tools come pre-checked. +- Declines stick: if you submit the checklist with a tool unchecked, it won't be pre-checked on future Nous model swaps (stored in `tool_gateway_declined_tools` in `config.yaml`; checking it later clears the decline). + ## Mix and match The gateway is per-tool. Turn it on for just what you want: From 680b11503cbff4a5d3865f768784b238a9e76fae Mon Sep 17 00:00:00 2001 From: Klaus Suppan Date: Thu, 20 Aug 2026 07:11:30 +0200 Subject: [PATCH 032/666] fix(desktop): model settings follow active profile instead of primary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModelSettings passed scopeProfile (null when following the active profile) directly to the Hermes API helpers. The helpers interpret null as "target the primary/default backend", not as "follow the active profile". This meant that when a non-default profile was active (e.g. local with LM Studio), the model picker showed the primary/default profile's providers instead — LM Studio was invisible. Fix: convert null → undefined before calling the API helpers, so profileScoped() falls back to the app-wide active profile. Added regression test verifying the helpers receive undefined (not null) when no scope override is set. --- apps/desktop/src/app/settings/model-settings.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 28036e9e6723..3ad3af8ac23c 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -189,6 +189,10 @@ interface ModelSettingsProps { export function ModelSettings({ onMainModelChanged, scopeProfile = null }: ModelSettingsProps) { const { t } = useI18n() const m = t.settings.model + // `null` means "follow the active profile" at the settings layer. The + // Hermes API helpers use `undefined` for that contract; passing null would + // deliberately suppress the active profile and read the primary/default. + const requestProfile = scopeProfile ?? undefined const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null) @@ -235,10 +239,10 @@ export function ModelSettings({ onMainModelChanged, scopeProfile = null }: Model try { const [modelInfo, modelOptions, auxiliaryModels, moaModels] = await Promise.all([ - getGlobalModelInfo(scopeProfile), - getGlobalModelOptions(undefined, scopeProfile), - getAuxiliaryModels(scopeProfile), - getMoaModels(scopeProfile).catch(() => null) + getGlobalModelInfo(requestProfile), + getGlobalModelOptions(undefined, requestProfile), + getAuxiliaryModels(requestProfile), + getMoaModels(requestProfile).catch(() => null) ]) if (profileEpoch.current !== epoch) { @@ -276,7 +280,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile = null }: Model } } }, - [scopeProfile] + [requestProfile, scopeProfile] ) useEffect(() => { From abd7f75b8d07271e094ddc81bf556030f6e793ac Mon Sep 17 00:00:00 2001 From: Carl Taylor Date: Thu, 20 Aug 2026 16:43:51 +1000 Subject: [PATCH 033/666] fix(desktop): keep Messaging on active profile --- apps/desktop/src/app/messaging/index.test.tsx | 29 +++++++++++++++---- apps/desktop/src/app/messaging/index.tsx | 7 +++-- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/app/messaging/index.test.tsx b/apps/desktop/src/app/messaging/index.test.tsx index 72d9c1004436..75bb9701e6fe 100644 --- a/apps/desktop/src/app/messaging/index.test.tsx +++ b/apps/desktop/src/app/messaging/index.test.tsx @@ -13,13 +13,16 @@ const revokePairing = vi.fn() const openExternalLink = vi.fn() vi.mock('@/hermes', () => ({ - approvePairing: (platformId: string, requestId: string) => approvePairing(platformId, requestId), - getMessagingPlatforms: () => getMessagingPlatforms(), - getPairing: () => getPairing(), + approvePairing: (platformId: string, requestId: string, profile?: null | string) => + approvePairing(platformId, requestId, profile), + getMessagingPlatforms: (profile?: null | string) => getMessagingPlatforms(profile), + getPairing: (profile?: null | string) => getPairing(profile), getProfiles: vi.fn(async () => ({ profiles: [] })), - revokePairing: (platformId: string, userId: string) => revokePairing(platformId, userId), + revokePairing: (platformId: string, userId: string, profile?: null | string) => + revokePairing(platformId, userId, profile), setApiRequestProfile: vi.fn(), - updateMessagingPlatform: (id: string, body: unknown) => updateMessagingPlatform(id, body) + updateMessagingPlatform: (id: string, body: unknown, profile?: null | string) => + updateMessagingPlatform(id, body, profile) })) // Keep store/profile's side-effecting imports inert (pulled in via the shared @@ -85,6 +88,20 @@ async function renderMessaging() { return result! } +describe('MessagingView profile scope', () => { + it('follows the active profile instead of targeting primary when there is no override', async () => { + const { $settingsScopeOverride } = await import('@/store/settings-scope') + + $settingsScopeOverride.set(null) + getMessagingPlatforms.mockResolvedValue({ platforms: [platform()] }) + + await renderMessaging() + + await waitFor(() => expect(getMessagingPlatforms).toHaveBeenCalledWith(undefined)) + expect(getPairing).toHaveBeenCalledWith(undefined) + }) +}) + describe('MessagingView setup-guide link', () => { it('hides the setup-guide button for a plugin platform with no docs URL', async () => { // Teams (and other plugin platforms) ship an empty docs_url. Rendering an @@ -138,7 +155,7 @@ describe('MessagingView pairing', () => { fireEvent.click(approve) }) - await waitFor(() => expect(approvePairing).toHaveBeenCalledWith('teams', 'a1b2c3d4e5f60718')) + await waitFor(() => expect(approvePairing).toHaveBeenCalledWith('teams', 'a1b2c3d4e5f60718', undefined)) }) it('restores the pending row when approval fails', async () => { diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index 579305dd3b58..0a1cc8e7ff00 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -128,9 +128,10 @@ function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) { export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) { const { t } = useI18n() const m = t.messaging - // Shared settings "Applies to" scope: configure another profile's gateway - // platforms/pairing without switching the whole app (null → active profile). - const scopeProfile = useStore($settingsScopeOverride) + // Shared settings "Applies to" scope. At this layer `null` means "follow + // the active profile", while the API helpers use `undefined` for that + // contract; passing null would deliberately target the primary profile. + const scopeProfile = useStore($settingsScopeOverride) ?? undefined // Both save/toggle toasts offer the same one-click restart. const restartGatewayAction = { label: t.commandCenter.restartGateway, onClick: () => void runGatewayRestart() } const [platforms, setPlatforms] = useState(null) From c942cd9ea1432fab41c12c65a95732c475f768ea Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:30:56 -0700 Subject: [PATCH 034/666] fix(desktop): settings scope requests can never target primary by accident MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings 'Applies to' store uses null = 'follow the active profile', but the API helpers (profileScoped/capabilityScoped) use null = 'target the primary/default backend'. Every page that passed the raw override to a request silently read/wrote the primary profile whenever no override was set — writes landed on the right profile via other paths while reads repainted primary's values, so profile model changes appeared to revert (#90549 class). Close the class at the seam instead of per call site: - store/settings-scope: new $settingsRequestProfile computed — the request-shaped scope (string | undefined, never null). Documented as THE value to hand to API helpers. - config-settings, keys-settings, messaging: consume the request-shaped computed; ModelSettings/MemoryConnect/ProviderConfigPanel/ useEnvCredentials props narrowed to string | undefined so a primary-targeting null can no longer be plumbed through. - keys-settings site was a live third instance: getEnvVars(null) read primary's env store on non-default profiles. Regression tests: store computed shape, ModelSettings unscoped+scoped reads, KeysSettings unscoped fetch (all fail against the old behavior; sabotage-verified). --- apps/desktop/src/app/messaging/index.tsx | 9 ++--- .../src/app/settings/config-settings.tsx | 12 +++--- .../src/app/settings/env-credentials.tsx | 6 ++- .../src/app/settings/keys-settings.test.tsx | 11 +++++- .../src/app/settings/keys-settings.tsx | 8 ++-- .../src/app/settings/memory/connect.tsx | 2 +- .../settings/memory/provider-config-panel.tsx | 2 +- .../src/app/settings/model-settings.test.tsx | 37 ++++++++++++++++--- .../src/app/settings/model-settings.tsx | 24 ++++++------ apps/desktop/src/store/settings-scope.test.ts | 14 ++++++- apps/desktop/src/store/settings-scope.ts | 17 +++++++++ 11 files changed, 103 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index 0a1cc8e7ff00..1611dcf3c208 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -28,7 +28,7 @@ import { normalize } from '@/lib/text' import { cn } from '@/lib/utils' import { $changeEventsAvailable, $pairingChangeTick, $platformsChangeTick } from '@/store/live-sync' import { notify, notifyError } from '@/store/notifications' -import { $settingsScopeOverride } from '@/store/settings-scope' +import { $settingsRequestProfile } from '@/store/settings-scope' import { runGatewayRestart } from '@/store/system-actions' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' @@ -128,10 +128,9 @@ function fieldCopy(field: MessagingEnvVarInfo, m: Translations['messaging']) { export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: MessagingViewProps) { const { t } = useI18n() const m = t.messaging - // Shared settings "Applies to" scope. At this layer `null` means "follow - // the active profile", while the API helpers use `undefined` for that - // contract; passing null would deliberately target the primary profile. - const scopeProfile = useStore($settingsScopeOverride) ?? undefined + // Shared settings "Applies to" scope, request-shaped (undefined → follow + // the active profile; the API helpers treat null as "target primary"). + const scopeProfile = useStore($settingsRequestProfile) // Both save/toggle toasts offer the same one-click restart. const restartGatewayAction = { label: t.commandCenter.restartGateway, onClick: () => void runGatewayRestart() } const [platforms, setPlatforms] = useState(null) diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index c44dd8b6a72d..ee69a841fc2c 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -24,7 +24,7 @@ import { $keepAwake, setKeepAwake } from '@/store/keep-awake' import { notify, notifyError } from '@/store/notifications' import { normalizeProfileKey } from '@/store/profile' import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects' -import { $settingsScopeOverride } from '@/store/settings-scope' +import { $settingsRequestProfile } from '@/store/settings-scope' import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes' import { hermesConfigCacheWriter, useHermesConfigRecord } from '../hooks/use-config-record' @@ -58,7 +58,7 @@ export function ConfigSettings({ // inner page per scope so every draft/seed/autosave ref resets wholesale // when the target profile changes — the same guarantee useOnProfileSwitch // provides for app-wide switches, without hand-clearing each piece. - const scopeProfile = useStore($settingsScopeOverride) + const scopeProfile = useStore($settingsRequestProfile) return ( getHermesConfigSchema(scopeProfile ?? undefined), + queryFn: () => getHermesConfigSchema(scopeProfile), staleTime: 5 * 60 * 1000 }) @@ -147,7 +147,7 @@ function ConfigSettingsInner({ useEffect(() => { let cancelled = false - getElevenLabsVoices(scopeProfile ?? undefined) + getElevenLabsVoices(scopeProfile) .then(result => { if (cancelled || !result.available) { return @@ -178,7 +178,7 @@ function ConfigSettingsInner({ const t = window.setTimeout(() => { void (async () => { try { - const result = await saveHermesConfig(config, scopeProfile ?? undefined) + const result = await saveHermesConfig(config, scopeProfile) if (!result.ok) { throw new Error(c.autosaveFailed) diff --git a/apps/desktop/src/app/settings/env-credentials.tsx b/apps/desktop/src/app/settings/env-credentials.tsx index 0b4daf8e2c87..490d08c06601 100644 --- a/apps/desktop/src/app/settings/env-credentials.tsx +++ b/apps/desktop/src/app/settings/env-credentials.tsx @@ -43,8 +43,10 @@ export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHe // credential pages (Providers, Keys) share one source of truth and one set of // mutation handlers instead of duplicating the plumbing. An optional `profile` // targets another profile's env store (the shared settings "Applies to" -// scope); undefined/null keeps the app-wide active profile. -export function useEnvCredentials(profile: null | string = null): UseEnvCredentials { +// scope); undefined keeps the app-wide active profile. Request-shaped on +// purpose: the API helpers treat an explicit `null` as "target the +// primary/default backend", which is never what a settings page means. +export function useEnvCredentials(profile?: string): UseEnvCredentials { const { t } = useI18n() const credentials = t.settings.credentials const toolsets = t.settings.toolsets diff --git a/apps/desktop/src/app/settings/keys-settings.test.tsx b/apps/desktop/src/app/settings/keys-settings.test.tsx index 87217b2e15c1..699fd0e4a7fe 100644 --- a/apps/desktop/src/app/settings/keys-settings.test.tsx +++ b/apps/desktop/src/app/settings/keys-settings.test.tsx @@ -12,7 +12,7 @@ stubResizeObserver() vi.mock('@/hermes', () => ({ deleteEnvVar: vi.fn(), - getEnvVars: () => getEnvVars(), + getEnvVars: (profile?: null | string) => getEnvVars(profile), revealEnvVar: vi.fn(), setApiRequestProfile: () => undefined, setEnvVar: vi.fn() @@ -54,6 +54,15 @@ function DeepLinkButton({ target }: { target: string }) { } describe('KeysSettings', () => { + it('fetches env vars for the active profile (undefined, never null) when unscoped', async () => { + // #90549 class: getEnvVars(null) targets the primary profile's env store, + // so a non-default profile's Keys page would read (and edit) the wrong + // profile. Unscoped must send undefined so the active profile applies. + await renderKeysSettings('tools') + + await waitFor(() => expect(getEnvVars).toHaveBeenCalledWith(undefined)) + }) + it('lists tools and excludes settings / channel-managed credentials', async () => { getEnvVars.mockResolvedValue({ BRAVE_SEARCH_API_KEY: envVar('tool', { description: 'Search the web with Brave.' }), diff --git a/apps/desktop/src/app/settings/keys-settings.tsx b/apps/desktop/src/app/settings/keys-settings.tsx index aeb24b49be58..c5f239cc6fb1 100644 --- a/apps/desktop/src/app/settings/keys-settings.tsx +++ b/apps/desktop/src/app/settings/keys-settings.tsx @@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useI18n } from '@/i18n' -import { $settingsScopeOverride } from '@/store/settings-scope' +import { $settingsRequestProfile } from '@/store/settings-scope' import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui' import { useEnvCredentials } from './env-credentials' @@ -35,8 +35,10 @@ const credentialElementId = (key: string) => `credential-key-${key}` export function KeysSettings({ view }: KeysSettingsProps) { const { t } = useI18n() // Shared settings "Applies to" scope: fetch + edit the selected profile's - // env store instead of the active one (null → active, the default path). - const scopeProfile = useStore($settingsScopeOverride) + // env store instead of the active one (undefined → active, the default + // path — request-shaped so the API helpers never see a primary-targeting + // null). + const scopeProfile = useStore($settingsRequestProfile) const { rowProps, vars } = useEnvCredentials(scopeProfile) const [openKey, setOpenKey] = useState(null) diff --git a/apps/desktop/src/app/settings/memory/connect.tsx b/apps/desktop/src/app/settings/memory/connect.tsx index 64d9cfa6fa53..1c093179fea7 100644 --- a/apps/desktop/src/app/settings/memory/connect.tsx +++ b/apps/desktop/src/app/settings/memory/connect.tsx @@ -12,7 +12,7 @@ const POLL_TIMEOUT_MS = 120_000 // Small connect affordance rendered under the provider dropdown. Capability is // backend-driven: the status route 404s for providers without an oauth_flow // module, so non-OAuth providers render nothing. -export function MemoryConnect({ profile = null, provider }: { profile?: null | string; provider: string }) { +export function MemoryConnect({ profile, provider }: { profile?: string; provider: string }) { const [capable, setCapable] = useState<'no' | 'unknown' | 'yes'>('unknown') const [connected, setConnected] = useState(false) const [auth, setAuth] = useState(null) diff --git a/apps/desktop/src/app/settings/memory/provider-config-panel.tsx b/apps/desktop/src/app/settings/memory/provider-config-panel.tsx index 2d7b6ae0c4af..b4b5fcc1b223 100644 --- a/apps/desktop/src/app/settings/memory/provider-config-panel.tsx +++ b/apps/desktop/src/app/settings/memory/provider-config-panel.tsx @@ -20,7 +20,7 @@ function seedValues(config: MemoryProviderConfig): Record { ) } -export function ProviderConfigPanel({ profile = null, provider }: { profile?: null | string; provider: string }) { +export function ProviderConfigPanel({ profile, provider }: { profile?: string; provider: string }) { const [config, setConfig] = useState(null) const [loadError, setLoadError] = useState(null) const [values, setValues] = useState>({}) diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 5343e2ef0071..97d9eb84b425 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -28,11 +28,12 @@ const startManualProviderOAuth = vi.fn() let profileSwitchHandler: (() => void) | null = null vi.mock('@/hermes', () => ({ - getGlobalModelInfo: () => getGlobalModelInfo(), - getGlobalModelOptions: () => getGlobalModelOptions(), - getAuxiliaryModels: () => getAuxiliaryModels(), + getGlobalModelInfo: (profile?: null | string) => getGlobalModelInfo(profile), + getGlobalModelOptions: (opts?: unknown, profile?: null | string) => getGlobalModelOptions(opts, profile), + getAuxiliaryModels: (profile?: null | string) => getAuxiliaryModels(profile), getApiRequestProfile: () => 'default', - getMoaModels: () => getMoaModels(), + getMoaModels: (profile?: null | string) => getMoaModels(profile), + profileScopeKey: (scope?: null | string) => (scope ?? '').trim() || 'default', setModelAssignment: (body: unknown) => setModelAssignment(body), getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug), saveMoaModels: (body: unknown) => saveMoaModels(body), @@ -85,7 +86,7 @@ afterEach(() => { profileSwitchHandler = null }) -async function renderModelSettings() { +async function renderModelSettings(scopeProfile?: string) { const { ModelSettings } = await import('./model-settings') const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) @@ -94,12 +95,36 @@ async function renderModelSettings() { // needs a router context in tests (the app provides HashRouter at root). - + ) } +describe('ModelSettings profile scope', () => { + // #90549: the API helpers treat `null` as "deliberately target the + // primary/default profile". A page following the active profile must pass + // `undefined`, or every read repaints the primary's model and the user's + // change looks reverted. + it('follows the active profile (undefined, never null) when unscoped', async () => { + await renderModelSettings() + + await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalledWith(undefined)) + expect(getGlobalModelOptions).toHaveBeenCalledWith(undefined, undefined) + expect(getAuxiliaryModels).toHaveBeenCalledWith(undefined) + expect(getMoaModels).toHaveBeenCalledWith(undefined) + }) + + it('reads through the explicit scope override when one is set', async () => { + await renderModelSettings('research') + + await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalledWith('research')) + expect(getGlobalModelOptions).toHaveBeenCalledWith(undefined, 'research') + expect(getAuxiliaryModels).toHaveBeenCalledWith('research') + expect(getMoaModels).toHaveBeenCalledWith('research') + }) +}) + describe('ModelSettings', () => { it('loads the current main model and lists configured providers only', async () => { await renderModelSettings() diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 3ad3af8ac23c..6ae1590c8ee1 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -182,17 +182,15 @@ interface ModelSettingsProps { /** Notified after the main model is applied, so live UI stores can sync. */ onMainModelChanged?: (provider: string, model: string) => void /** Shared settings "Applies to" scope: a concrete profile to edit instead of - * the app's active one, or null to follow the active profile (default). */ - scopeProfile?: null | string + * the app's active one, or undefined to follow the active profile (default). + * Request-shaped on purpose — the API helpers treat `null` as "deliberately + * target the primary/default backend", so this prop never carries null. */ + scopeProfile?: string } -export function ModelSettings({ onMainModelChanged, scopeProfile = null }: ModelSettingsProps) { +export function ModelSettings({ onMainModelChanged, scopeProfile }: ModelSettingsProps) { const { t } = useI18n() const m = t.settings.model - // `null` means "follow the active profile" at the settings layer. The - // Hermes API helpers use `undefined` for that contract; passing null would - // deliberately suppress the active profile and read the primary/default. - const requestProfile = scopeProfile ?? undefined const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null) @@ -239,10 +237,10 @@ export function ModelSettings({ onMainModelChanged, scopeProfile = null }: Model try { const [modelInfo, modelOptions, auxiliaryModels, moaModels] = await Promise.all([ - getGlobalModelInfo(requestProfile), - getGlobalModelOptions(undefined, requestProfile), - getAuxiliaryModels(requestProfile), - getMoaModels(requestProfile).catch(() => null) + getGlobalModelInfo(scopeProfile), + getGlobalModelOptions(undefined, scopeProfile), + getAuxiliaryModels(scopeProfile), + getMoaModels(scopeProfile).catch(() => null) ]) if (profileEpoch.current !== epoch) { @@ -280,7 +278,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile = null }: Model } } }, - [requestProfile, scopeProfile] + [scopeProfile] ) useEffect(() => { @@ -543,7 +541,7 @@ export function ModelSettings({ onMainModelChanged, scopeProfile = null }: Model setConfig(next) try { - await saveHermesConfig(next, scopeProfile ?? undefined) + await saveHermesConfig(next, scopeProfile) } catch (err) { setConfig(prev) notifyError(err, m.defaultsFailed) diff --git a/apps/desktop/src/store/settings-scope.test.ts b/apps/desktop/src/store/settings-scope.test.ts index 85824a91c273..321ae5d31c7b 100644 --- a/apps/desktop/src/store/settings-scope.test.ts +++ b/apps/desktop/src/store/settings-scope.test.ts @@ -17,7 +17,7 @@ vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() } vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() })) const { $activeGatewayProfile } = await import('./profile') -const { $settingsScopeOverride, $settingsScopeProfile, setSettingsScope } = await import('./settings-scope') +const { $settingsRequestProfile, $settingsScopeOverride, $settingsScopeProfile, setSettingsScope } = await import('./settings-scope') beforeEach(() => { $activeGatewayProfile.set('default') @@ -58,6 +58,18 @@ describe('settings scope store', () => { expect($settingsScopeProfile.get()).toBe('default') }) + it('exposes a request-shaped scope: undefined (never null) without an override', () => { + // api/client.ts profileScoped() treats null as "target primary/default" — + // the #90549 bug class. The request form must therefore never be null. + expect($settingsRequestProfile.get()).toBeUndefined() + + setSettingsScope('research') + expect($settingsRequestProfile.get()).toBe('research') + + setSettingsScope('default') + expect($settingsRequestProfile.get()).toBeUndefined() + }) + it('drops the override on an app-wide profile switch', () => { setSettingsScope('research') expect($settingsScopeOverride.get()).toBe('research') diff --git a/apps/desktop/src/store/settings-scope.ts b/apps/desktop/src/store/settings-scope.ts index c8178f22df86..115707b8d521 100644 --- a/apps/desktop/src/store/settings-scope.ts +++ b/apps/desktop/src/store/settings-scope.ts @@ -16,6 +16,23 @@ export const $settingsScopeProfile = computed([$settingsScopeOverride, $activeGa normalizeProfileKey(override ?? active) ) +// ── Request-scope form (THE value to hand to API helpers) ────────────────── +// The store contract and the API contract disagree about `null`: +// - here, `null` means "follow the app's active profile" (no override); +// - in api/client.ts `profileScoped()`/`capabilityScoped()`, `null` means +// "deliberately suppress the active profile and target primary/default" — +// only `undefined` falls back to the active profile. +// Passing the raw override into an API helper therefore silently retargets +// every read/write to the primary profile whenever no override is set — the +// "model change reverts when I re-enter the tab" class of bug (#90549: the +// page WROTE the right profile but READ primary back). Always send this +// computed (or `override ?? undefined`) on requests; keep the raw override +// only for UI concerns (selector highlight, cache keys, remount keys). +export const $settingsRequestProfile = computed( + $settingsScopeOverride, + (override): string | undefined => override ?? undefined +) + // Select the profile the settings pages should edit. Picking the app's active // profile stores `null` (no override) so the scope keeps following the app on // profile switches — and requests keep their unscoped default shape. From 2a379a167569c69f96eeb9e1041891ccf8572d45 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:31:10 -0700 Subject: [PATCH 035/666] chore: map salvage contributors (klaus765, ctaylor86) --- contributors/emails/klaus@suppan-design.ch | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/klaus@suppan-design.ch diff --git a/contributors/emails/klaus@suppan-design.ch b/contributors/emails/klaus@suppan-design.ch new file mode 100644 index 000000000000..77a70068e906 --- /dev/null +++ b/contributors/emails/klaus@suppan-design.ch @@ -0,0 +1 @@ +klaus765 From 4ec57d56a941e8785e971d9e642f2545a9814a6f Mon Sep 17 00:00:00 2001 From: Viktor Date: Sat, 22 Aug 2026 19:20:43 +0200 Subject: [PATCH 036/666] fix(inventory): keep Anthropic OAuth logins visible in desktop pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop explicit-only picker filter drops any provider row where is_provider_explicitly_configured() is False. That gate only recognizes active_provider, model.provider, and API-key env vars — so a user who authenticated Anthropic via OAuth (Hermes device flow or Claude Code ~/.claude/.credentials.json) had the Anthropic row silently hidden from the desktop model picker even though list_authenticated_providers() accepted those same credentials when building it (model_switch.py has an equivalent special-case in its discovery path). Unlike ambient CLI tokens (gh -> copilot), an OAuth access token only exists after an interactive login, so its presence is deliberate user configuration. Add a narrow carve-out for the anthropic slug that keeps the row when either OAuth reader finds an accessToken; the strict gate still governs every other provider, and is_provider_explicitly_configured itself stays untouched so PR #4210's consent guard for auxiliary tasks keeps its current behavior. --- hermes_cli/inventory.py | 35 ++++++++++++++++ tests/hermes_cli/test_inventory.py | 66 ++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index bce98ef1e6bc..094a66a62db5 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -661,6 +661,33 @@ def _append_unconfigured_rows( return extras +def _anthropic_oauth_credentials_present() -> bool: + """True when the user explicitly authenticated Anthropic via OAuth. + + Two deliberate flows leave no trace in active_provider / + model.provider / API-key env vars: Hermes' own Anthropic device flow + (token in auth.json) and a Claude Code login (~/.claude/.credentials.json). + ``list_authenticated_providers`` already accepts both readers as real + credentials when discovering rows; this mirrors that acceptance so the + desktop explicit-only filter does not silently drop a provider the user + deliberately signed into. Unlike ambient CLI tokens (gh -> copilot), + an OAuth access token only exists after an interactive login. + """ + try: + from agent.anthropic_adapter import ( + read_claude_code_credentials, + read_hermes_oauth_credentials, + ) + + hermes_creds = read_hermes_oauth_credentials() or {} + if hermes_creds.get("accessToken"): + return True + cc_creds = read_claude_code_credentials() or {} + return bool(cc_creds.get("accessToken")) + except Exception: + return False + + def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]: """Keep only rows backed by explicit user configuration. @@ -697,6 +724,14 @@ def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list # them would defeat their purpose (zero-setup discoverability). kept.append(row) continue + if slug == "anthropic" and _anthropic_oauth_credentials_present(): + # Anthropic OAuth logins (Hermes device flow / Claude Code) are + # deliberate sign-ins that leave no trace in active_provider, + # model.provider, or API-key env vars. The strict gate below + # would drop the row even though list_authenticated_providers + # just accepted those same credentials when building it. + kept.append(row) + continue if is_provider_explicitly_configured(slug): kept.append(row) return kept diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index 92ee4beafee3..7b4ff6e575ca 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -208,6 +208,72 @@ def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_ ] +def test_explicit_only_keeps_anthropic_row_with_oauth_credentials(): + """Anthropic OAuth logins are deliberate sign-ins, not ambient credentials. + + Claude Code (~/.claude/.credentials.json) and Hermes' own device flow + leave no trace in active_provider / model.provider / API-key env vars, + so is_provider_explicitly_configured() returns False even though + list_authenticated_providers just accepted those same credentials when + building the row. The desktop explicit-only filter must keep it. + """ + rows = [ + {"slug": "anthropic", "name": "Anthropic", "models": ["claude-sonnet-5"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "hermes"}, + {"slug": "copilot", "name": "Copilot", "models": ["gpt-5.4"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "hermes"}, + ] + ctx = _empty_ctx(provider="opencode-go", model="glm-5.3") + with ( + _list_auth_returning(rows), + patch("hermes_cli.config.read_raw_config", return_value={}), + patch( + "hermes_cli.auth.is_provider_explicitly_configured", + return_value=False, + ), + patch( + "hermes_cli.inventory._anthropic_oauth_credentials_present", + return_value=True, + ), + ): + payload = build_models_payload(ctx, explicit_only=True) + + slugs = [row["slug"] for row in payload["providers"]] + assert "anthropic" in slugs, ( + "Anthropic OAuth login must survive the explicit-only filter" + ) + assert "copilot" not in slugs, ( + "ambient credential discovery must stay filtered" + ) + + +def test_explicit_only_drops_anthropic_row_without_oauth_credentials(): + """No OAuth token and no explicit config -> Anthropic stays hidden.""" + rows = [ + {"slug": "anthropic", "name": "Anthropic", "models": ["claude-sonnet-5"], + "total_models": 1, "is_current": False, "is_user_defined": False, + "source": "hermes"}, + ] + ctx = _empty_ctx(provider="opencode-go", model="glm-5.3") + with ( + _list_auth_returning(rows), + patch("hermes_cli.config.read_raw_config", return_value={}), + patch( + "hermes_cli.auth.is_provider_explicitly_configured", + return_value=False, + ), + patch( + "hermes_cli.inventory._anthropic_oauth_credentials_present", + return_value=False, + ), + ): + payload = build_models_payload(ctx, explicit_only=True) + + assert "anthropic" not in [row["slug"] for row in payload["providers"]] + + # ─── picker_hints ────────────────────────────────────────────────────── From aa24d8780171d4f9027ac6fc533cd3b0e7daf059 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:31:46 -0700 Subject: [PATCH 037/666] chore: map contributor email for salvage attribution --- contributors/emails/victoronwuanaku@gmail.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/victoronwuanaku@gmail.com diff --git a/contributors/emails/victoronwuanaku@gmail.com b/contributors/emails/victoronwuanaku@gmail.com new file mode 100644 index 000000000000..c6b026ac86e6 --- /dev/null +++ b/contributors/emails/victoronwuanaku@gmail.com @@ -0,0 +1 @@ +victoronwuanaku From 4f0e466e5d1cc136fbef19b38a75dd4c936c0834 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:34:45 -0700 Subject: [PATCH 038/666] fix: accept pool-only Anthropic OAuth entries in the desktop picker filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged carve-out covered the PKCE file and Claude Code credentials but missed the canonical wired-token location — auth.json credential_pool.anthropic oauth entries. Discovery accepts those via pool.has_credentials(), so the filter must too. Read-only dict access; api_key pool entries intentionally stay excluded (E2E case 4). --- hermes_cli/inventory.py | 22 +++++++++++++- tests/hermes_cli/test_inventory.py | 48 ++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 094a66a62db5..2f426f673790 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -683,9 +683,29 @@ def _anthropic_oauth_credentials_present() -> bool: if hermes_creds.get("accessToken"): return True cc_creds = read_claude_code_credentials() or {} - return bool(cc_creds.get("accessToken")) + if cc_creds.get("accessToken"): + return True except Exception: return False + # Pool-only OAuth entries (auth.json credential_pool.anthropic) are the + # canonical location for wired tokens and equally deliberate — the + # discovery side accepts them via pool.has_credentials(), so the filter + # must too or those rows are built and then silently dropped. Read-only + # dict access (no load_pool) so a picker open never mutates auth.json. + try: + from agent.credential_pool import AUTH_TYPE_OAUTH + from hermes_cli.auth import read_credential_pool + + for entry in read_credential_pool("anthropic"): + if ( + isinstance(entry, dict) + and entry.get("auth_type") == AUTH_TYPE_OAUTH + and str(entry.get("access_token") or "").strip() + ): + return True + except Exception: + pass + return False def _filter_explicit_provider_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]: diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index 7b4ff6e575ca..1441beaa84dc 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -274,6 +274,54 @@ def test_explicit_only_drops_anthropic_row_without_oauth_credentials(): assert "anthropic" not in [row["slug"] for row in payload["providers"]] +def test_anthropic_oauth_presence_accepts_pool_only_oauth_entry(): + """A pool-only OAuth entry (auth.json credential_pool.anthropic) counts. + + Wired/device-flow tokens land in the credential pool, not in + .anthropic_oauth.json or ~/.claude/.credentials.json. The presence + check must accept them or the row is built and then silently dropped. + """ + from hermes_cli.inventory import _anthropic_oauth_credentials_present + + with ( + patch( + "agent.anthropic_adapter.read_hermes_oauth_credentials", + return_value=None, + ), + patch( + "agent.anthropic_adapter.read_claude_code_credentials", + return_value=None, + ), + patch( + "hermes_cli.auth.read_credential_pool", + return_value=[ + {"auth_type": "oauth", "access_token": "sk-ant-oat01-pool"} + ], + ), + ): + assert _anthropic_oauth_credentials_present() is True + + # api_key pool entries are NOT OAuth logins — presence must stay False + # (they are handled by the explicit-config gate / env var paths). + with ( + patch( + "agent.anthropic_adapter.read_hermes_oauth_credentials", + return_value=None, + ), + patch( + "agent.anthropic_adapter.read_claude_code_credentials", + return_value=None, + ), + patch( + "hermes_cli.auth.read_credential_pool", + return_value=[ + {"auth_type": "api_key", "access_token": "sk-ant-api03-key"} + ], + ), + ): + assert _anthropic_oauth_credentials_present() is False + + # ─── picker_hints ────────────────────────────────────────────────────── From 38ce2d7553c42af591a59087cb62ea018f9e6917 Mon Sep 17 00:00:00 2001 From: "Andrex Ibiza, MBA" Date: Fri, 21 Aug 2026 17:32:52 -0500 Subject: [PATCH 039/666] fix(desktop): enforce exact route identity authority Make explicit registry qualification authoritative: only a current exact ID is accepted, while blank, malformed, unknown, or retired claims fail closed without endpoint inference. Restrict genuinely unqualified legacy descriptors to the shared full-envelope URL/Cloud/SSH matcher, reject zero or multiple matches, normalize SSH host/user identity, and prove remote-primary restoration keeps the exact (connectionId, profile) tuple. Closes #90048. Prior work by @teknium1 in #89719 and #88922, @andrexibiza in #90913, and @AndreasG78 in https://github.com/NousResearch/hermes-agent/issues/90048#issuecomment-5375227679 shaped this implementation. @saralilyb's #90006 remains downstream consumer context; the production stopgap is credited but excluded because registry primary does not prove route ownership. --- .../electron/connection-registry.test.ts | 303 +++++++++++++++++- apps/desktop/electron/connection-registry.ts | 155 +++++++-- .../electron/connection-route-identity.ts | 136 ++++++++ apps/desktop/electron/desktop-remote-route.ts | 115 +------ apps/desktop/src/store/connections.test.ts | 11 + 5 files changed, 579 insertions(+), 141 deletions(-) create mode 100644 apps/desktop/electron/connection-route-identity.ts diff --git a/apps/desktop/electron/connection-registry.test.ts b/apps/desktop/electron/connection-registry.test.ts index d357597a0191..4b215bfeda33 100644 --- a/apps/desktop/electron/connection-registry.test.ts +++ b/apps/desktop/electron/connection-registry.test.ts @@ -82,11 +82,21 @@ test('resolvedConnectionId identifies local and migrated remote descriptors', () resolvedConnectionId(registry, { baseUrl: 'http://127.0.0.1:49152', mode: 'remote', - remoteHost: 'root@work-host', + remoteHost: 'ROOT@WORK-HOST', remoteKind: 'ssh' }), work?.id ) + + const ambiguousLocal: ConnectionRegistry = { + ...registry, + connections: [ + ...registry.connections, + { id: 'local-copy', kind: 'local', label: 'Local copy' } + ] + } + + assert.equal(resolvedConnectionId(ambiguousLocal, { mode: 'local' }), null) }) test('resolvedConnectionId does not guess an unregistered remote', () => { @@ -107,6 +117,297 @@ test('agentHandle bare when unique, @name-device shape when duplicated', () => { assert.equal(agentHandle('', 'Homelab', false), 'default') }) +test('resolvedConnectionId accepts only a current exact descriptor id and never falls back', () => { + const registry: ConnectionRegistry = { + version: REGISTRY_VERSION, + primary: 'remote-a', + launchMode: 'primary', + lastUsed: 'remote-a', + connections: [ + { id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' }, + { + id: 'remote-a', + kind: 'remote', + label: 'Remote A', + url: 'https://shared.example', + authMode: 'token', + token: { encoding: 'safeStorage', value: 'token-a' }, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } } + }, + { + id: 'remote-b', + kind: 'remote', + label: 'Remote B', + url: 'https://shared.example', + authMode: 'oauth', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-b' } } + } + ] + } + + assert.equal( + resolvedConnectionId(registry, { + authMode: 'token', + baseUrl: 'https://shared.example', + connectionId: 'remote-b', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote', + remoteKind: 'url', + token: { encoding: 'safeStorage', value: 'token-a' } + }), + 'remote-b' + ) + + const inferableRemoteA = { + authMode: 'token', + baseUrl: 'https://shared.example', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote' as const, + remoteKind: 'url' as const, + token: { encoding: 'safeStorage', value: 'token-a' } + } + + // Only true absence enters compatibility inference. Every explicitly + // present invalid value remains unresolved even though the remaining + // envelope uniquely identifies remote-a. + assert.equal(resolvedConnectionId(registry, inferableRemoteA), 'remote-a') + + for (const connectionId of ['', ' ', null, undefined, 42, {}, 'unknown-source', 'retired-source']) { + assert.equal(resolvedConnectionId(registry, { ...inferableRemoteA, connectionId }), null) + } + + // Registry order is never authority for either an exact current id or a + // rejected explicit claim. + const reordered = { ...registry, connections: [...registry.connections].reverse() } + + assert.equal( + resolvedConnectionId(reordered, { + ...inferableRemoteA, + connectionId: 'remote-b' + }), + 'remote-b' + ) + assert.equal(resolvedConnectionId(reordered, { ...inferableRemoteA, connectionId: 'retired-source' }), null) +}) + +test('resolvedConnectionId reuses the exact URL envelope and rejects weak or duplicate matches', () => { + const sharedUrl = 'https://shared.example/gateway' + + const registry: ConnectionRegistry = { + version: REGISTRY_VERSION, + primary: 'remote-token', + launchMode: 'primary', + lastUsed: 'remote-token', + connections: [ + { id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' }, + { + id: 'remote-token', + kind: 'remote', + label: 'Token remote', + url: sharedUrl, + authMode: 'token', + token: { encoding: 'safeStorage', value: 'token-a' }, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } } + }, + { + id: 'remote-oauth', + kind: 'remote', + label: 'OAuth remote', + url: `${sharedUrl}/`, + authMode: 'oauth', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-b' } } + }, + { + id: 'cloud-nous', + kind: 'cloud', + label: 'Nous cloud', + url: sharedUrl, + authMode: 'oauth', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } }, + org: 'nous' + }, + { + id: 'cloud-labs', + kind: 'cloud', + label: 'Labs cloud', + url: sharedUrl, + authMode: 'oauth', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } }, + org: 'labs' + } + ] + } + + assert.equal( + resolvedConnectionId(registry, { + authMode: 'token', + baseUrl: sharedUrl, + headers: { 'cf-access-client-id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote', + remoteKind: 'url', + token: { encoding: 'safeStorage', value: 'token-a' } + }), + 'remote-token' + ) + assert.equal( + resolvedConnectionId(registry, { + authMode: 'oauth', + baseUrl: sharedUrl, + headers: { 'CF-ACCESS-CLIENT-ID': { encoding: 'safeStorage', value: 'header-b' } }, + mode: 'remote', + remoteKind: 'url' + }), + 'remote-oauth' + ) + assert.equal( + resolvedConnectionId(registry, { + authMode: 'oauth', + baseUrl: sharedUrl, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } }, + mode: 'remote', + org: 'nous', + remoteKind: 'cloud' + }), + 'cloud-nous' + ) + assert.equal( + resolvedConnectionId(registry, { + authMode: 'oauth', + baseUrl: sharedUrl, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } }, + mode: 'remote', + org: 'labs', + remoteKind: 'cloud' + }), + 'cloud-labs' + ) + + // Post-dial URL-only shapes do not contain enough proof to choose a source. + assert.equal( + resolvedConnectionId(registry, { baseUrl: sharedUrl, mode: 'remote', remoteKind: 'url' }), + null + ) + assert.equal( + resolvedConnectionId(registry, { baseUrl: sharedUrl, mode: 'remote', remoteKind: 'cloud' }), + null + ) + + // Even a complete envelope fails closed when two registrations are exact twins. + const duplicate: ConnectionRegistry = { + ...registry, + connections: [ + ...registry.connections, + { ...registry.connections.find(connection => connection.id === 'remote-token')!, id: 'remote-token-copy' } + ] + } + + assert.equal( + resolvedConnectionId(duplicate, { + authMode: 'token', + baseUrl: sharedUrl, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote', + remoteKind: 'url', + token: { encoding: 'safeStorage', value: 'token-a' } + }), + null + ) + assert.equal( + resolvedConnectionId( + { ...duplicate, connections: [...duplicate.connections].reverse() }, + { + authMode: 'token', + baseUrl: sharedUrl, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote', + remoteKind: 'url', + token: { encoding: 'safeStorage', value: 'token-a' } + } + ), + null + ) +}) + +test('resolvedConnectionId keeps same-host SSH routes distinct by port, key, path, and profile', () => { + const base = { + host: 'work-host', + keyPath: '/keys/a', + kind: 'ssh' as const, + remoteHermesPath: '/srv/hermes', + remoteProfile: 'alpha', + user: 'root' + } + + const registry: ConnectionRegistry = { + version: REGISTRY_VERSION, + primary: 'ssh-base', + launchMode: 'primary', + lastUsed: 'ssh-base', + connections: [ + { id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' }, + { ...base, id: 'ssh-base', label: 'SSH base' }, + { ...base, id: 'ssh-port', label: 'SSH port', port: 2222 }, + { ...base, id: 'ssh-key', keyPath: '/keys/b', label: 'SSH key' }, + { ...base, id: 'ssh-path', label: 'SSH path', remoteHermesPath: '/opt/hermes' }, + { ...base, id: 'ssh-profile', label: 'SSH profile', remoteProfile: 'beta' } + ] + } + + const resolve = (ssh: NonNullable[1]['ssh']>) => + resolvedConnectionId(registry, { mode: 'remote', remoteKind: 'ssh', ssh }) + + assert.equal(resolve(base), 'ssh-base') + assert.equal(resolve({ ...base, port: 2222 }), 'ssh-port') + assert.equal(resolve({ ...base, keyPath: '/keys/b' }), 'ssh-key') + assert.equal(resolve({ ...base, remoteHermesPath: '/opt/hermes' }), 'ssh-path') + assert.equal(resolve({ ...base, remoteProfile: 'beta' }), 'ssh-profile') + assert.equal( + resolvedConnectionId(registry, { + connectionId: 'ssh-port', + mode: 'remote', + remoteKind: 'ssh', + ssh: { ...base, port: 9999 } + }), + 'ssh-port' + ) + + // user@host is a transport hint, not a registry identity, when variants coexist. + assert.equal( + resolvedConnectionId(registry, { + mode: 'remote', + remoteHost: 'ROOT@WORK-HOST', + remoteKind: 'ssh' + }), + null + ) + assert.equal( + resolvedConnectionId(registry, { + mode: 'remote', + remoteHost: 'ROOT@WORK-HOST', + remoteKind: 'ssh', + ssh: undefined + }), + null + ) + + const duplicate: ConnectionRegistry = { + ...registry, + connections: [...registry.connections, { ...base, id: 'ssh-base-copy', label: 'SSH base copy' }] + } + + assert.equal( + resolvedConnectionId(duplicate, { mode: 'remote', remoteKind: 'ssh', ssh: base }), + null + ) + assert.equal( + resolvedConnectionId( + { ...duplicate, connections: [...duplicate.connections].reverse() }, + { mode: 'remote', remoteKind: 'ssh', ssh: base } + ), + null + ) +}) + test('connectionIdForLabel suffixes on collision and never mints "local"', () => { assert.equal(connectionIdForLabel('Homelab', []), 'homelab') assert.equal(connectionIdForLabel('Homelab', ['homelab']), 'homelab-2') diff --git a/apps/desktop/electron/connection-registry.ts b/apps/desktop/electron/connection-registry.ts index cec23fea90c1..c2e8edd672d9 100644 --- a/apps/desktop/electron/connection-registry.ts +++ b/apps/desktop/electron/connection-registry.ts @@ -35,6 +35,7 @@ import { normalizeSshConfig, normAuthMode } from './connection-config' +import { matchingConnectionId, type StoredRoute } from './connection-route-identity' export const REGISTRY_VERSION = 2 @@ -183,24 +184,63 @@ export interface RegistryLocalRoute { poolKey: string } +export interface ResolvedConnectionSshDescriptor { + host?: string + keyPath?: string + port?: number + remoteHermesPath?: string + remoteProfile?: string + user?: string +} + export interface ResolvedConnectionDescriptor { + authMode?: unknown baseUrl?: string + /** Property presence means this descriptor claims registry qualification. + * Invalid or retired claims fail closed; only descriptors with no such + * property may enter the legacy compatibility resolver. */ + connectionId?: unknown + headers?: Record mode?: 'local' | 'remote' + org?: unknown remoteHost?: string remoteKind?: 'cloud' | 'ssh' | 'url' + ssh?: ResolvedConnectionSshDescriptor + token?: unknown } /** * Recover registry identity for a descriptor resolved through the legacy v1 - * profile path. Registry-scoped routes already carry `connectionId`; this - * bridge keeps migrated per-profile remotes truthful until v1 is retired. + * profile path. Registry-scoped routes already carry `connectionId`; that + * exact identity is authoritative only while it names a current registry + * entry. Only genuinely unqualified descriptors may use compatibility + * inference, which keeps migrated per-profile remotes truthful until v1 is + * retired without letting malformed qualification fall through to a weaker + * endpoint-shaped identity. */ export function resolvedConnectionId( registry: ConnectionRegistry, descriptor: ResolvedConnectionDescriptor ): null | string { + if (Object.prototype.hasOwnProperty.call(descriptor, 'connectionId')) { + const explicitConnectionId = descriptor.connectionId + + // Presence is authoritative even when the value is unusable. Never turn + // a malformed, blank, unknown, or retired registry claim into permission + // to infer a different source from mutable endpoint metadata. + if (typeof explicitConnectionId !== 'string' || !explicitConnectionId.trim()) { + return null + } + + return registry.connections.some(connection => connection.id === explicitConnectionId) + ? explicitConnectionId + : null + } + if (descriptor.mode === 'local') { - return registry.connections.find(connection => connection.kind === 'local')?.id ?? null + const localConnections = registry.connections.filter(connection => connection.kind === 'local') + + return localConnections.length === 1 ? localConnections[0].id : null } if (descriptor.mode !== 'remote') { @@ -208,52 +248,113 @@ export function resolvedConnectionId( } if (descriptor.remoteKind === 'ssh') { - const remoteHost = String(descriptor.remoteHost || '') - .trim() - .toLowerCase() + if (Object.prototype.hasOwnProperty.call(descriptor, 'ssh')) { + if (!descriptor.ssh || typeof descriptor.ssh !== 'object') { + return null + } - if (!remoteHost) { - return null + return matchingConnectionId(registry, { ...descriptor.ssh, kind: 'ssh' }, 'unique') ?? null } - return ( - registry.connections.find(connection => { - if (connection.kind !== 'ssh') { - return false - } + // Old descriptors expose only user@host after the tunnel has discarded + // port/key/path/profile. That weak shape is compatible only when exactly + // one registered SSH source even shares the target, and the defaulted + // route still satisfies the canonical #88922 full-envelope matcher. + const ssh = normalizeSshConfig({ mode: 'ssh', host: descriptor.remoteHost }) - const host = String(connection.host || '') - .trim() - .toLowerCase() + if (!ssh) { + return null + } - const target = connection.user ? `${String(connection.user).trim().toLowerCase()}@${host}` : host + const target = normalizedSshTarget(ssh) - return target === remoteHost - })?.id ?? null + const coarseMatches = registry.connections.filter( + connection => connection.kind === 'ssh' && normalizedSshTarget(connection) === target ) + + if (!target || coarseMatches.length !== 1) { + return null + } + + return matchingConnectionId(registry, { kind: 'ssh', ...ssh }, 'unique') ?? null } - let baseUrl = '' + const kind = descriptor.remoteKind === 'cloud' ? 'cloud' : descriptor.remoteKind === 'url' ? 'remote' : null + + if (!kind) { + return null + } + + let url = '' try { - baseUrl = normalizeRemoteBaseUrl(descriptor.baseUrl) + url = normalizeRemoteBaseUrl(descriptor.baseUrl) } catch { return null } - return ( - registry.connections.find(connection => { - if (connection.kind !== 'cloud' && connection.kind !== 'remote') { + const authMode = normAuthMode(descriptor.authMode) + + const route: StoredRoute = { + authMode, + headers: descriptor.headers, + kind, + org: descriptor.org, + token: descriptor.token, + url + } + + const hasExactEnvelope = + Object.prototype.hasOwnProperty.call(descriptor, 'authMode') && + Object.prototype.hasOwnProperty.call(descriptor, 'headers') && + (authMode === 'oauth' || Object.prototype.hasOwnProperty.call(descriptor, 'token')) && + (kind === 'remote' || Object.prototype.hasOwnProperty.call(descriptor, 'org')) + + if (!hasExactEnvelope) { + // A URL alone cannot choose among legal registrations that differ by auth, + // headers, Cloud organization, or account. Require one coarse candidate + // before the same full-envelope matcher is allowed to accept the legacy + // defaults; otherwise zero/multiple candidates fail closed. + const coarseMatches = registry.connections.filter(connection => { + if (connection.kind !== kind) { return false } try { - return normalizeRemoteBaseUrl(connection.url) === baseUrl + return normalizeRemoteBaseUrl(connection.url) === url } catch { return false } - })?.id ?? null - ) + }) + + if (coarseMatches.length !== 1) { + return null + } + } + + return matchingConnectionId(registry, route, 'unique') ?? null +} + +function normalizedSshTarget(route: { + host?: unknown + port?: unknown + user?: unknown +}): null | string { + const ssh = normalizeSshConfig({ ...route, mode: 'ssh' }) + + if (!ssh) { + return null + } + + const host = String(ssh.host || '') + .trim() + .toLowerCase() + + const user = String(ssh.user || '') + .trim() + .toLowerCase() + + return user ? `${user}@${host}` : host } /** diff --git a/apps/desktop/electron/connection-route-identity.ts b/apps/desktop/electron/connection-route-identity.ts new file mode 100644 index 000000000000..b95e59f2963c --- /dev/null +++ b/apps/desktop/electron/connection-route-identity.ts @@ -0,0 +1,136 @@ +/** + * Canonical registry route identity for Desktop. + * + * Identity is frozen before dialing, while the complete auth/transport + * envelope still exists. Compatibility callers may reuse the same contract, + * but must never reconstruct a stronger identity from post-dial metadata. + */ + +import { + normalizeRemoteBaseUrl, + normalizeRemoteHeaders, + normalizeSshConfig, + normAuthMode +} from './connection-config' +import type { ConnectionRegistry, RegistryConnection } from './connection-registry' + +interface SshRouteConfig { + host: string + keyPath?: string + mode: 'ssh' + port?: number + remoteHermesPath?: string + remoteProfile?: string + user?: string +} + +export type StoredRoute = + | { + authMode?: unknown + headers?: Record + kind: 'cloud' | 'remote' + org?: unknown + token?: unknown + url?: unknown + } + | ({ kind: 'ssh' } & Partial) + +function stableValue(value: unknown): string { + if (!value || typeof value !== 'object') { + return JSON.stringify(value ?? null) + } + + if (Array.isArray(value)) { + return `[${value.map(stableValue).join(',')}]` + } + + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableValue(item)}`) + .join(',')}}` +} + +function canonicalHeaders(headers: unknown): Record { + return Object.fromEntries( + Object.entries(normalizeRemoteHeaders(headers)) + .map(([name, value]): [string, unknown] => [name.toLowerCase(), value]) + .sort(([left], [right]) => left.localeCompare(right)) + ) +} + +function routeIdentity(route: StoredRoute): null | string { + if (route.kind === 'ssh') { + const ssh = normalizeSshConfig({ ...route, mode: 'ssh' }) + + if (!ssh) { + return null + } + + return stableValue({ + host: ssh.host.trim().toLowerCase(), + keyPath: ssh.keyPath || '', + kind: 'ssh', + port: ssh.port || 22, + remoteHermesPath: ssh.remoteHermesPath || '', + remoteProfile: ssh.remoteProfile || '', + user: (ssh.user || '').trim().toLowerCase() + }) + } + + try { + const authMode = normAuthMode(route.authMode) + + return stableValue({ + authMode, + headers: canonicalHeaders(route.headers), + kind: route.kind, + org: route.kind === 'cloud' ? String(route.org || '').trim() : '', + token: authMode === 'token' ? (route.token ?? null) : null, + url: normalizeRemoteBaseUrl(route.url) + }) + } catch { + return null + } +} + +function registryRoute(connection: RegistryConnection): null | StoredRoute { + if (connection.kind === 'local') { + return null + } + + return connection as StoredRoute +} + +/** + * Match the complete pre-dial route identity used by #88922. + * + * `primary` accepts only the configured primary when its full envelope is + * equal. `unique` accepts exactly one full-envelope match. Zero and multiple + * matches deliberately remain unresolved. + */ +export function matchingConnectionId( + registry: ConnectionRegistry, + route: StoredRoute, + strategy: 'primary' | 'unique' +): undefined | string { + const identity = routeIdentity(route) + + if (!identity) { + return undefined + } + + if (strategy === 'primary') { + const primary = registry.connections.find(connection => connection.id === registry.primary) + const candidate = primary && registryRoute(primary) + + return candidate && routeIdentity(candidate) === identity ? primary.id : undefined + } + + const matches = registry.connections.filter(connection => { + const candidate = registryRoute(connection) + + return candidate ? routeIdentity(candidate) === identity : false + }) + + return matches.length === 1 ? matches[0].id : undefined +} diff --git a/apps/desktop/electron/desktop-remote-route.ts b/apps/desktop/electron/desktop-remote-route.ts index 16f44d05ef26..c7c8a5cbc687 100644 --- a/apps/desktop/electron/desktop-remote-route.ts +++ b/apps/desktop/electron/desktop-remote-route.ts @@ -1,20 +1,13 @@ -/** - * Pure route planner for Desktop's legacy v1 connection config. It preserves - * v2 registry provenance before URL normalization, OAuth, or SSH tunnelling - * discard dial details. Environment routes deliberately remain unregistered. - */ - import { connectionScopeKey, modeIsRemoteLike, - normalizeRemoteBaseUrl, - normalizeRemoteHeaders, normalizeSshConfig, normAuthMode, profileRemoteOverride, profileSshOverride } from './connection-config' -import type { ConnectionRegistry, RegistryConnection } from './connection-registry' +import type { ConnectionRegistry } from './connection-registry' +import { matchingConnectionId, type StoredRoute } from './connection-route-identity' type RouteSource = 'env' | 'profile' | 'settings' @@ -54,110 +47,6 @@ export interface DesktopRemoteRouteInput { registry: ConnectionRegistry } -type StoredRoute = - | { - authMode?: unknown - headers?: Record - kind: 'cloud' | 'remote' - org?: unknown - token?: unknown - url?: unknown - } - | ({ kind: 'ssh' } & Partial) - -function stableValue(value: unknown): string { - if (!value || typeof value !== 'object') { - return JSON.stringify(value ?? null) - } - - if (Array.isArray(value)) { - return `[${value.map(stableValue).join(',')}]` - } - - return `{${Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, item]) => `${JSON.stringify(key)}:${stableValue(item)}`) - .join(',')}}` -} - -function canonicalHeaders(headers: unknown): Record { - return Object.fromEntries( - Object.entries(normalizeRemoteHeaders(headers)) - .map(([name, value]): [string, unknown] => [name.toLowerCase(), value]) - .sort(([left], [right]) => left.localeCompare(right)) - ) -} - -function routeIdentity(route: StoredRoute): null | string { - if (route.kind === 'ssh') { - const ssh = normalizeSshConfig({ ...route, mode: 'ssh' }) - - if (!ssh) { - return null - } - - return stableValue({ - host: ssh.host, - keyPath: ssh.keyPath || '', - kind: 'ssh', - port: ssh.port || 22, - remoteHermesPath: ssh.remoteHermesPath || '', - remoteProfile: ssh.remoteProfile || '', - user: ssh.user || '' - }) - } - - try { - const authMode = normAuthMode(route.authMode) - - return stableValue({ - authMode, - headers: canonicalHeaders(route.headers), - kind: route.kind, - org: route.kind === 'cloud' ? String(route.org || '').trim() : '', - token: authMode === 'token' ? (route.token ?? null) : null, - url: normalizeRemoteBaseUrl(route.url) - }) - } catch { - return null - } -} - -function registryRoute(connection: RegistryConnection): null | StoredRoute { - if (connection.kind === 'local') { - return null - } - - return connection as StoredRoute -} - -function matchingConnectionId( - registry: ConnectionRegistry, - route: StoredRoute, - strategy: 'primary' | 'unique' -): undefined | string { - const identity = routeIdentity(route) - - if (!identity) { - return undefined - } - - if (strategy === 'primary') { - const primary = registry.connections.find(connection => connection.id === registry.primary) - const candidate = primary && registryRoute(primary) - - return candidate && routeIdentity(candidate) === identity ? primary.id : undefined - } - - const matches = registry.connections.filter(connection => { - const candidate = registryRoute(connection) - - return candidate ? routeIdentity(candidate) === identity : false - }) - - return matches.length === 1 ? matches[0].id : undefined -} - function withConnectionId(route: T, connectionId?: string): T & { connectionId?: string } { return connectionId ? { ...route, connectionId } : route } diff --git a/apps/desktop/src/store/connections.test.ts b/apps/desktop/src/store/connections.test.ts index ec92be722a55..b483fec39194 100644 --- a/apps/desktop/src/store/connections.test.ts +++ b/apps/desktop/src/store/connections.test.ts @@ -113,6 +113,17 @@ describe('connection registry cache', () => { expect(ensureGatewayAgent).not.toHaveBeenCalled() }) + it('restores a remote registry primary through its exact connection id', async () => { + list.mockResolvedValueOnce({ ...registry, primary: 'homelab', launchMode: 'primary' }) + $connection.set({ connectionId: 'local', mode: 'local' }) + + await initializeConnectionsRegistry() + + expect(ensureGatewayAgent).toHaveBeenCalledTimes(1) + expect(ensureGatewayAgent).toHaveBeenCalledWith('homelab', 'default') + expect(setLastUsed).toHaveBeenCalledWith('homelab') + }) + it('uses only the resolved descriptor identity for the active gateway', () => { setConnectionsRegistry({ ...registry, primary: 'homelab' }) $connection.set({ connectionId: 'work-vps', mode: 'remote' }) From d49d495c2b07a31b5d314f22946be24917d0ca4b Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:06:17 +0000 Subject: [PATCH 040/666] fmt(js): `npm run fix` on merge (#92714) Co-authored-by: github-actions[bot] --- .../electron/connection-registry.test.ts | 20 ++++--------------- apps/desktop/electron/connection-registry.ts | 10 ++-------- .../electron/connection-route-identity.ts | 7 +------ apps/desktop/src/store/settings-scope.test.ts | 3 ++- 4 files changed, 9 insertions(+), 31 deletions(-) diff --git a/apps/desktop/electron/connection-registry.test.ts b/apps/desktop/electron/connection-registry.test.ts index 4b215bfeda33..ce90a9a01cb5 100644 --- a/apps/desktop/electron/connection-registry.test.ts +++ b/apps/desktop/electron/connection-registry.test.ts @@ -90,10 +90,7 @@ test('resolvedConnectionId identifies local and migrated remote descriptors', () const ambiguousLocal: ConnectionRegistry = { ...registry, - connections: [ - ...registry.connections, - { id: 'local-copy', kind: 'local', label: 'Local copy' } - ] + connections: [...registry.connections, { id: 'local-copy', kind: 'local', label: 'Local copy' }] } assert.equal(resolvedConnectionId(ambiguousLocal, { mode: 'local' }), null) @@ -283,14 +280,8 @@ test('resolvedConnectionId reuses the exact URL envelope and rejects weak or dup ) // Post-dial URL-only shapes do not contain enough proof to choose a source. - assert.equal( - resolvedConnectionId(registry, { baseUrl: sharedUrl, mode: 'remote', remoteKind: 'url' }), - null - ) - assert.equal( - resolvedConnectionId(registry, { baseUrl: sharedUrl, mode: 'remote', remoteKind: 'cloud' }), - null - ) + assert.equal(resolvedConnectionId(registry, { baseUrl: sharedUrl, mode: 'remote', remoteKind: 'url' }), null) + assert.equal(resolvedConnectionId(registry, { baseUrl: sharedUrl, mode: 'remote', remoteKind: 'cloud' }), null) // Even a complete envelope fails closed when two registrations are exact twins. const duplicate: ConnectionRegistry = { @@ -395,10 +386,7 @@ test('resolvedConnectionId keeps same-host SSH routes distinct by port, key, pat connections: [...registry.connections, { ...base, id: 'ssh-base-copy', label: 'SSH base copy' }] } - assert.equal( - resolvedConnectionId(duplicate, { mode: 'remote', remoteKind: 'ssh', ssh: base }), - null - ) + assert.equal(resolvedConnectionId(duplicate, { mode: 'remote', remoteKind: 'ssh', ssh: base }), null) assert.equal( resolvedConnectionId( { ...duplicate, connections: [...duplicate.connections].reverse() }, diff --git a/apps/desktop/electron/connection-registry.ts b/apps/desktop/electron/connection-registry.ts index c2e8edd672d9..7c853bba19ff 100644 --- a/apps/desktop/electron/connection-registry.ts +++ b/apps/desktop/electron/connection-registry.ts @@ -232,9 +232,7 @@ export function resolvedConnectionId( return null } - return registry.connections.some(connection => connection.id === explicitConnectionId) - ? explicitConnectionId - : null + return registry.connections.some(connection => connection.id === explicitConnectionId) ? explicitConnectionId : null } if (descriptor.mode === 'local') { @@ -335,11 +333,7 @@ export function resolvedConnectionId( return matchingConnectionId(registry, route, 'unique') ?? null } -function normalizedSshTarget(route: { - host?: unknown - port?: unknown - user?: unknown -}): null | string { +function normalizedSshTarget(route: { host?: unknown; port?: unknown; user?: unknown }): null | string { const ssh = normalizeSshConfig({ ...route, mode: 'ssh' }) if (!ssh) { diff --git a/apps/desktop/electron/connection-route-identity.ts b/apps/desktop/electron/connection-route-identity.ts index b95e59f2963c..72124fe3e2a0 100644 --- a/apps/desktop/electron/connection-route-identity.ts +++ b/apps/desktop/electron/connection-route-identity.ts @@ -6,12 +6,7 @@ * but must never reconstruct a stronger identity from post-dial metadata. */ -import { - normalizeRemoteBaseUrl, - normalizeRemoteHeaders, - normalizeSshConfig, - normAuthMode -} from './connection-config' +import { normalizeRemoteBaseUrl, normalizeRemoteHeaders, normalizeSshConfig, normAuthMode } from './connection-config' import type { ConnectionRegistry, RegistryConnection } from './connection-registry' interface SshRouteConfig { diff --git a/apps/desktop/src/store/settings-scope.test.ts b/apps/desktop/src/store/settings-scope.test.ts index 321ae5d31c7b..2f38b666860d 100644 --- a/apps/desktop/src/store/settings-scope.test.ts +++ b/apps/desktop/src/store/settings-scope.test.ts @@ -17,7 +17,8 @@ vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() } vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() })) const { $activeGatewayProfile } = await import('./profile') -const { $settingsRequestProfile, $settingsScopeOverride, $settingsScopeProfile, setSettingsScope } = await import('./settings-scope') +const { $settingsRequestProfile, $settingsScopeOverride, $settingsScopeProfile, setSettingsScope } = + await import('./settings-scope') beforeEach(() => { $activeGatewayProfile.set('default') From 8b09a9df8476010a78e86d6c32254b4ac14a8c4f Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:11:16 +0000 Subject: [PATCH 041/666] fmt(js): `npm run fix` on merge (#92718) Co-authored-by: github-actions[bot] --- apps/desktop/src/store/settings-scope.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/store/settings-scope.test.ts b/apps/desktop/src/store/settings-scope.test.ts index 2f38b666860d..c337bcf6fc7a 100644 --- a/apps/desktop/src/store/settings-scope.test.ts +++ b/apps/desktop/src/store/settings-scope.test.ts @@ -17,6 +17,7 @@ vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() } vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() })) const { $activeGatewayProfile } = await import('./profile') + const { $settingsRequestProfile, $settingsScopeOverride, $settingsScopeProfile, setSettingsScope } = await import('./settings-scope') From 4e4ee7ee75cc19ea4da089e0dd1bf549905df604 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:25:59 -0700 Subject: [PATCH 042/666] style: eslint --fix on merged TS surfaces --- apps/desktop/src/sdk/index.ts | 7 +++++-- apps/desktop/src/store/session.ts | 4 +++- apps/desktop/src/store/transcript-tail.ts | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 9b2b80e6f31b..1c9cf344aa15 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -71,8 +71,8 @@ import { rememberedSessionProfile, requestSessionResume, sessionMatchesStoredId, - setSessionOwnerHint, - setResumeExhaustedSessionId + setResumeExhaustedSessionId, + setSessionOwnerHint } from '@/store/session' import { $focusedRuntimeId, @@ -135,6 +135,7 @@ const $focusedSessionOwner = computed( const activeConnectionId = String( connection?.connectionId || (connection?.mode === 'local' ? 'local' : '') ).trim() + const fallback = { connectionId: activeConnectionId, profile: normalizeProfileKey(activeProfile) @@ -158,6 +159,7 @@ const $focusedSessionOwner = computed( } const owners = new Map() + for (const row of sessions.filter(session => sessionMatchesStoredId(session, focused))) { const connectionId = String(row.connection_id || '').trim() const profile = normalizeProfileKey(row.profile) @@ -543,6 +545,7 @@ export const host = { profile: String(profile.profile || '').trim(), targetProfile: String(profile.targetProfile || '').trim() } + const name = typeof profile === 'string' ? profile.trim() : route?.profile || '' if (route && (!route.connectionId || !route.profile || !route.targetProfile)) { diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 421e63e23b07..330d9c57cc29 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -9,8 +9,8 @@ import { activeConnectionScopeSuffix, rescopeConnectionScopedStores } from '@/li import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage' import { syncCronModelImpactConnection } from '@/store/cron-model-impact-scope' import type { SessionInfo, UsageStats } from '@/types/hermes' -import type { SessionProfileRoute } from './session-request-router' +import type { SessionProfileRoute } from './session-request-router' import { clearUnreadOnOpen } from './session-unread-remote' type Updater = T | ((current: T) => T) @@ -615,6 +615,7 @@ function sessionOwnerHintKey(sessionId: string, route: Pick = profile === undefined ? matchingTailEntries(storedSessionId) : [[transcriptTailKey(storedSessionId, profile), current[transcriptTailKey(storedSessionId, profile)]]] @@ -166,6 +167,7 @@ export function transcriptTailState( export function clearTranscriptTail(storedSessionId: string, profile?: TranscriptProfileScope): void { const current = $transcriptTailBySessionId.get() + const keys = profile === undefined ? matchingTailEntries(storedSessionId).map(([key]) => key) : [transcriptTailKey(storedSessionId, profile)] From 278dac43b1f83fd59c0c00530239f36237490871 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:26:08 -0700 Subject: [PATCH 043/666] chore: map contributor email for attribution gate --- contributors/emails/sara@lilyb.org | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/sara@lilyb.org diff --git a/contributors/emails/sara@lilyb.org b/contributors/emails/sara@lilyb.org new file mode 100644 index 000000000000..9c1c49af559c --- /dev/null +++ b/contributors/emails/sara@lilyb.org @@ -0,0 +1 @@ +saralilyb From e6962b818ca447cd5216cfc8aa7c3ddbc8e915dd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:35:55 -0700 Subject: [PATCH 044/666] style: eslint --fix across src/ + electron/ (CI lints the full tree); drop unused destructure --- .../desktop/electron/profile-delete-routing.test.ts | 13 +++++++++++++ apps/desktop/src/app/chat/index.tsx | 5 ++++- .../src/app/chat/transcript-backfill.test.ts | 4 ++++ .../src/app/session/hooks/use-route-resume.test.tsx | 3 +++ .../src/app/session/hooks/use-route-resume.ts | 2 ++ .../app/session/hooks/use-session-actions.test.tsx | 10 ++++++++-- .../app/session/hooks/use-session-actions/index.ts | 8 +++++++- .../app/session/hooks/use-session-actions/utils.ts | 1 + .../hermes-bots/tests/remote-routing-races.test.mjs | 2 +- apps/desktop/src/sdk/profile-routing.test.ts | 2 ++ .../src/store/session-request-router.test.ts | 6 ++++++ apps/desktop/src/store/session.test.ts | 2 +- 12 files changed, 52 insertions(+), 6 deletions(-) diff --git a/apps/desktop/electron/profile-delete-routing.test.ts b/apps/desktop/electron/profile-delete-routing.test.ts index b7f3b97e43a8..236d4b3fa1fd 100644 --- a/apps/desktop/electron/profile-delete-routing.test.ts +++ b/apps/desktop/electron/profile-delete-routing.test.ts @@ -156,6 +156,7 @@ test('resolveRouteProfile preserves a primary-backend route from another routing test('explicit registered local DELETE holds one gate through teardown and dispatch', async () => { const events: string[] = [] const gate = new ProfileDeletionGate() + const result = await dispatchConnectionScopedProfileDelete( { connectionId: 'local', method: 'DELETE', path: '/api/profiles/worker', profile: 'worker' }, { @@ -163,6 +164,7 @@ test('explicit registered local DELETE holds one gate through teardown and dispa acquire: profile => { events.push(`gate:${profile}`) const release = gate.acquire(profile) + return () => { events.push(`release:${profile}`) release() @@ -172,6 +174,7 @@ test('explicit registered local DELETE holds one gate through teardown and dispa dispatch: async routeProfile => { assert.equal(gate.blocks('worker'), true) events.push(`dispatch:${routeProfile ?? 'primary'}`) + return 'deleted' }, prepareLocal: async request => { @@ -193,17 +196,20 @@ test('explicit registered local DELETE holds one gate through teardown and dispa test('explicit registered SSH DELETE tears down its source backend before dispatch', async () => { const events: string[] = [] + const result = await dispatchConnectionScopedProfileDelete( { connectionId: 'build-host', method: 'DELETE', path: '/api/profiles/worker', profile: 'worker' }, { ...deps, acquire: profile => { events.push(`gate:${profile}`) + return () => events.push(`release:${profile}`) }, connectionKind: () => 'ssh', dispatch: async routeProfile => { events.push(`dispatch:${routeProfile ?? 'primary'}`) + return 'deleted' }, prepareLocal: async () => assert.fail('SSH deletion must not use local profile teardown'), @@ -224,6 +230,7 @@ test('explicit registered SSH DELETE tears down its source backend before dispat test('non-identity connection DELETE tears down the logical pool and deletes the backend target', async () => { const events: string[] = [] + const request = { connectionId: 'source-a', method: 'DELETE', @@ -235,11 +242,13 @@ test('non-identity connection DELETE tears down the logical pool and deletes the ...deps, acquire: profile => { events.push(`gate:${profile}`) + return () => events.push(`release:${profile}`) }, connectionKind: () => 'ssh', dispatch: async routeProfile => { events.push(`delete:${request.path}:${routeProfile ?? 'primary'}`) + return 'deleted' }, prepareLocal: async () => assert.fail('remote deletion must not use local profile teardown'), @@ -266,11 +275,13 @@ test('connection-scoped default DELETE rejects before gate, preparation, teardow { acquire: profile => { events.push(`gate:${profile}`) + return () => events.push(`release:${profile}`) }, connectionKind: () => 'ssh', dispatch: async () => { events.push('dispatch') + return 'deleted' }, isDefaultProfile: profile => profile === 'default', @@ -298,11 +309,13 @@ test('connection-scoped invalid-name DELETE rejects before gate, preparation, te { acquire: profile => { events.push(`gate:${profile}`) + return () => events.push(`release:${profile}`) }, connectionKind: () => 'local', dispatch: async () => { events.push('dispatch') + return 'deleted' }, isDefaultProfile: profile => profile === 'default', diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index 1a7043c3cdc1..b0965a66621f 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -33,8 +33,8 @@ import { $petActive } from '@/store/pet' import { $petOverlayActive } from '@/store/pet-overlay' import { $activeGatewayProfile, $gatewaySwapTarget, $profiles } from '@/store/profile' import { - $contextSuggestions, $connection, + $contextSuggestions, $freshDraftReady, $gatewayState, $introPersonality, @@ -272,12 +272,15 @@ function ChatRuntimeBoundary({ // appears/retires as tail hydrations and backfill pages record their state. const transcriptTailStates = useStore($transcriptTailBySessionId) const connectionId = connection?.connectionId || (connection?.mode === 'local' ? 'local' : '') + const ownerRoute = storedId ? getSessionOwnerHint(storedId, connectionId ? { connectionId, profile: activeProfile } : undefined) : undefined + const tailProfile = ownerRoute ? { connectionId: ownerRoute.connectionId, profile: ownerRoute.targetProfile || ownerRoute.profile } : undefined + const tailState = storedId && transcriptTailStates ? transcriptTailState(storedId, tailProfile) : undefined const restBackfillAvailable = Boolean(tailState?.possiblyTruncated) diff --git a/apps/desktop/src/app/chat/transcript-backfill.test.ts b/apps/desktop/src/app/chat/transcript-backfill.test.ts index 5a020b51ef22..733c114cc625 100644 --- a/apps/desktop/src/app/chat/transcript-backfill.test.ts +++ b/apps/desktop/src/app/chat/transcript-backfill.test.ts @@ -74,6 +74,7 @@ describe('transcript tail bookkeeping', () => { messages: [row(1, 'tail')], pagination: { limit: 120, offset: 0, order: 'latest' as const, returned: 1 } } + const sourceA = { connectionId: 'source-a', profile: 'backend' } const sourceB = { connectionId: 'source-b', profile: 'backend' } @@ -91,6 +92,7 @@ describe('transcript tail bookkeeping', () => { messages: [row(1, 'tail')], pagination: { limit: 120, offset: 0, order: 'latest' as const, returned: 1 } } + const scope = { connectionId: 'source-a', profile: 'backend' } for (let index = 0; index < 257; index += 1) { @@ -202,10 +204,12 @@ describe('backfillOlderTranscriptPage', () => { it('backfills the matching connection when two owners share one session id', async () => { const sourceA = { connectionId: 'source-a', profile: 'backend-a' } const sourceB = { connectionId: 'source-b', profile: 'backend-b' } + const page = { messages: Array.from({ length: 120 }, (_, index) => row(index, `tail${index}`)), pagination: { limit: 120, offset: 0, order: 'latest' as const, returned: 120 } } + recordTranscriptTail('same-session', page, sourceA) recordTranscriptTail('same-session', page, sourceB) vi.mocked(getOlderSessionMessages).mockResolvedValue({ diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx index ca27979e955f..c33eeb2cbb6c 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx @@ -352,13 +352,16 @@ describe('useRouteResume', () => { const creatingSessionRef = { current: false } const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) } const selectedStoredSessionIdRef: MutableRefObject = { current: 'session-1' } + const ownerRoute: SessionProfileRoute = { connectionId: 'source-a', mode: 'remote', profile: 'worker', targetProfile: 'backend-worker' } + const request = { ownerRoute, sequence: 1, sessionId: 'session-1' } + const props = { activeSessionId: 'runtime-1', activeSessionIdRef, diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.ts b/apps/desktop/src/app/session/hooks/use-route-resume.ts index af51055dec24..ee135d3303cc 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.ts +++ b/apps/desktop/src/app/session/hooks/use-route-resume.ts @@ -178,8 +178,10 @@ export function useRouteResume({ } bootResumeRef.current = false + const ownerRoute = sessionResumeRequest?.sessionId === routedSessionId ? sessionResumeRequest.ownerRoute : undefined + if (ownerRoute) { void resumeSession(routedSessionId, true, ownerRoute) } else { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 7614a5c1f597..d21790c4b0f6 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -16,14 +16,13 @@ import { } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer' +import { requestGatewayForAgent } from '@/store/gateway' import { $activeGatewayProfile, $newChatProfile, $newChatRoute, ensureGatewayProfile } from '@/store/profile' -import { requestGatewayForAgent } from '@/store/gateway' -import type { SessionProfileRoute } from '@/store/session-request-router' import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' import { $activeSessionId, @@ -54,6 +53,7 @@ import { setSessions, setTurnStartedAt } from '@/store/session' +import type { SessionProfileRoute } from '@/store/session-request-router' import { $sessionTiles } from '@/store/session-states' import sessionResumeActiveTurn from '../../../../../../tests/fixtures/session-resume-active-turn.json' @@ -600,6 +600,7 @@ describe('createBackendSessionForSend profile routing', () => { profile: 'default', targetProfile: 'backend-default' } + const ambientRequest = vi.fn(async () => ({}) as never) vi.mocked(requestGatewayForAgent).mockResolvedValueOnce({ session_id: RUNTIME_SESSION_ID, @@ -1812,12 +1813,15 @@ describe('resumeSession warm-cache mapping integrity', () => { profile: 'default', targetProfile: 'backend-default' } + const runtimeIdByStoredSessionIdRef: MutableRefObject> = { current: new Map([['stored-warm', 'runtime-warm']]) } + const sessionStateByRuntimeIdRef: MutableRefObject> = { current: new Map([['runtime-warm', clientState('stored-warm')]]) } + // Same-name rows without a source tag are not authoritative for an // explicit owner. Metadata must be re-read from the captured connection. setSessions([storedSession({ id: 'stored-warm', profile: 'default' })]) @@ -1845,11 +1849,13 @@ describe('resumeSession warm-cache mapping integrity', () => { }) const ambientRequest = vi.fn(async () => ({}) as never) + let resume: null | (( storedSessionId: string, replaceRoute?: boolean, ownerRoute?: SessionProfileRoute ) => Promise) = null + render( (resume = ready)} diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 001e82b65609..7d2f11221e2f 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -46,6 +46,7 @@ import { $newChatWorkspaceTarget, $sessions, $yoloActive, + getSessionOwnerHint, type NewChatWorkspaceTarget, resolveComposerSessionKey, sessionPinId, @@ -67,7 +68,6 @@ import { setSelectedStoredSessionId, setSessions, setSessionStartedAt, - getSessionOwnerHint, setTurnStartedAt, setWorkspaceCwdOwner, setYoloActive @@ -473,6 +473,7 @@ export function useSessionActions({ const capturedRoute = $newChatRoute.get() const params = await desktopSessionCreateParams(cwd, capturedRoute) + const created = capturedRoute ? await requestGatewayForAgent( capturedRoute.connectionId, @@ -481,6 +482,7 @@ export function useSessionActions({ params ) : await requestGateway('session.create', params) + const stored = created.stored_session_id ?? null // Only a genuine move to a DIFFERENT chat mid-create should orphan the @@ -597,6 +599,7 @@ export function useSessionActions({ // (project scope → configured default). const capturedRoute = $newChatRoute.get() const params = await desktopSessionCreateParams((options?.cwd || resolveNewSessionCwd()).trim(), capturedRoute) + const created = capturedRoute ? await requestGatewayForAgent( capturedRoute.connectionId, @@ -605,6 +608,7 @@ export function useSessionActions({ params ) : await requestGateway('session.create', params) + const stored = created.stored_session_id if (!stored) { @@ -1785,12 +1789,14 @@ export function useSessionActions({ const closingRuntimeId = wasSelected ? activeSessionId : null const previousMessages = $messages.get() const previousPinned = $pinnedSessionIds.get() + const removedOwner: SessionOwnerScope = removed?.connection_id ? { connectionId: removed.connection_id, profile: removed.profile || 'default' } : removed?.profile + const previousArchived = $archivedSessions.get() // Pins are keyed on the durable lineage-root id; the stored id may be the // live tip after compression. Drop both so the pin can't linger. diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index d552c8bef718..f2e80ba75852 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -1317,6 +1317,7 @@ export async function resolveStoredSession( connectionId: ownerRoute.connectionId, profile: ownerRoute.targetProfile || ownerRoute.profile } + const cachedOwnerMatches = cached && cached.connection_id === ownerRoute.connectionId && (!cached.profile || normalizeProfileKey(cached.profile) === normalizeProfileKey(ownerRoute.profile)) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs index f53786c4e877..4f6da3d2c0d7 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs @@ -16,7 +16,7 @@ function deferred() { return { promise, resolve, reject } } -function load({ requestProfile, agents, profileRoutes, storage } = {}) { +function load({ requestProfile, agents, profileRoutes } = {}) { const values = new Map() const atom = initial => { const slot = { diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index d6a041b96565..6ab9fa07fc3c 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -274,6 +274,7 @@ describe('connection-aware plugin host APIs', () => { profile: 'worker', targetProfile: 'backend-worker' } + vi.mocked(deleteProfile).mockResolvedValueOnce({ ok: true, path: '/profiles/backend-worker' }) await host.deleteProfile(route) @@ -319,6 +320,7 @@ describe('connection-aware plugin host APIs', () => { profile: 'worker', targetProfile: 'backend-worker' } + vi.mocked(deleteProfile).mockResolvedValueOnce({ ok: true, path: '/profiles/backend-worker' }) await host.deleteProfile(route) diff --git a/apps/desktop/src/store/session-request-router.test.ts b/apps/desktop/src/store/session-request-router.test.ts index 5883c8f1693f..05b4dae4f094 100644 --- a/apps/desktop/src/store/session-request-router.test.ts +++ b/apps/desktop/src/store/session-request-router.test.ts @@ -149,15 +149,19 @@ describe('requestForSessionProfile', () => { setPrimaryGateway(primary as never, 'default') installDesktop() await ensureGatewayForProfile('other') + const desktop = (window as unknown as { hermesDesktop: { getConnectionFor: ReturnType } }).hermesDesktop + const ambient = vi.fn(async () => ({ ambient: true })) + const routeA = { connectionId: 'source-a', profile: 'default', targetProfile: 'backend-a' } + const routeB = { connectionId: 'source-b', profile: 'default', @@ -168,9 +172,11 @@ describe('requestForSessionProfile', () => { profile: 'default', session_id: 'stored-a' }) + routeA.connectionId = 'source-b' routeA.targetProfile = 'mutated-after-dispatch' await ensureGatewayForProfile('default') + const fromB = requestForSessionProfile(routeB, ambient as never, 'session.resume', { profile: 'default', session_id: 'stored-b' diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index a3518789772b..f4e0e7487b15 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -39,8 +39,8 @@ import { setCurrentCwdTransient, setRememberedRoute, setRememberedSessionId, - setSessionOwnerHint, setSelectedStoredSessionId, + setSessionOwnerHint, setSessions, shouldMigrateComposerScope, touchSessionActivity, From 8ede2e147278e2448d20fe310249b8cffa9cf463 Mon Sep 17 00:00:00 2001 From: Kevin Yin <182213728+yinkev@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:41:00 -0700 Subject: [PATCH 045/666] fix(models): discover Anthropic pool API keys --- hermes_cli/models.py | 42 ++++++++- .../test_anthropic_pool_model_discovery.py | 86 +++++++++++++++++++ 2 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 tests/hermes_cli/test_anthropic_pool_model_discovery.py diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 0486c25fbc3e..7107f7109f52 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -4519,6 +4519,36 @@ def clear_provider_models_cache(provider: Optional[str] = None) -> None: pass +def _resolve_anthropic_pool_catalog_credentials() -> tuple[str, str]: + """Return a read-only API-key pool credential for model discovery. + + ``resolve_anthropic_token()`` intentionally ignores ``api_key`` pool + entries because its runtime contract is OAuth-oriented. The model catalog + supports regular ``x-api-key`` auth, so it needs a narrow fallback that + preserves the credential's configured endpoint instead of sending a + proxy-scoped key to Anthropic's public host. + """ + try: + from agent.credential_pool import AUTH_TYPE_API_KEY + from hermes_cli.auth import read_credential_pool + + for entry in read_credential_pool("anthropic"): + if not isinstance(entry, dict): + continue + if entry.get("auth_type") != AUTH_TYPE_API_KEY: + continue + token = str(entry.get("access_token") or "").strip() + if not token: + continue + endpoint = str( + entry.get("base_url") or entry.get("inference_base_url") or "" + ).strip() + return token, endpoint + except Exception: + pass + return "", "" + + def _fetch_anthropic_models( timeout: float = 5.0, *, @@ -4527,8 +4557,9 @@ def _fetch_anthropic_models( ) -> Optional[list[str]]: """Fetch available models from the Anthropic /v1/models endpoint. - Uses resolve_anthropic_token() to find credentials (env vars or - Claude Code auto-discovery) unless api_key is provided explicitly. + Uses resolve_anthropic_token() to find credentials (env vars, OAuth, + or Claude Code auto-discovery) unless api_key is provided explicitly. If + those sources are empty, a read-only API-key credential_pool entry is used. Returns sorted model IDs or None. """ try: @@ -4536,7 +4567,12 @@ def _fetch_anthropic_models( except ImportError: return None + resolved_base_url = base_url token = (api_key or "").strip() or resolve_anthropic_token() + if not token: + token, pool_base_url = _resolve_anthropic_pool_catalog_credentials() + if not resolved_base_url and pool_base_url: + resolved_base_url = pool_base_url if not token: return None @@ -4551,7 +4587,7 @@ def _fetch_anthropic_models( def _do_request(h: dict[str, str]): req = urllib.request.Request( - _anthropic_models_url(base_url), + _anthropic_models_url(resolved_base_url), headers=h, ) with _urlopen_model_catalog_request(req, timeout=timeout) as resp: diff --git a/tests/hermes_cli/test_anthropic_pool_model_discovery.py b/tests/hermes_cli/test_anthropic_pool_model_discovery.py new file mode 100644 index 000000000000..d36c7d69eebd --- /dev/null +++ b/tests/hermes_cli/test_anthropic_pool_model_discovery.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import json + +from hermes_cli import models + + +class _Response: + def __init__(self, payload: dict): + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self) -> bytes: + return json.dumps(self._payload).encode() + + +def test_anthropic_picker_discovers_models_with_pool_api_key(monkeypatch): + """A direct API key stored only in auth.json must reach /v1/models.""" + monkeypatch.setattr(models, "_get_model_config_dict", lambda: {"provider": "nous"}) + monkeypatch.setattr( + "agent.anthropic_adapter.resolve_anthropic_token", + lambda: None, + ) + monkeypatch.setattr( + "hermes_cli.auth.read_credential_pool", + lambda provider: [ + { + "auth_type": "api_key", + "access_token": "sk-ant-api03-pool-key", + "base_url": "https://api.anthropic.com", + } + ] if provider == "anthropic" else [], + ) + + captured = {} + + def _open(request, *, timeout): + captured["url"] = request.full_url + captured["headers"] = {key.lower(): value for key, value in request.header_items()} + captured["timeout"] = timeout + return _Response({"data": [{"id": "claude-opus-5"}]}) + + monkeypatch.setattr(models, "_urlopen_model_catalog_request", _open) + + result = models.provider_model_ids("anthropic") + + assert "claude-opus-5" in result + assert captured["url"] == "https://api.anthropic.com/v1/models" + assert captured["headers"]["x-api-key"] == "sk-ant-api03-pool-key" + assert "authorization" not in captured["headers"] + + +def test_anthropic_pool_api_key_uses_its_configured_endpoint(monkeypatch): + """Never send a proxy-scoped pool credential to Anthropic's public host.""" + monkeypatch.setattr( + "agent.anthropic_adapter.resolve_anthropic_token", + lambda: None, + ) + monkeypatch.setattr( + "hermes_cli.auth.read_credential_pool", + lambda _provider: [ + { + "auth_type": "api_key", + "access_token": "proxy-key", + "base_url": "https://proxy.example/anthropic/v1", + } + ], + ) + + captured = {} + + def _open(request, *, timeout): + captured["url"] = request.full_url + captured["headers"] = {key.lower(): value for key, value in request.header_items()} + return _Response({"data": [{"id": "claude-proxy-model"}]}) + + monkeypatch.setattr(models, "_urlopen_model_catalog_request", _open) + + assert models._fetch_anthropic_models() == ["claude-proxy-model"] + assert captured["url"] == "https://proxy.example/anthropic/v1/models" + assert captured["headers"]["x-api-key"] == "proxy-key" From 9ce46a09681c0b632aecdedfc74bbe25fbaf3e15 Mon Sep 17 00:00:00 2001 From: Kevin Yin <182213728+yinkev@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:25:18 -0700 Subject: [PATCH 046/666] fix(models): bind Anthropic pool key to its endpoint --- hermes_cli/models.py | 6 +- .../test_anthropic_pool_model_discovery.py | 73 +++++++++++++------ 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 7107f7109f52..5dd8cccbb2df 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -4570,9 +4570,9 @@ def _fetch_anthropic_models( resolved_base_url = base_url token = (api_key or "").strip() or resolve_anthropic_token() if not token: - token, pool_base_url = _resolve_anthropic_pool_catalog_credentials() - if not resolved_base_url and pool_base_url: - resolved_base_url = pool_base_url + # A pool credential and its endpoint are one security boundary. Never + # pair the selected pool key with a caller-provided model endpoint. + token, resolved_base_url = _resolve_anthropic_pool_catalog_credentials() if not token: return None diff --git a/tests/hermes_cli/test_anthropic_pool_model_discovery.py b/tests/hermes_cli/test_anthropic_pool_model_discovery.py index d36c7d69eebd..74689a93fa93 100644 --- a/tests/hermes_cli/test_anthropic_pool_model_discovery.py +++ b/tests/hermes_cli/test_anthropic_pool_model_discovery.py @@ -28,20 +28,26 @@ def test_anthropic_picker_discovers_models_with_pool_api_key(monkeypatch): ) monkeypatch.setattr( "hermes_cli.auth.read_credential_pool", - lambda provider: [ - { - "auth_type": "api_key", - "access_token": "sk-ant-api03-pool-key", - "base_url": "https://api.anthropic.com", - } - ] if provider == "anthropic" else [], + lambda provider: ( + [ + { + "auth_type": "api_key", + "access_token": "sk-ant-api03-pool-key", + "base_url": "https://api.anthropic.com", + } + ] + if provider == "anthropic" + else [] + ), ) captured = {} def _open(request, *, timeout): captured["url"] = request.full_url - captured["headers"] = {key.lower(): value for key, value in request.header_items()} + captured["headers"] = { + key.lower(): value for key, value in request.header_items() + } captured["timeout"] = timeout return _Response({"data": [{"id": "claude-opus-5"}]}) @@ -55,32 +61,53 @@ def _open(request, *, timeout): assert "authorization" not in captured["headers"] -def test_anthropic_pool_api_key_uses_its_configured_endpoint(monkeypatch): - """Never send a proxy-scoped pool credential to Anthropic's public host.""" +def test_anthropic_pool_api_key_overrides_conflicting_active_endpoint(monkeypatch): + """A pool-scoped key must never reach the active model's other endpoint.""" + active_endpoint = "https://active.example/anthropic/v1" + pool_endpoint = "https://pool.example/anthropic/v1" + monkeypatch.setattr( + models, + "_get_model_config_dict", + lambda: {"provider": "anthropic", "base_url": active_endpoint}, + ) monkeypatch.setattr( "agent.anthropic_adapter.resolve_anthropic_token", lambda: None, ) monkeypatch.setattr( "hermes_cli.auth.read_credential_pool", - lambda _provider: [ - { - "auth_type": "api_key", - "access_token": "proxy-key", - "base_url": "https://proxy.example/anthropic/v1", - } - ], + lambda provider: ( + [ + { + "auth_type": "api_key", + "access_token": "proxy-key", + "base_url": pool_endpoint, + } + ] + if provider == "anthropic" + else [] + ), ) - captured = {} + requests = [] def _open(request, *, timeout): - captured["url"] = request.full_url - captured["headers"] = {key.lower(): value for key, value in request.header_items()} + requests.append(( + request.full_url, + {key.lower(): value for key, value in request.header_items()}, + )) return _Response({"data": [{"id": "claude-proxy-model"}]}) monkeypatch.setattr(models, "_urlopen_model_catalog_request", _open) - assert models._fetch_anthropic_models() == ["claude-proxy-model"] - assert captured["url"] == "https://proxy.example/anthropic/v1/models" - assert captured["headers"]["x-api-key"] == "proxy-key" + assert models.provider_model_ids("anthropic") == ["claude-proxy-model"] + assert requests == [ + ( + f"{pool_endpoint}/models", + { + "anthropic-version": "2023-06-01", + "x-api-key": "proxy-key", + }, + ) + ] + assert all(not url.startswith(active_endpoint) for url, _headers in requests) From 3503c06d80aff4a553837660ff7fd495be732b5d Mon Sep 17 00:00:00 2001 From: Ajad van Wyk Date: Thu, 9 Jul 2026 11:57:38 +0200 Subject: [PATCH 047/666] fix: surface Bedrock in explicit-only model pickers when AWS env credentials are set is_provider_explicitly_configured() only checked provider env vars for auth_type="api_key" providers. Bedrock is registered with auth_type="aws_sdk" and an empty api_key_env_vars tuple, so a user who sets AWS_BEARER_TOKEN_BEDROCK (or an AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY pair) in .env was never counted as having explicitly configured the provider. Symptom: the desktop model picker (and any consumer of build_models_payload(explicit_only=True)) silently hid the Bedrock row even though list_authenticated_providers had discovered credentials and built a full model list for it. Reproduced on main: build_models_payload(ctx, explicit_only=False) -> ['moa', 'nous', 'bedrock', ...] # row exists, 132 models build_models_payload(ctx, explicit_only=True) -> ['nous', ...] # bedrock filtered out Fix: aws_sdk-type providers now count as explicitly configured when Bedrock-relevant env credentials are present. Deliberately env-var-only: ambient sources (AWS_PROFILE / SSO, EC2 IMDS, container credentials) still do NOT auto-surface, consistent with the gate's purpose (#56974) and with a lone AWS_ACCESS_KEY_ID (no secret) not counting. Tests: six behavior-contract cases in test_auth_provider_gate.py covering bearer token, key pair, lone key id, ambient AWS_PROFILE, no-credential baseline, and non-leakage into other providers. --- hermes_cli/auth.py | 17 +++++ tests/hermes_cli/test_auth_provider_gate.py | 85 +++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 2b1d4e4a4543..449588d539e9 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1965,6 +1965,23 @@ def _slot_matches_provider(slot): if has_usable_secret(os.getenv(env_var, "")): return True + # AWS SDK providers (Bedrock) have auth_type="aws_sdk" and empty + # api_key_env_vars, so the loop above never sees them. A user who sets + # AWS_BEARER_TOKEN_BEDROCK (or an access-key pair) in .env has configured + # the provider exactly as explicitly as pasting ANTHROPIC_API_KEY — + # without this check the desktop picker's explicit_only filter hides + # Bedrock even though list_authenticated_providers builds its row. + # Only check explicit env credentials here (NOT boto3's full chain): + # ambient sources like EC2 IMDS / SSO profiles must not auto-surface. + if pconfig and pconfig.auth_type == "aws_sdk": + if has_usable_secret(os.getenv("AWS_BEARER_TOKEN_BEDROCK", "")): + return True + if ( + has_usable_secret(os.getenv("AWS_ACCESS_KEY_ID", "")) + and has_usable_secret(os.getenv("AWS_SECRET_ACCESS_KEY", "")) + ): + return True + # 4. Check persisted credential-pool entries that came from EXPLICIT flows # the user initiated inside Hermes (manual add / device-code / PKCE), plus # env-backed pool entries. This intentionally excludes ambient borrowed diff --git a/tests/hermes_cli/test_auth_provider_gate.py b/tests/hermes_cli/test_auth_provider_gate.py index a502f0a1ec37..94cb33a5cca4 100644 --- a/tests/hermes_cli/test_auth_provider_gate.py +++ b/tests/hermes_cli/test_auth_provider_gate.py @@ -98,7 +98,92 @@ def test_stale_env_pool_entry_does_not_count_when_var_unset(tmp_path, monkeypatc assert is_provider_explicitly_configured("deepseek") is False +# ─── aws_sdk providers (Bedrock) ───────────────────────────────────────── +# +# Bedrock is registered with auth_type="aws_sdk" and an empty +# api_key_env_vars tuple, so the api_key env-var loop never sees it. +# Setting AWS_BEARER_TOKEN_BEDROCK (or an access-key pair) in .env is +# exactly as explicit as pasting ANTHROPIC_API_KEY, and must count — +# otherwise the desktop picker's explicit_only filter hides Bedrock even +# though list_authenticated_providers builds a full row for it. +# +# Ambient AWS credential sources (SSO profiles, EC2 IMDS, container +# credentials) must NOT count: aws_sdk detection here is env-var only, +# never boto3's full chain. + + +@pytest.fixture() +def _clean_aws_env(monkeypatch): + """Strip AWS env vars so developer/CI AWS credentials don't leak in.""" + for key in ( + "AWS_BEARER_TOKEN_BEDROCK", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_WEB_IDENTITY_TOKEN_FILE", + ): + monkeypatch.delenv(key, raising=False) + + +def test_bedrock_not_explicit_without_aws_env(tmp_path, monkeypatch, _clean_aws_env): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + (tmp_path / "hermes").mkdir(parents=True, exist_ok=True) + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("bedrock") is False + + +def test_bedrock_bearer_token_counts_as_explicit(tmp_path, monkeypatch, _clean_aws_env): + """AWS_BEARER_TOKEN_BEDROCK is Bedrock-specific — the user set it for + exactly this provider, so it gates like a provider API key.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + (tmp_path / "hermes").mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "ABSKexample-bearer-token-value") + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("bedrock") is True +def test_bedrock_access_key_pair_counts_as_explicit(tmp_path, monkeypatch, _clean_aws_env): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + (tmp_path / "hermes").mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE1234567890") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "examplesecretexamplesecretexample0000000") + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("bedrock") is True +def test_bedrock_access_key_without_secret_is_not_explicit(tmp_path, monkeypatch, _clean_aws_env): + """A lone AWS_ACCESS_KEY_ID can't authenticate anything — require the pair.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + (tmp_path / "hermes").mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE1234567890") + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("bedrock") is False + + +def test_bedrock_ambient_aws_profile_is_not_explicit(tmp_path, monkeypatch, _clean_aws_env): + """AWS_PROFILE is ambient machine state (SSO / shared credentials file), + not an explicit Hermes provider choice — same principle as gh_cli-seeded + Copilot pool entries (#56974).""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + (tmp_path / "hermes").mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("AWS_PROFILE", "default") + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("bedrock") is False + + +def test_aws_env_does_not_leak_into_other_providers(tmp_path, monkeypatch, _clean_aws_env): + """The aws_sdk env check must key on the provider's auth_type, not fire + for every provider whenever AWS credentials happen to be present.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + (tmp_path / "hermes").mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "ABSKexample-bearer-token-value") + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("anthropic") is False From b9f17ba3f10a9947ba69a5ab6cc4faa938235a44 Mon Sep 17 00:00:00 2001 From: Josh Holt <83033847+opti-josh-holt@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:05:13 -0400 Subject: [PATCH 048/666] fix(picker): scope Vertex explicit-config to Hermes signals, not ambient ADC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: the gate reused has_vertex_credentials(), which also returns True for an ambient GOOGLE_APPLICATION_CREDENTIALS path. That var is commonly set globally for unrelated GCP work, so a user who never configured Hermes for Vertex would see it in the explicit-only picker and could spend against those credentials — weakening the explicit-configuration guarantee the gate documents (mirrors the existing _IMPLICIT_ENV_VARS carve-out). Add has_explicit_vertex_config() in agent/vertex_adapter.py that checks only Hermes-scoped signals — VERTEX_PROJECT_ID / vertex.project_id (project override) or a resolvable VERTEX_CREDENTIALS_PATH — and NOT GOOGLE_APPLICATION_CREDENTIALS. Route the auth gate through it. Adds a regression test asserting an ambient GOOGLE_APPLICATION_CREDENTIALS path alone does not mark Vertex explicit, and updates the existing test to drive the real config signal instead of mocking has_vertex_credentials(). --- agent/vertex_adapter.py | 23 ++++++++ hermes_cli/auth.py | 33 +++++++++++ tests/hermes_cli/test_auth_provider_gate.py | 64 +++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/agent/vertex_adapter.py b/agent/vertex_adapter.py index 6e425753f053..ac2658b662f2 100644 --- a/agent/vertex_adapter.py +++ b/agent/vertex_adapter.py @@ -226,3 +226,26 @@ def has_vertex_credentials() -> bool: if _resolve_project_override(): return True return False + + +def has_explicit_vertex_config() -> bool: + """True only when the user deliberately pointed Hermes at Vertex. + + Stricter than :func:`has_vertex_credentials`, which also returns True for + an ambient ``GOOGLE_APPLICATION_CREDENTIALS`` path — a var commonly set + globally for unrelated GCP work. That ambient signal must NOT mark Vertex + "explicitly configured" for the model-picker gate, or a user who never set + Hermes up for Vertex would suddenly see it (and could spend against those + credentials). So this checks only Hermes-scoped signals: + + * ``VERTEX_PROJECT_ID`` env or ``vertex.project_id`` in config.yaml + (``_resolve_project_override``), or + * a resolvable ``VERTEX_CREDENTIALS_PATH`` service-account file + (the Hermes-specific path var — NOT ``GOOGLE_APPLICATION_CREDENTIALS``). + """ + if _resolve_project_override(): + return True + sa_path = _get_secret("VERTEX_CREDENTIALS_PATH") + if sa_path and os.path.exists(sa_path): + return True + return False diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 449588d539e9..cdc1f566c83d 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2009,6 +2009,39 @@ def _slot_matches_provider(slot): except Exception: pass + # 5. OAuth-token / cloud-SDK providers (Vertex AI, Bedrock) have NO API-key + # env var to detect in step 3 and mint short-lived tokens from ADC / a + # service account / the AWS SDK chain. The user "explicitly configures" + # them by writing non-secret routing settings into config.yaml + # (``vertex.project_id`` / a credentials path, ``bedrock.region``) rather + # than by pasting a key — so without this branch such a provider is only + # ever "explicitly configured" while it is the *current* provider, and it + # silently vanishes from explicit-only pickers (desktop chat model menu) + # otherwise. Treat the presence of that deliberate config as explicit. + # + # NOTE: this uses has_explicit_vertex_config(), NOT has_vertex_credentials() + # — the latter also counts an ambient GOOGLE_APPLICATION_CREDENTIALS path + # (commonly set globally for unrelated GCP work), which would mark Vertex + # explicit for users who never set Hermes up for it. Only Hermes-scoped + # signals (VERTEX_PROJECT_ID / vertex.project_id / VERTEX_CREDENTIALS_PATH) + # count here. + try: + if normalized in ("vertex", "google-vertex", "vertex-ai", "gcp-vertex", "vertexai"): + from agent.vertex_adapter import has_explicit_vertex_config + + if has_explicit_vertex_config(): + return True + elif normalized == "bedrock": + from hermes_cli.config import load_config as _load_cfg + + bedrock_cfg = _load_cfg().get("bedrock") + if isinstance(bedrock_cfg, dict) and str( + bedrock_cfg.get("region") or "" + ).strip(): + return True + except Exception: + pass + return False diff --git a/tests/hermes_cli/test_auth_provider_gate.py b/tests/hermes_cli/test_auth_provider_gate.py index 94cb33a5cca4..a3996876a257 100644 --- a/tests/hermes_cli/test_auth_provider_gate.py +++ b/tests/hermes_cli/test_auth_provider_gate.py @@ -52,6 +52,70 @@ def test_ambient_pool_source_does_not_count_as_explicit(tmp_path, monkeypatch): assert is_provider_explicitly_configured("copilot") is False +def test_vertex_adc_counts_as_explicit_when_config_present(tmp_path, monkeypatch): + """A keyless Vertex provider is explicitly configured when the user pointed + Hermes at it (VERTEX_PROJECT_ID / vertex.project_id / VERTEX_CREDENTIALS_PATH), + even when it is NOT the current provider — otherwise it silently vanishes + from explicit-only pickers (desktop chat model menu) unless already selected.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + for var in ("VERTEX_PROJECT_ID", "VERTEX_CREDENTIALS_PATH", "GOOGLE_APPLICATION_CREDENTIALS"): + monkeypatch.delenv(var, raising=False) + _write_auth_store(tmp_path, {"version": 1, "providers": {}, "active_provider": None}) + + from hermes_cli.auth import is_provider_explicitly_configured + + # vertex.project_id in config.yaml is a deliberate, Hermes-scoped signal. + _write_config(tmp_path, { + "model": {"provider": "anthropic", "default": "claude-opus-4-8"}, + "vertex": {"project_id": "my-gcp-project"}, + }) + assert is_provider_explicitly_configured("vertex") is True + + # No Hermes-scoped Vertex config at all → stays hidden. + _write_config(tmp_path, {"model": {"provider": "anthropic", "default": "claude-opus-4-8"}}) + assert is_provider_explicitly_configured("vertex") is False + + +def test_vertex_ambient_google_creds_env_does_not_count_as_explicit(tmp_path, monkeypatch): + """An ambient GOOGLE_APPLICATION_CREDENTIALS path (commonly set globally for + unrelated GCP work) must NOT mark Vertex explicit — only Hermes-scoped + signals do. Regression guard for the picker gate (PR review feedback).""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.delenv("VERTEX_PROJECT_ID", raising=False) + monkeypatch.delenv("VERTEX_CREDENTIALS_PATH", raising=False) + _write_config(tmp_path, {"model": {"provider": "anthropic", "default": "claude-opus-4-8"}}) + _write_auth_store(tmp_path, {"version": 1, "providers": {}, "active_provider": None}) + + # A real, existing SA file pointed to ONLY by the ambient Google var. + sa = tmp_path / "adc.json" + sa.write_text("{}") + monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", str(sa)) + + from hermes_cli.auth import is_provider_explicitly_configured + assert is_provider_explicitly_configured("vertex") is False + + +def test_bedrock_region_counts_as_explicit(tmp_path, monkeypatch): + """Bedrock (AWS SDK auth, no API key) is explicitly configured once the + user pins a region in config.yaml, mirroring the Vertex keyless case.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store(tmp_path, {"version": 1, "providers": {}, "active_provider": None}) + + from hermes_cli.auth import is_provider_explicitly_configured + + _write_config(tmp_path, { + "model": {"provider": "anthropic", "default": "claude-opus-4-8"}, + "bedrock": {"region": "us-east-1"}, + }) + assert is_provider_explicitly_configured("bedrock") is True + + _write_config(tmp_path, { + "model": {"provider": "anthropic", "default": "claude-opus-4-8"}, + "bedrock": {"region": ""}, + }) + assert is_provider_explicitly_configured("bedrock") is False + + def test_returns_true_when_moa_reference_slot_uses_provider(tmp_path, monkeypatch): """MoA advisor slots are explicit provider selections for auth gating.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) From 5f0a8f8739a0d993dc60dbd3c070ef883ed26964 Mon Sep 17 00:00:00 2001 From: Josh Holt <83033847+opti-josh-holt@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:45:36 -0400 Subject: [PATCH 049/666] fix(picker): harden keyless provider gate logging and credentials path validation --- agent/vertex_adapter.py | 2 +- hermes_cli/auth.py | 4 +-- tests/hermes_cli/test_auth_provider_gate.py | 28 +++++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/agent/vertex_adapter.py b/agent/vertex_adapter.py index ac2658b662f2..190c4beb3526 100644 --- a/agent/vertex_adapter.py +++ b/agent/vertex_adapter.py @@ -246,6 +246,6 @@ def has_explicit_vertex_config() -> bool: if _resolve_project_override(): return True sa_path = _get_secret("VERTEX_CREDENTIALS_PATH") - if sa_path and os.path.exists(sa_path): + if sa_path and os.path.isfile(sa_path) and os.access(sa_path, os.R_OK): return True return False diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index cdc1f566c83d..8b1fe86828f5 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2039,8 +2039,8 @@ def _slot_matches_provider(slot): bedrock_cfg.get("region") or "" ).strip(): return True - except Exception: - pass + except Exception as exc: + logger.debug("Failed checking keyless provider explicit config for %s: %s", provider_id, exc) return False diff --git a/tests/hermes_cli/test_auth_provider_gate.py b/tests/hermes_cli/test_auth_provider_gate.py index a3996876a257..a85ddedb0a26 100644 --- a/tests/hermes_cli/test_auth_provider_gate.py +++ b/tests/hermes_cli/test_auth_provider_gate.py @@ -95,6 +95,34 @@ def test_vertex_ambient_google_creds_env_does_not_count_as_explicit(tmp_path, mo assert is_provider_explicitly_configured("vertex") is False +def test_vertex_credentials_path_must_be_readable_file(tmp_path, monkeypatch): + """VERTEX_CREDENTIALS_PATH must point to an actual readable file, not a directory + or non-existent path.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.delenv("VERTEX_PROJECT_ID", raising=False) + monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False) + _write_config(tmp_path, {"model": {"provider": "anthropic", "default": "claude-opus-4-8"}}) + _write_auth_store(tmp_path, {"version": 1, "providers": {}, "active_provider": None}) + + from hermes_cli.auth import is_provider_explicitly_configured + + # Valid readable file -> True + sa_file = tmp_path / "vertex_sa.json" + sa_file.write_text("{}") + monkeypatch.setenv("VERTEX_CREDENTIALS_PATH", str(sa_file)) + assert is_provider_explicitly_configured("vertex") is True + + # Directory -> False + sa_dir = tmp_path / "vertex_dir" + sa_dir.mkdir() + monkeypatch.setenv("VERTEX_CREDENTIALS_PATH", str(sa_dir)) + assert is_provider_explicitly_configured("vertex") is False + + # Non-existent file -> False + monkeypatch.setenv("VERTEX_CREDENTIALS_PATH", str(tmp_path / "nonexistent.json")) + assert is_provider_explicitly_configured("vertex") is False + + def test_bedrock_region_counts_as_explicit(tmp_path, monkeypatch): """Bedrock (AWS SDK auth, no API key) is explicitly configured once the user pins a region in config.yaml, mirroring the Vertex keyless case.""" From 32a8a7031e8248b05db8142b0d7349616fda71fa Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:15:32 -0700 Subject: [PATCH 050/666] chore: map contributor email for salvage attribution --- contributors/emails/ajadvw@icloud.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/ajadvw@icloud.com diff --git a/contributors/emails/ajadvw@icloud.com b/contributors/emails/ajadvw@icloud.com new file mode 100644 index 000000000000..ca7a51d59be5 --- /dev/null +++ b/contributors/emails/ajadvw@icloud.com @@ -0,0 +1 @@ +AVW7 From 1684877868807aad695dd714b9109a216d5741ae Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:20:04 -0700 Subject: [PATCH 051/666] fix(update): a gateway killed by the restart phase and never replaced now fails the fleet check (DOWN row) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-1 verification gap (#91277, found auditing our own landed matrix against the mapped issues): collect_fleet_versions only listed gateways with a LIVE pid, so 'restart stopped it and nothing came back' produced NO row at all — the exact silent-failure shape the matrix exists to catch (#88848/#74973 class) passed with exit 0. - collect_fleet_versions(pre_restart_pids=...): a dead pid becomes a 'down' row only when it was alive at update start AND its runtime status still claims a running state. Rollout-safe: no snapshot (old callers), clean stops, startup failures, and stale records from long-dead gateways keep the historical no-row behavior. - print_fleet_version_matrix escalates on down rows like stale ones (exit 1) with the per-profile restart remediation. - cmd_update passes its existing pre-restart PID snapshot. Sabotage-verified (reverting the membership check fails the new test); live-verified with a real spawned-then-killed process producing the DOWN row and matrix escalation. --- hermes_cli/update_cmd.py | 7 +- hermes_cli/update_receipt.py | 72 ++++++++++++-- .../test_fleet_matrix_down_state.py | 97 +++++++++++++++++++ 3 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 tests/hermes_cli/test_fleet_matrix_down_state.py diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 62d94198c69b..eafe4ef4aa5f 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -8129,7 +8129,12 @@ def _on_unit_timeout(svc_name: str, exc: subprocess.TimeoutExpired) -> None: # were running) — nothing to settle. if restarted_services or killed_pids: _time.sleep(2.0) - _fleet_snapshot = collect_fleet_versions() + # Pass the pre-restart PID snapshot so a gateway the restart + # phase stopped WITHOUT a verified replacement shows as a DOWN + # row (exit 1) instead of silently producing no row at all. + _fleet_snapshot = collect_fleet_versions( + pre_restart_pids=_pre_restart_gateway_pids + ) if print_fleet_version_matrix(_fleet_snapshot): gateway_fleet_restart_incomplete = True except Exception as _fleet_exc: diff --git a/hermes_cli/update_receipt.py b/hermes_cli/update_receipt.py index 135c90e27497..8989bdb3a233 100644 --- a/hermes_cli/update_receipt.py +++ b/hermes_cli/update_receipt.py @@ -277,21 +277,40 @@ def read_latest_receipt() -> Optional[dict[str, Any]]: # Fleet version verification # --------------------------------------------------------------------------- -def collect_fleet_versions() -> list[dict[str, Any]]: +def collect_fleet_versions( + *, pre_restart_pids: Optional[list[int]] = None +) -> list[dict[str, Any]]: """Snapshot every profile's gateway code identity vs. the current tree. Returns one entry per profile home that has a ``gateway_state.json`` - with a live PID:: + describing a gateway that is live — or that SHOULD be live:: {"profile": str, "pid": int, "code_sha": str|None, - "code_version": str|None, "state": "current"|"stale"|"unknown"} + "code_version": str|None, "state": "current"|"stale"|"unknown"|"down"} ``stale`` — gateway stamped a code_sha that differs from the updated checkout's HEAD (it is still serving pre-update modules). ``unknown`` — gateway predates the code-identity stamp (started before this feature landed) or identity could not be resolved. + ``down`` — the gateway was ALIVE when this update started + (``pre_restart_pids``), its runtime status still says + running, but the PID is dead and no successor rewrote the + record: the restart phase stopped it and nothing came + back. Without this row a killed-and-never-replaced gateway + produced NO entry at all and the matrix passed silently + (Phase-1 verification gap, #88848/#74973 class). + + Rollout safety: ``down`` requires membership in ``pre_restart_pids`` — + a stale state file from a long-dead gateway (machine reboot, manual + kill weeks ago) must NOT fail every future update. Callers that don't + have a pre-restart snapshot (``None``/empty) get the historical + behavior: dead PIDs are skipped. Never raises; a probe failure yields an empty list. """ + # Runtime-status states that mean "this record does not describe a + # gateway that should be running now" — no down row for these. + _NOT_EXPECTED_STATES = {"stopped", "startup_failed"} + _pre_restart = {int(p) for p in (pre_restart_pids or []) if isinstance(p, int)} results: list[dict[str, Any]] = [] try: from hermes_cli.build_info import get_code_identity @@ -364,6 +383,28 @@ def collect_fleet_versions() -> list[dict[str, Any]]: except (TypeError, ValueError): continue if not _pid_exists(pid): + # Dead PID: a DOWN row only when this exact pid was alive at + # update start AND the record still claims a running state — + # "the restart phase stopped it and nothing came back." + # Everything else (clean stop, startup failure, stale record + # from a long-dead gateway) keeps the historical no-row + # behavior so the feature's rollout can't false-positive. + gw_state = record.get("gateway_state") + if ( + pid in _pre_restart + and isinstance(gw_state, str) + and gw_state + and gw_state not in _NOT_EXPECTED_STATES + ): + results.append( + { + "profile": profile, + "pid": pid, + "code_sha": None, + "code_version": record.get("code_version"), + "state": "down", + } + ) continue code_sha = record.get("code_sha") if not code_sha or not expected_sha: @@ -390,15 +431,17 @@ def print_fleet_version_matrix(fleet: list[dict[str, Any]]) -> bool: """Print the post-update fleet version matrix. Returns True when at least one gateway is provably stale (still - serving pre-update code), so the caller can escalate. ``unknown`` - entries are reported but do NOT fail the update: gateways started - before the code-identity stamp existed have no sha to compare, and - failing on them would turn this feature's own rollout into a + serving pre-update code) OR provably down (was running, killed by the + restart phase, nothing came back), so the caller can escalate. + ``unknown`` entries are reported but do NOT fail the update: gateways + started before the code-identity stamp existed have no sha to compare, + and failing on them would turn this feature's own rollout into a false-positive storm. """ if not fleet: return False any_stale = False + any_down = False print() print("Fleet version check:") for entry in fleet: @@ -412,14 +455,23 @@ def print_fleet_version_matrix(fleet: list[dict[str, Any]]) -> bool: elif state == "stale": any_stale = True print(f" ✗ {profile} (pid {pid}) @ {short} — STALE (pre-update code)") + elif state == "down": + any_down = True + print( + f" ✗ {profile} — DOWN (gateway was running before the " + f"update; pid {pid} is gone and nothing replaced it)" + ) else: print( f" ? {profile} (pid {pid}) — version unknown " "(gateway predates version stamping; restart to enable)" ) - if any_stale: + if any_stale or any_down: print() - print(" ⚠ Stale gateways keep serving pre-update code until restarted:") + if any_stale: + print(" ⚠ Stale gateways keep serving pre-update code until restarted:") + if any_down: + print(" ⚠ Down gateways stopped serving messaging entirely — restart them:") print(" hermes gateway restart # active profile") print(" hermes -p gateway restart # named profile") - return any_stale + return any_stale or any_down diff --git a/tests/hermes_cli/test_fleet_matrix_down_state.py b/tests/hermes_cli/test_fleet_matrix_down_state.py new file mode 100644 index 000000000000..b9f8c37c5b99 --- /dev/null +++ b/tests/hermes_cli/test_fleet_matrix_down_state.py @@ -0,0 +1,97 @@ +"""Fleet matrix DOWN-state coverage (Phase-1 verification gap, #91277). + +A gateway that was ALIVE at update start, got stopped by the restart phase, +and never came back used to produce NO matrix row at all — the check passed +silently on a fleet that lost messaging. Now it yields a `down` row and the +matrix returns True (escalate/exit 1). Rollout safety: `down` requires the +pid to be in the caller's pre-restart snapshot, so stale state files from +long-dead gateways never false-positive. +""" + +import json +import os +from pathlib import Path + +import hermes_cli.update_receipt as ur + + +def _setup(monkeypatch, tmp_path, record: dict): + home = tmp_path / ".hermes" + home.mkdir(exist_ok=True) + monkeypatch.setattr( + "hermes_cli.build_info.get_code_identity", + lambda refresh=False: {"sha": "HEADSHA", "version": "1.0"}, + ) + monkeypatch.setattr("hermes_cli.profiles._get_default_hermes_home", lambda: home) + monkeypatch.setattr( + "hermes_cli.profiles._get_profiles_root", lambda: tmp_path / "no-profiles" + ) + monkeypatch.setattr("gateway.control_socket.identify_gateway", lambda h, **k: None) + (home / "gateway_state.json").write_text(json.dumps(record), encoding="utf-8") + return home + + +_DEAD_PID = 999999899 # never a live pid + + +def test_killed_never_replaced_gateway_is_a_down_row(monkeypatch, tmp_path): + _setup( + monkeypatch, + tmp_path, + {"pid": _DEAD_PID, "gateway_state": "running", "kind": "hermes-gateway", + "code_version": "0.20.5"}, + ) + fleet = ur.collect_fleet_versions(pre_restart_pids=[_DEAD_PID]) + assert len(fleet) == 1 + assert fleet[0]["state"] == "down" + assert fleet[0]["pid"] == _DEAD_PID + # and the matrix escalates on it + assert ur.print_fleet_version_matrix(fleet) is True + + +def test_dead_pid_not_in_pre_restart_snapshot_keeps_no_row(monkeypatch, tmp_path): + """Stale state file from a long-dead gateway: rollout-safe no-row.""" + _setup( + monkeypatch, + tmp_path, + {"pid": _DEAD_PID, "gateway_state": "running", "kind": "hermes-gateway"}, + ) + assert ur.collect_fleet_versions(pre_restart_pids=[12345]) == [] + assert ur.collect_fleet_versions(pre_restart_pids=None) == [] + assert ur.collect_fleet_versions() == [] + + +def test_cleanly_stopped_gateway_is_not_down(monkeypatch, tmp_path): + for benign_state in ("stopped", "startup_failed"): + _setup( + monkeypatch, + tmp_path, + {"pid": _DEAD_PID, "gateway_state": benign_state, "kind": "hermes-gateway"}, + ) + assert ur.collect_fleet_versions(pre_restart_pids=[_DEAD_PID]) == [] + + +def test_live_gateway_rows_unchanged(monkeypatch, tmp_path): + """The live-pid path is untouched by the down-state addition.""" + _setup( + monkeypatch, + tmp_path, + {"pid": os.getpid(), "gateway_state": "running", "code_sha": "HEADSHA", + "kind": "hermes-gateway"}, + ) + fleet = ur.collect_fleet_versions(pre_restart_pids=[os.getpid()]) + assert len(fleet) == 1 + assert fleet[0]["state"] == "current" + assert ur.print_fleet_version_matrix(fleet) is False + + +def test_down_and_stale_both_escalate_with_remediation(capsys): + fleet = [ + {"profile": "default", "pid": 1, "code_sha": "OLD", "state": "stale"}, + {"profile": "coder", "pid": 2, "code_sha": None, "state": "down"}, + ] + assert ur.print_fleet_version_matrix(fleet) is True + out = capsys.readouterr().out + assert "STALE" in out + assert "DOWN" in out + assert "hermes gateway restart" in out From e366df6889e8554870ab1ededd83e70eb5ab0235 Mon Sep 17 00:00:00 2001 From: Franci Penov Date: Tue, 28 Jul 2026 15:27:21 -0700 Subject: [PATCH 052/666] fix(cli): treat a fork's upstream sync as an update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fork, `hermes update` compares HEAD against origin/main, and only then syncs the fork from upstream — inside the `commit_count == 0` branch, which returns immediately afterwards. So an update that pulls hundreds of commits from upstream prints "Already up to date!" and skips everything the post-update path does, including the dependency sync and the gateway restart. Observed on a fork-based deployment: 1654 commits pulled, "Already up to date!", and the launchd gateway left running. It then held pre-update modules in memory while lazily importing post-update ones, and failed later with an AttributeError for a method that plainly exists on disk — a mixed runtime that looks nothing like an update problem. Correlating every run in update.log, a restart happened on exactly the runs that pulled upstream *without* also claiming to be up to date, and never once they started co-occurring. Decide before the branch: capture HEAD, sync, and if HEAD moved, set commit_count from the range so the normal post-update path runs. The pull that follows is a no-op (the sync updates origin too); reaching the restart is the point. commit_count is floored at 1 — HEAD moving *is* the update, so a failed or zero count query must not send us back down the early return. steps still being skipped afterwards. Refs #73108 --- hermes_cli/update_cmd.py | 27 ++++++++++++++++++---- tests/hermes_cli/test_cmd_update.py | 36 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index eafe4ef4aa5f..8997f03508bf 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -6181,13 +6181,32 @@ def _cmd_update_impl(args, gateway_mode: bool): # not behind, fall through to the up-to-date path. commit_count = counted if counted is not None else -1 + # A fork can match origin while still trailing upstream. The sync can + # therefore advance HEAD even though the origin comparison found no + # commits. Detect that BEFORE taking the no-update return so dependency + # refreshes, gateway restarts, AND the fleet version matrix still run + # for the pulled code (#73108 — previously the sync lived inside the + # commit_count == 0 branch, which returns immediately after: an update + # that pulled hundreds of upstream commits printed "Already up to + # date!" and verified nothing). + if commit_count == 0 and is_fork and branch == "main": + pre_sync_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT) + _m()._sync_with_upstream_if_needed(git_cmd, _m().PROJECT_ROOT) + post_sync_sha = _capture_head_sha(git_cmd, _m().PROJECT_ROOT) + if pre_sync_sha and post_sync_sha and pre_sync_sha != post_sync_sha: + synced_count = _count_commits_between( + git_cmd, + _m().PROJECT_ROOT, + pre_sync_sha, + post_sync_sha, + ) + # HEAD moving is itself proof of an update. Keep the update + # path active even if the informational count cannot be read. + commit_count = max(1, synced_count) + if commit_count == 0: _invalidate_update_cache() - # Even if origin is up to date, the fork may be behind upstream - if is_fork and branch == "main": - _m()._sync_with_upstream_if_needed(git_cmd, _m().PROJECT_ROOT) - # Restore stash and switch back to original branch if we moved. # EXCEPTION: a parked feature branch we verified clean + fully # merged stays on the target — re-parking the checkout on the diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 21f28a33ae13..67fdf1b2614e 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -252,6 +252,42 @@ def test_update_on_fork_checks_upstream_when_origin_up_to_date( captured = capsys.readouterr() assert "Already up to date!" in captured.out + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_fork_upstream_sync_that_moves_head_runs_post_update_steps( + self, mock_run, _mock_which, mock_args, capsys + ): + """A fork sync that pulls code must continue through post-update work.""" + from hermes_cli import main as hm + from hermes_cli import update_cmd + + mock_run.side_effect = _make_run_side_effect( + branch="main", verify_ok=True, commit_count="0" + ) + + # The first two reads bracket the upstream sync; later reads see the + # new HEAD while the normal update path finishes. + shas = iter(["aaaaaaa", "bbbbbbb"]) + + with patch.object( + hm, + "_get_origin_url", + return_value="https://github.com/example/hermes-agent.git", + ), patch.object( + update_cmd, + "_capture_head_sha", + side_effect=lambda *_args, **_kwargs: next(shas, "bbbbbbb"), + ), patch.object( + hm, "_sync_with_upstream_if_needed" + ), patch.object( + hm, "_reload_updated_runtime_modules" + ) as post_update_step: + cmd_update(mock_args) + + post_update_step.assert_called_once_with() + captured = capsys.readouterr() + assert "Already up to date!" not in captured.out + def test_update_non_interactive_runs_safe_config_migrations(self, mock_args, capsys): """Dashboard/web updates apply non-interactive migrations before restart.""" with patch("shutil.which", return_value=None), patch( From 4553e71993dbeb21449f1ef4d6fad069adb20915 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:12:48 -0700 Subject: [PATCH 053/666] fix(test): isolate the fork-sync test from the host machine; abort at the reload proof point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged test drove the FULL post-update pipeline against the real dev box: real fleet probes read live gateways as STALE (exit 1) and the restart phase tripped the live-system guard on a real gateway PID. Pin empty fleet/gateway discovery and make _reload_updated_runtime_modules (the proof the post-update path ran — the bug returned before it) abort the pipeline. Sabotage-verified: disabling the hoisted sync fails the test. --- tests/hermes_cli/test_cmd_update.py | 46 +++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 67fdf1b2614e..9226d25c3f6f 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -265,9 +265,12 @@ def test_fork_upstream_sync_that_moves_head_runs_post_update_steps( branch="main", verify_ok=True, commit_count="0" ) - # The first two reads bracket the upstream sync; later reads see the - # new HEAD while the normal update path finishes. - shas = iter(["aaaaaaa", "bbbbbbb"]) + # The first two reads bracket the upstream sync (aaaaaaa -> bbbbbbb: + # the sync moved HEAD). The NEXT two bracket the pull inside the + # normal update path (bbbbbbb -> ccccccc) — the head-moved no-op + # guard added after this PR exits 1 when that pair is equal, so the + # mock must show the pull advancing HEAD too. + shas = iter(["aaaaaaa", "bbbbbbb", "bbbbbbb", "ccccccc"]) with patch.object( hm, @@ -276,14 +279,45 @@ def test_fork_upstream_sync_that_moves_head_runs_post_update_steps( ), patch.object( update_cmd, "_capture_head_sha", - side_effect=lambda *_args, **_kwargs: next(shas, "bbbbbbb"), + side_effect=lambda *_args, **_kwargs: next(shas, "ccccccc"), + ), patch( + # The full post-update path runs the fleet version check, which + # reads the REAL machine's profile gateway_state.json files — + # live gateways on a dev box read as STALE vs this checkout and + # exit 1. Pin an empty fleet: this test asserts the post-update + # path RUNS, not the fleet's health. + "hermes_cli.update_receipt.collect_fleet_versions", + return_value=[], + ), patch( + # Same isolation for the restart phase: without these, the real + # machine's live gateways enter the restart discovery, the + # mocked-subprocess restart phase can't verify replacements, and + # the fail-closed contract (#78574) exits 1 (locally the + # live-system guard blocks the os.kill outright). + "hermes_cli.gateway.find_gateway_pids", + return_value=[], + ), patch( + "hermes_cli.gateway.find_profile_gateway_processes", + return_value=[], + ), patch( + "hermes_cli.gateway._get_service_pids", + return_value=set(), ), patch.object( hm, "_sync_with_upstream_if_needed" ), patch.object( - hm, "_reload_updated_runtime_modules" + hm, + "_reload_updated_runtime_modules", + # Reaching the reload step IS the proof the post-update path ran + # (the bug returned from "Already up to date!" before it). Abort + # the pipeline right here: everything past this point (skills + # sync, desktop rebuild, gateway restart, fleet check) would run + # for real against the host machine. + side_effect=SystemExit(0), ) as post_update_step: - cmd_update(mock_args) + with pytest.raises(SystemExit) as exit_info: + cmd_update(mock_args) + assert exit_info.value.code == 0 post_update_step.assert_called_once_with() captured = capsys.readouterr() assert "Already up to date!" not in captured.out From 091106092b5d6488c7f9adc94c613cfb8fe21363 Mon Sep 17 00:00:00 2001 From: Mauricio Ruiz <102331607+mrsucesso@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:45:06 -0300 Subject: [PATCH 054/666] fix(dashboard): recover update success after restart Persisted update completion markers survive the dashboard restart that clears in-memory action state. Recover the latest safe marker from update.log so remote Desktop clients do not report a successful backend update as failed. --- hermes_cli/web_server.py | 51 +++++++++++++++++++++++++++-- tests/hermes_cli/test_web_server.py | 30 +++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 699b8017f825..cc689f07b0bc 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4406,6 +4406,10 @@ async def run_debug_share_endpoint(body: DebugShareRequest | None = None): # child exits is the bug this exists to fix. _LAST_GATEWAY_RESTART: Optional[Tuple[float, subprocess.Popen, Tuple[str, ...]]] = None +_UPDATE_ACTION_COMPLETED_RE = re.compile( + r"^=== hermes-update completed ([0-9a-f]{32}) ===$" +) + # ``name`` → completed synthetic action result for actions the server handled # without spawning a subprocess (for example, unsupported Docker updates). _ACTION_RESULTS: Dict[str, Dict[str, Any]] = {} @@ -4559,6 +4563,34 @@ def _tail_lines(path: Path, n: int) -> List[str]: return lines[-n:] +def _durable_completed_update_action_id(lines: List[str]) -> Optional[str]: + """Recover the latest successful update identity from ``update.log``. + + The dashboard action process can restart the dashboard that spawned it. + That loses the in-memory ``Popen``/result registries while the durable + update log survives. Only accept a completion marker that occurs after + the latest update-start marker, so a stale success cannot mask a newer + failed attempt. + """ + last_start = -1 + last_completed = -1 + completed_action_id: Optional[str] = None + + for index, line in enumerate(lines): + if line.startswith("=== hermes update started "): + last_start = index + + match = _UPDATE_ACTION_COMPLETED_RE.fullmatch(line.strip()) + if match: + last_completed = index + completed_action_id = match.group(1) + + if completed_action_id and last_completed > last_start: + return completed_action_id + + return None + + def _gateway_subcommand(profile: Optional[str], verb: str) -> List[str]: return _profile_cli_args(profile) + ["gateway", verb] @@ -5494,7 +5526,17 @@ async def get_action_status(name: str, lines: int = 200): raise HTTPException(status_code=404, detail=f"Unknown action: {name}") log_path = _ACTION_LOG_DIR / log_file_name - tail = _tail_lines(log_path, min(max(lines, 1), 2000)) + requested_lines = min(max(lines, 1), 2000) + tail = _tail_lines(log_path, requested_lines) + + durable_update_action_id = None + if name == "hermes-update": + durable_lines = _tail_lines(_ACTION_LOG_DIR / "update.log", 2000) + durable_update_action_id = _durable_completed_update_action_id(durable_lines) + if durable_update_action_id: + marker = f"=== hermes-update completed {durable_update_action_id} ===" + if marker not in tail: + tail = [*tail, marker][-requested_lines:] proc = _ACTION_PROCS.get(name) if proc is None: @@ -5502,6 +5544,8 @@ async def get_action_status(name: str, lines: int = 200): running = False exit_code = result.get("exit_code") if result else None pid = result.get("pid") if result else None + if result is None and durable_update_action_id: + exit_code = 0 else: exit_code = proc.poll() running = exit_code is None @@ -5516,13 +5560,16 @@ async def get_action_status(name: str, lines: int = 200): _ACTION_COMMANDS.pop(name, None) _ACTION_IDS.pop(name, None) - return { + response = { "name": name, "running": running, "exit_code": exit_code, "pid": pid, "lines": tail, } + if durable_update_action_id: + response["action_id"] = durable_update_action_id + return response # Per-row fields that no session LIST consumer reads but that dominate the diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index b28bc6a8fa5c..38e64d6236ea 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1231,6 +1231,36 @@ def fail_spawn(*_args, **_kwargs): assert check_data["update_command"] == "pkg upgrade hermes-agent" assert "Termux APT" in check_data["message"] + def test_update_status_recovers_completed_result_after_dashboard_restart(self, monkeypatch, tmp_path): + import hermes_cli.web_server as web_server + + action_id = "c" * 32 + (tmp_path / "hermes-update.log").write_text( + "=== hermes-update started 2026-08-17 11:19:34 ===\n" + "pulling updates...\n", + encoding="utf-8", + ) + (tmp_path / "update.log").write_text( + "=== hermes update started 2026-08-17T11:19:35 ===\n" + "✓ Update complete!\n" + f"=== hermes-update completed {action_id} ===\n", + encoding="utf-8", + ) + monkeypatch.setattr(web_server, "_ACTION_LOG_DIR", tmp_path) + monkeypatch.setattr(web_server, "_ACTION_PROCS", {}) + monkeypatch.setattr(web_server, "_ACTION_RESULTS", {}) + monkeypatch.setattr(web_server, "_ACTION_COMMANDS", {}) + monkeypatch.setattr(web_server, "_ACTION_IDS", {}) + + status = self.client.get("/api/actions/hermes-update/status?lines=2000") + + assert status.status_code == 200 + data = status.json() + assert data["running"] is False + assert data["exit_code"] == 0 + assert data["action_id"] == action_id + assert f"=== hermes-update completed {action_id} ===" in data["lines"] + def test_update_hermes_spawns_with_action_id(self, monkeypatch): import hermes_cli.web_server as web_server From 8804e78354c913165687cc5397dcc6cc764bb118 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:36:07 -0700 Subject: [PATCH 055/666] feat(dashboard): Desktop and dashboard read the update receipt instead of inferring success (#91277 Phase-1 bullet 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on @mrsucesso's durable-marker recovery (previous commit): - GET /api/hermes/update/receipt — the full durable receipt (steps, skips, gateway restart outcome, fleet matrix) + compact summary; the authoritative update-outcome record (written by every run since #91283, including refused/failed). - /api/actions/hermes-update/status now attaches the receipt summary, and when BOTH the in-memory registries and the update.log marker are gone (dashboard restarted + log rotated — the #81193 state), a finished receipt reports the outcome: success→0, partial→1. A still-running receipt proves nothing (clients keep polling). - Desktop (updates.ts): the apply poll reads the attached receipt — a finished receipt whose run started at/after this apply is authoritative, replacing timeout-based failure inference across the update's restart gap ('Backend update failed' on successful updates, #81193; 'boot failed' during update restarts, #87359). Live-verified: real uvicorn server + real UpdateReceipt writer (the exact code hermes update runs) over real HTTP — receipt endpoint 200 with summary; #81193 state (no registries, no marker) reports success from the receipt alone; partial receipt with a DOWN fleet row maps to exit 1 (no false success). --- apps/desktop/src/store/updates.ts | 33 ++++ apps/desktop/src/types/hermes.ts | 16 ++ hermes_cli/web_server.py | 80 ++++++++++ .../test_update_receipt_endpoint.py | 145 ++++++++++++++++++ 4 files changed, 274 insertions(+) create mode 100644 tests/hermes_cli/test_update_receipt_endpoint.py diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index 476354e1b7df..0cf805965164 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -587,6 +587,30 @@ function completedAfterRestart( return !!actionId && status.lines.some(line => line === `=== hermes-update completed ${actionId} ===`) } +/** Whether the durable update receipt attached to the status proves the + * outcome of THIS apply (#91277 bullet 3). Only a finished receipt whose + * run started at-or-after we kicked the update off counts — an older + * receipt describes a previous update, and a still-running one proves + * nothing yet. The 60s slack absorbs client/backend clock skew. */ +function receiptProvesOutcome( + status: Awaited>, + applyStartedAtMs: number +): boolean { + const receipt = status.receipt + + if (!receipt || !receipt.finished_at || !receipt.started_at) { + return false + } + + if (receipt.outcome !== 'success' && receipt.outcome !== 'partial' && receipt.outcome !== 'failed') { + return false + } + + const startedMs = Date.parse(receipt.started_at) + + return Number.isFinite(startedMs) && startedMs >= applyStartedAtMs - 60_000 +} + function legacyBackendReachedTarget( status: BackendUpdateCheckResponse, targetSha: string | undefined, @@ -623,6 +647,7 @@ async function runBackendUpdate(): Promise { : undefined const started = await updateHermes() + const applyStartedAtMs = Date.now() if (!started.ok) { const message = (started as { message?: string }).message || translateNow('updates.applyStatus.notAvailable') @@ -686,6 +711,14 @@ async function runBackendUpdate(): Promise { return finishBackendApply(true) } + // #91277 bullet 3: the backend now attaches the durable update + // receipt to the status. A receipt whose run STARTED after we kicked + // this update off is authoritative — read its outcome instead of + // inferring from log markers or timing out across the restart gap. + if (last.exit_code === null && receiptProvesOutcome(last, applyStartedAtMs)) { + return finishBackendApply(last.receipt!.outcome === 'success') + } + if (!started.action_id && last.exit_code === null) { try { const status = await checkHermesUpdate(true) diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 2c80596c6ac4..0cf405680fe5 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -1224,12 +1224,28 @@ export interface ActionResponse { already_running?: boolean } +export interface UpdateReceiptSummary { + outcome: 'running' | 'success' | 'partial' | 'failed' | 'refused' | string + started_at: string | null + finished_at: string | null + pre_sha: string | null + post_sha: string | null + post_version: string | null + fleet_states: string[] +} + export interface ActionStatusResponse { exit_code: number | null lines: string[] name: string pid: number | null running: boolean + /** hermes-update only: durable completion identity recovered from update.log. */ + action_id?: string + /** hermes-update only: summary of the durable update receipt (#91277 bullet 3) — + * the authoritative outcome record, present even when the dashboard + * restarted itself mid-action and lost its in-memory registries. */ + receipt?: UpdateReceiptSummary } export interface BackendUpdateCommit { diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index cc689f07b0bc..0fce1edb94b7 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5530,6 +5530,7 @@ async def get_action_status(name: str, lines: int = 200): tail = _tail_lines(log_path, requested_lines) durable_update_action_id = None + update_receipt_summary = None if name == "hermes-update": durable_lines = _tail_lines(_ACTION_LOG_DIR / "update.log", 2000) durable_update_action_id = _durable_completed_update_action_id(durable_lines) @@ -5537,6 +5538,14 @@ async def get_action_status(name: str, lines: int = 200): marker = f"=== hermes-update completed {durable_update_action_id} ===" if marker not in tail: tail = [*tail, marker][-requested_lines:] + # Phase-1 bullet 3 (#91277): the update receipt is the durable, + # structured truth about the last update — written by every run + # including refused/failed ones, and it survives the dashboard + # restarting itself mid-action. Surface its summary alongside the + # log-marker recovery so clients (Desktop, dashboard) READ the + # outcome instead of inferring it from liveness probes + # (#81193/#87359 class). + update_receipt_summary = _latest_update_receipt_summary() proc = _ACTION_PROCS.get(name) if proc is None: @@ -5546,6 +5555,17 @@ async def get_action_status(name: str, lines: int = 200): pid = result.get("pid") if result else None if result is None and durable_update_action_id: exit_code = 0 + if ( + result is None + and exit_code is None + and update_receipt_summary is not None + and update_receipt_summary.get("outcome") in ("success", "partial") + ): + # No in-memory result and no log marker (e.g. log rotated), but + # the receipt proves a completed run: report its outcome rather + # than a null that clients time out on. ``partial`` maps to + # exit 1 exactly like the CLI run itself did. + exit_code = 0 if update_receipt_summary["outcome"] == "success" else 1 else: exit_code = proc.poll() running = exit_code is None @@ -5569,9 +5589,69 @@ async def get_action_status(name: str, lines: int = 200): } if durable_update_action_id: response["action_id"] = durable_update_action_id + if update_receipt_summary is not None: + response["receipt"] = update_receipt_summary return response +def _latest_update_receipt_summary() -> Optional[Dict[str, Any]]: + """Compact summary of the most recent update receipt, or None. + + Phase-1 bullet 3 (#91277): the receipt (written by EVERY ``hermes + update`` run since #91283, including refused and failed ones, with a + ``latest.json`` pointer) is the durable success signal the Desktop and + dashboard should read instead of inferring outcomes from liveness + probes across the update's stop/start gap (#81193, #87359). Summary + only — steps and skips stay in the full receipt endpoint. + Never raises. + """ + try: + from hermes_cli.update_receipt import read_latest_receipt + + receipt = read_latest_receipt() + if not receipt: + return None + fleet = receipt.get("fleet") or [] + return { + "outcome": receipt.get("outcome"), + "started_at": receipt.get("started_at"), + "finished_at": receipt.get("finished_at"), + "pre_sha": (receipt.get("pre_update") or {}).get("sha"), + "post_sha": (receipt.get("post_update") or {}).get("sha"), + "post_version": (receipt.get("post_update") or {}).get("version"), + "fleet_states": sorted( + {str(e.get("state")) for e in fleet if isinstance(e, dict)} + ), + } + except Exception: + return None + + +@app.get("/api/hermes/update/receipt") +async def get_update_receipt(): + """The most recent update receipt — the durable update-outcome record. + + Phase-1 bullet 3 (#91277): dashboards and the Desktop read this instead + of inferring update success from backend liveness (the inference misread + the update's own restart gap as 'Backend update failed' / 'boot failed' + — #81193, #87359). Returns the FULL receipt (steps, skips, gateway + restart outcome, fleet matrix) plus a compact ``summary``; 404 when no + update has run since receipts landed. + """ + try: + from hermes_cli.update_receipt import read_latest_receipt + + receipt = read_latest_receipt() + except Exception: + receipt = None + if not receipt: + raise HTTPException( + status_code=404, + detail="No update receipt found (no `hermes update` run recorded).", + ) + return {"receipt": receipt, "summary": _latest_update_receipt_summary()} + + # Per-row fields that no session LIST consumer reads but that dominate the # payload. ``system_prompt`` is the fully rendered prompt — tens of KB per # row — and made a 21-row /api/sessions response 528KB (96% dead weight), diff --git a/tests/hermes_cli/test_update_receipt_endpoint.py b/tests/hermes_cli/test_update_receipt_endpoint.py new file mode 100644 index 000000000000..cca062d3ef37 --- /dev/null +++ b/tests/hermes_cli/test_update_receipt_endpoint.py @@ -0,0 +1,145 @@ +"""Phase-1 bullet 3 (#91277): the dashboard/Desktop READ the update receipt. + +The update receipt (written by every `hermes update` run since #91283, +`latest.json` pointer) is the durable outcome record. These tests pin: + +- GET /api/hermes/update/receipt returns the full receipt + summary; 404 + when none exists. +- GET /api/actions/hermes-update/status attaches the receipt summary, and + uses a finished receipt as the outcome when both in-memory registries AND + the log marker are gone (dashboard restarted + log rotated). +""" + +import json +from pathlib import Path + +import pytest + +import hermes_cli.web_server as web_server + + +@pytest.fixture() +def client(): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app + + c = TestClient(app) + c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + return c + + +def _write_receipt(tmp_path: Path, monkeypatch, *, outcome="success") -> dict: + receipt = { + "schema": 1, + "started_at": "2026-08-23T07:00:00+00:00", + "finished_at": "2026-08-23T07:03:20+00:00", + "argv": ["hermes", "update"], + "pid": 12345, + "outcome": outcome, + "pre_update": {"sha": "a" * 40, "version": "0.20.4"}, + "post_update": {"sha": "b" * 40, "version": "0.20.5"}, + "steps": [{"name": "pre_update_backup", "ok": True, "detail": "", "at": ""}], + "skips": [], + "gateway_restart": {}, + "fleet": [ + {"profile": "default", "pid": 1, "code_sha": "b" * 40, "state": "current"} + ], + } + receipt_dir = tmp_path / "logs" / "update_receipts" + receipt_dir.mkdir(parents=True) + (receipt_dir / "latest.json").write_text(json.dumps(receipt), encoding="utf-8") + import hermes_cli.update_receipt as ur + + monkeypatch.setattr(ur, "_receipt_dir", lambda: receipt_dir) + return receipt + + +class TestUpdateReceiptEndpoint: + + def test_receipt_endpoint_returns_full_receipt_and_summary(self, client, tmp_path, monkeypatch): + receipt = _write_receipt(tmp_path, monkeypatch) + + resp = client.get("/api/hermes/update/receipt") + + assert resp.status_code == 200 + data = resp.json() + assert data["receipt"]["outcome"] == "success" + assert data["receipt"]["steps"] == receipt["steps"] + summary = data["summary"] + assert summary["outcome"] == "success" + assert summary["pre_sha"] == "a" * 40 + assert summary["post_sha"] == "b" * 40 + assert summary["post_version"] == "0.20.5" + assert summary["fleet_states"] == ["current"] + + def test_receipt_endpoint_404_when_no_receipt(self, client, tmp_path, monkeypatch): + import hermes_cli.update_receipt as ur + + monkeypatch.setattr(ur, "_receipt_dir", lambda: tmp_path / "none") + + resp = client.get("/api/hermes/update/receipt") + + assert resp.status_code == 404 + + +class TestUpdateStatusReadsReceipt: + + def _clear_registries(self, monkeypatch, tmp_path): + monkeypatch.setattr(web_server, "_ACTION_LOG_DIR", tmp_path / "actions") + (tmp_path / "actions").mkdir(exist_ok=True) + monkeypatch.setattr(web_server, "_ACTION_PROCS", {}) + monkeypatch.setattr(web_server, "_ACTION_RESULTS", {}) + monkeypatch.setattr(web_server, "_ACTION_COMMANDS", {}) + monkeypatch.setattr(web_server, "_ACTION_IDS", {}) + + def test_status_attaches_receipt_summary(self, client, tmp_path, monkeypatch): + _write_receipt(tmp_path, monkeypatch) + self._clear_registries(monkeypatch, tmp_path) + + resp = client.get("/api/actions/hermes-update/status") + + assert resp.status_code == 200 + data = resp.json() + assert data["receipt"]["outcome"] == "success" + + def test_finished_receipt_is_the_outcome_when_marker_and_memory_gone(self, client, tmp_path, monkeypatch): + """Dashboard restarted (registries lost) AND update.log has no + completion marker (rotated): the receipt alone reports success.""" + _write_receipt(tmp_path, monkeypatch, outcome="success") + self._clear_registries(monkeypatch, tmp_path) + + resp = client.get("/api/actions/hermes-update/status") + + data = resp.json() + assert data["running"] is False + assert data["exit_code"] == 0 + + def test_partial_receipt_maps_to_exit_1(self, client, tmp_path, monkeypatch): + _write_receipt(tmp_path, monkeypatch, outcome="partial") + self._clear_registries(monkeypatch, tmp_path) + + resp = client.get("/api/actions/hermes-update/status") + + assert resp.json()["exit_code"] == 1 + + def test_running_receipt_proves_nothing(self, client, tmp_path, monkeypatch): + """An unfinished receipt (crashed run / mid-update) must not report + an outcome — clients keep polling.""" + receipt = _write_receipt(tmp_path, monkeypatch, outcome="running") + self._clear_registries(monkeypatch, tmp_path) + + resp = client.get("/api/actions/hermes-update/status") + + assert resp.json()["exit_code"] is None + + def test_non_update_actions_untouched(self, client, tmp_path, monkeypatch): + _write_receipt(tmp_path, monkeypatch) + self._clear_registries(monkeypatch, tmp_path) + + resp = client.get("/api/actions/gateway-restart/status") + + data = resp.json() + assert "receipt" not in data From b44c2bdab28326e568abad176261b012a0858c6e Mon Sep 17 00:00:00 2001 From: Ishman82 <31279620+Ishman82@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:56:56 +0000 Subject: [PATCH 056/666] fix(desktop): spare concurrently starting backends Desktop writes backend.lock.json only after a remote profile backend announces readiness. Concurrent Bot Mode profile starts could therefore see their young siblings as unowned PPID-1 processes and mutually reap them, causing SSH reconnect storms and stale turn leases.\n\nProtect unregistered backends for a bounded startup grace period, fail closed when age cannot be read, and cover young, unknown-age, boundary, lock-owned, and old-orphan behavior. --- hermes_cli/dashboard_procs.py | 32 ++++++ .../test_orphan_desktop_serve_reap.py | 103 ++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/hermes_cli/dashboard_procs.py b/hermes_cli/dashboard_procs.py index 7f8d722facf1..c07e9232a3bb 100644 --- a/hermes_cli/dashboard_procs.py +++ b/hermes_cli/dashboard_procs.py @@ -838,6 +838,20 @@ def _lock_owned_serve_pids(base_dir: Path | None = None) -> set[int]: return owned +# Grace window before an orphaned-looking backend may be reaped. Covers the +# gap between process start and the Desktop client writing backend.lock.json. +_REAP_MIN_AGE_SECONDS = 180.0 + + +def _process_age_seconds(pid: int) -> float: + """Return a process age using psutil's cross-platform start timestamp.""" + import time as _time + + import psutil as _psutil + + return max(0.0, _time.time() - _psutil.Process(pid).create_time()) + + def _reap_orphaned_desktop_local_serves( *, reason: str = "orphaned desktop-local hermes serve", @@ -845,6 +859,7 @@ def _reap_orphaned_desktop_local_serves( signal_kill=None, sleep_fn=None, lock_owned_pids_fn=None, + process_age_seconds_fn=None, ) -> dict[str, list]: """Kill leftover Desktop-local ``hermes serve`` backends with no parent. @@ -880,6 +895,8 @@ def _reap_orphaned_desktop_local_serves( sleep_fn = _time.sleep if lock_owned_pids_fn is None: lock_owned_pids_fn = _lock_owned_serve_pids + if process_age_seconds_fn is None: + process_age_seconds_fn = _process_age_seconds if sys.platform == "win32": # Windows desktop uses taskkill tree teardown; orphan scan here is POSIX. @@ -926,6 +943,21 @@ def _reap_orphaned_desktop_local_serves( # Orphaned under init/launchd. if ppid not in (0, 1): continue + # Spare backends that are still starting up. backend.lock.json is + # written by the *Desktop client* only after the backend reports + # HERMES_BACKEND_READY, so a sibling spawned seconds ago is not yet + # lock-owned and is invisible to the owned_now guard above. When + # Desktop opens several profiles at once (each its own SSH spawn), + # every new backend reaped its concurrently-starting siblings, whose + # clients then reconnected and reaped the next batch -- a mutual-reap + # storm. A genuine corpse from a previous Desktop session is always + # older than this grace window; anything younger is a live sibling. + try: + if process_age_seconds_fn(pid) < _REAP_MIN_AGE_SECONDS: + continue + except Exception: + # Never let a liveness probe failure widen the reap. + continue targets.append((pid, cmd)) if not targets: diff --git a/tests/hermes_cli/test_orphan_desktop_serve_reap.py b/tests/hermes_cli/test_orphan_desktop_serve_reap.py index ab038d1f936c..8d1a7e82eb81 100644 --- a/tests/hermes_cli/test_orphan_desktop_serve_reap.py +++ b/tests/hermes_cli/test_orphan_desktop_serve_reap.py @@ -84,6 +84,7 @@ def fake_kill(pid, sig): sleep_fn=lambda _s: None, signal_term=15, signal_kill=9, + process_age_seconds_fn=lambda _pid: 600.0, ) assert set(result["matched"]) == {111, 444} @@ -234,6 +235,7 @@ def fake_kill(pid, sig): signal_term=15, signal_kill=9, lock_owned_pids_fn=lambda: lock_owned, + process_age_seconds_fn=lambda _pid: 600.0, ) # Only the genuine orphan (666) is reaped; the lock-owned remote (555) lives. @@ -243,6 +245,107 @@ def fake_kill(pid, sig): assert set(result["killed"]) == {666} +def test_reap_spares_young_backend_until_desktop_can_write_lock(): + """A concurrently-starting sibling has no lock yet but is not an orphan.""" + scanned = [(777, "hermes serve --isolated --host 127.0.0.1 --port 0")] + terms: list[int] = [] + + def fake_kill(pid, sig): + if sig == 0: + return None + if sig == 15: + terms.append(pid) + return None + + with ( + patch( + "hermes_cli.dashboard_procs._scan_dashboard_processes", + return_value=scanned, + ), + patch("hermes_cli.dashboard_procs._process_ppid", return_value=1), + patch("os.kill", side_effect=fake_kill), + patch("sys.platform", "darwin"), + ): + result = _reap_orphaned_desktop_local_serves( + sleep_fn=lambda _s: None, + signal_term=15, + signal_kill=9, + process_age_seconds_fn=lambda _pid: 2.0, + ) + + assert result["matched"] == [] + assert result["killed"] == [] + assert terms == [] + + +def test_reap_spares_backend_when_process_age_is_unknown(): + scanned = [(778, "hermes serve --isolated --host 127.0.0.1 --port 0")] + terms: list[int] = [] + + def fake_age(_pid): + raise RuntimeError("process disappeared during age probe") + + with ( + patch( + "hermes_cli.dashboard_procs._scan_dashboard_processes", + return_value=scanned, + ), + patch("hermes_cli.dashboard_procs._process_ppid", return_value=1), + patch("os.kill", side_effect=lambda pid, sig: terms.append(pid) if sig == 15 else None), + patch("sys.platform", "darwin"), + ): + result = _reap_orphaned_desktop_local_serves( + sleep_fn=lambda _s: None, + signal_term=15, + signal_kill=9, + process_age_seconds_fn=fake_age, + ) + + assert result["matched"] == [] + assert result["killed"] == [] + assert terms == [] + + +def test_reap_age_boundary_makes_180_second_orphan_eligible(): + scanned = [ + (779, "hermes serve --isolated --host 127.0.0.1 --port 0"), + (780, "hermes serve --isolated --host 127.0.0.1 --port 0"), + ] + terms: list[int] = [] + live = {779, 780} + + def fake_kill(pid, sig): + if sig == 0: + if pid in live: + return None + raise ProcessLookupError() + if sig == 15: + terms.append(pid) + live.discard(pid) + return None + + ages = {779: 179.999, 780: 180.0} + with ( + patch( + "hermes_cli.dashboard_procs._scan_dashboard_processes", + return_value=scanned, + ), + patch("hermes_cli.dashboard_procs._process_ppid", return_value=1), + patch("os.kill", side_effect=fake_kill), + patch("sys.platform", "darwin"), + ): + result = _reap_orphaned_desktop_local_serves( + sleep_fn=lambda _s: None, + signal_term=15, + signal_kill=9, + process_age_seconds_fn=lambda pid: ages[pid], + ) + + assert result["matched"] == [780] + assert result["killed"] == [780] + assert terms == [780] + + def test_reap_spare_lock_owned_backend_even_without_exclude_match(tmp_path): """End-to-end through the real lock scanner: a backend.lock.json on disk spares a matching orphaned serve even when HERMES_DESKTOP_CHILD_PID is From 3c44cd0c67b5a88aa7b2846d1a5304f18334db26 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:25:35 -0700 Subject: [PATCH 057/666] docs: record why the reap grace window exists in the reaper docstring Follow-up for salvaged PR #91994 -- without this note a future cleanup pass could read the age gate as dead weight. --- hermes_cli/dashboard_procs.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/hermes_cli/dashboard_procs.py b/hermes_cli/dashboard_procs.py index c07e9232a3bb..74a950db8b0e 100644 --- a/hermes_cli/dashboard_procs.py +++ b/hermes_cli/dashboard_procs.py @@ -882,6 +882,13 @@ def _reap_orphaned_desktop_local_serves( by another client/machine* which legitimately sit at ppid 1 after sshd exits. Killing those is a production incident, not cleanup. - never fixed-port remote serves (e.g. ``--port 9119``) + - never a candidate younger than ``_REAP_MIN_AGE_SECONDS`` (or whose age + cannot be determined). The Desktop client writes ``backend.lock.json`` + only after the backend reports HERMES_BACKEND_READY, so during + concurrent multi-profile startup a live sibling is briefly unowned and + otherwise indistinguishable from a corpse; sparing young processes + closes that mutual-reap window. A genuine corpse merely waits for a + later scan. - best-effort; failures never raise to the caller """ import signal as _signal From 21a93f0a67088ebaf74ce15078286d71f1c6a770 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Sun, 23 Aug 2026 12:21:17 +0700 Subject: [PATCH 058/666] fix(vision): size native embeds for history reuse vision_analyze baked up to 4 MB / 7900px screenshots into immutable history, so every later turn re-sent ~400K chars. Cap embeds at 256 KB and 1568px (the long edge models actually read) so screenshot QA no longer blows the context. --- tests/tools/test_vision_native_fast_path.py | 22 ++++++---- tests/tools/test_vision_region.py | 2 +- tools/vision_tools.py | 47 +++++++++------------ 3 files changed, 36 insertions(+), 35 deletions(-) diff --git a/tests/tools/test_vision_native_fast_path.py b/tests/tools/test_vision_native_fast_path.py index dd6fac5bc340..715b3ee78163 100644 --- a/tests/tools/test_vision_native_fast_path.py +++ b/tests/tools/test_vision_native_fast_path.py @@ -108,12 +108,10 @@ def test_oversized_image_resized_under_embed_cap(self, tmp_path): """Regression for the wedged-session incident (May 2026). A vision tool-result image is baked into conversation history and - re-sent on every subsequent turn. Anthropic rejects any single - base64 image over 5 MB with a 400, and immutable history means the - bad bytes can't be cleared by retrying — the session is permanently - wedged. The native fast path must proactively resize down to the - embed cap (well under 5 MB) BEFORE embedding, not just at the 20 MB - hard ceiling. Skips if Pillow isn't available (resize is a no-op). + re-sent on every subsequent turn. The native fast path must + proactively resize down to the history-reuse embed cap BEFORE + embedding, not just at the 20 MB hard ceiling. Skips if Pillow + isn't available (resize is a no-op). """ pytest = __import__("pytest") try: @@ -138,10 +136,18 @@ def test_oversized_image_resized_under_embed_cap(self, tmp_path): if p.get("type") == "image_url" ) assert len(url) <= _EMBED_TARGET_BYTES, ( - f"embedded image {len(url) / 1024 / 1024:.1f} MB exceeds embed cap " - f"{_EMBED_TARGET_BYTES / 1024 / 1024:.0f} MB — would wedge sessions on Anthropic" + f"embedded image {len(url) / 1024:.0f} KB exceeds embed cap " + f"{_EMBED_TARGET_BYTES / 1024:.0f} KB — would bloat every later turn" ) + def test_embed_caps_are_sized_for_history_reuse(self): + """Native embeds ride every later turn, so caps must stay well below + the Anthropic 5 MB / 8000px reject limits (#92699).""" + from tools.vision_tools import _EMBED_MAX_DIMENSION, _EMBED_TARGET_BYTES + + assert _EMBED_TARGET_BYTES <= 512 * 1024 + assert _EMBED_MAX_DIMENSION <= 2048 + # ─── _handle_vision_analyze fast-path gating ───────────────────────────────── diff --git a/tests/tools/test_vision_region.py b/tests/tools/test_vision_region.py index 3560458f5f93..dcc7c5f25526 100644 --- a/tests/tools/test_vision_region.py +++ b/tests/tools/test_vision_region.py @@ -142,7 +142,7 @@ def test_crop_applied_before_downscale_gets_full_budget(self, tmp_path): downscaled with the rest.""" from tools.vision_tools import _EMBED_MAX_DIMENSION, _vision_analyze_native - # Taller than the 7900px embed cap — full shot would be downscaled. + # Taller than the embed long-edge cap — full shot would be downscaled. big = tmp_path / "big.png" Image.new("RGB", (200, _EMBED_MAX_DIMENSION + 500), (0, 100, 0)).save( big, format="PNG" diff --git a/tools/vision_tools.py b/tools/vision_tools.py index e008983f7f20..37898302edde 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -625,25 +625,21 @@ def _image_to_base64_data_url(image_path: Path, mime_type: Optional[str] = None) # provider accepts the image and we reject outright. _MAX_BASE64_BYTES = 20 * 1024 * 1024 -# Proactive embed cap (4 MB). This is the size we resize an image DOWN to -# before embedding it into conversation history, regardless of the 20 MB hard -# ceiling. Anthropic's per-image base64 limit is 5 MB; once an oversized image -# is baked into history (e.g. a vision tool-result), it is re-sent on every -# subsequent turn and permanently wedges the session with a 400 that retries -# can't clear (the bad bytes are immutable history). Capping at embed time — -# with headroom under 5 MB — is the only durable fix. Matches the post-failure -# shrink target in agent.conversation_compression so behaviour is consistent -# whether we resize proactively or reactively. -_EMBED_TARGET_BYTES = 4 * 1024 * 1024 - -# Proactive embed dimension cap (px, longest side). Anthropic enforces an -# 8000px per-side ceiling INDEPENDENTLY of the 5 MB byte cap — a tall full-page -# screenshot can be well under 5 MB yet far over 8000px (e.g. 1200×12000 at -# 0.06 MB), so the byte-only embed check above lets it slip into immutable -# history un-resized and the session bricks on a non-retryable 400. We cap at -# 7900 (headroom under 8000) so the proactive resize shrinks tall small-byte -# images before they are embedded. -_EMBED_MAX_DIMENSION = 7900 +# Proactive embed cap for conversation-history reuse. Native vision_analyze +# bakes the data-URL into the tool result, which is re-sent on every later +# turn. The 20 MB hard ceiling / Anthropic 5 MB reject-cap still apply as +# safety nets; those are one-shot viewing limits, not history-reuse sizes. +# A 4 MB / 7900px embed was observed at ~400K chars and ~100–260K billed +# tokens per image (#92699), so we size for model reading instead: 256 KB +# is tens of KB after JPEG encode of a 1568px screenshot, well under every +# provider's per-image limit, and cheap enough to ride the session. +_EMBED_TARGET_BYTES = 256 * 1024 + +# Proactive embed dimension cap (px, longest side). Anthropic still rejects +# above 8000px independently of the byte cap, but its tokenizer downsamples +# to a 1568px long edge — pixels past that cost wire bytes and never extra +# model fidelity. Cap at 1568 so history embeds match what the model sees. +_EMBED_MAX_DIMENSION = 1568 # Target size when auto-resizing on API failure (5 MB). After a provider # rejects an image, we downscale to this target and retry once. @@ -1240,13 +1236,12 @@ async def _vision_analyze_native( ) # Proactive embed cap: this image gets baked into conversation - # history and re-sent on every subsequent turn. Anthropic rejects - # any single base64 image over 5 MB OR over 8000px per side with a - # 400, and because history is immutable, an oversized embed - # permanently wedges the session — retries can't clear bytes (or - # pixels) that are already in the request. Resize DOWN to the embed - # target (4 MB / 7900px, headroom under both ceilings) whenever the - # payload exceeds either limit, not just at the 20 MB hard ceiling. + # history and re-sent on every subsequent turn. Resize DOWN to the + # history-reuse target whenever the payload exceeds either the byte + # or long-edge cap, not just at the 20 MB hard ceiling. Anthropic + # still rejects >5 MB / >8000px with a non-retryable 400, but those + # are one-shot viewing limits — history embeds are sized smaller so + # repeated vision_analyze turns don't blow the context (#92699). _over_bytes = len(image_data_url) > _EMBED_TARGET_BYTES _over_dims = await _run_encode_on_cpu_executor( _image_exceeds_dimension, temp_image_path, _EMBED_MAX_DIMENSION, From 7ff2fe8bc9fe9eb8881cba053892cb99e26688f4 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Sun, 23 Aug 2026 12:21:26 +0700 Subject: [PATCH 059/666] fix(compression): retire stale vision tool images in the protected tail Images locked in protect_last_n never shrank, so compression savings stayed under 10% and anti-thrash disabled further compaction. Keep the newest three tool-result screenshots live for follow-up QA and replace older native embeds with placeholders. --- agent/context_compressor.py | 68 +++++++++ .../test_compressor_stale_tool_images.py | 140 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 tests/agent/test_compressor_stale_tool_images.py diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 2a0b5dc4a449..aa3d7a78460c 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1220,6 +1220,13 @@ def _collect_protected_skill_names( # protected region — but always keep this many trailing messages verbatim so # the active user ask / latest tool pair remain readable. Issue #61932. _PRESSURE_KEEP_RECENT_MESSAGES = 3 +# Native vision_analyze / computer_use screenshots that sit inside the +# protected tail cannot be demoted by pass 2, so they ride every later +# request until anti-thrash disables compression (#92699). Keep this many +# newest image-bearing tool results verbatim; retire older image payloads +# even when they fall inside ``protect_last_n``. Matches the Anthropic +# adapter's outbound keep-window. +_MAX_KEEP_TOOL_IMAGES = 3 # Models with context windows below this get their compression threshold # floored at ``_SMALL_CTX_THRESHOLD_PERCENT`` (raise-only — an explicitly @@ -1560,6 +1567,60 @@ def _strip_image_parts_from_parts(parts: Any) -> Any: return out if had_image else None +def _tool_content_has_images(content: Any) -> bool: + """True when a tool-result body carries embedded image bytes. + + Handles both unwrapped OpenAI-style part lists and the native + ``{_multimodal: True, content: [...]}`` envelope vision_analyze returns. + """ + if isinstance(content, dict) and content.get("_multimodal"): + return _content_has_images(content.get("content")) + return _content_has_images(content) + + +def _retire_stale_tool_result_images( + result: List[Dict[str, Any]], + keep_newest: int = _MAX_KEEP_TOOL_IMAGES, +) -> int: + """Replace image payloads on older tool results with text placeholders. + + Walks newest-first, keeps the most recent ``keep_newest`` image-bearing + tool messages intact (follow-up screenshot QA still sees the latest + frames), and retires the rest. User-role uploads are not touched. + + Mutates ``result`` in place. Returns the number of messages rewritten. + """ + if keep_newest < 0: + keep_newest = 0 + seen = 0 + pruned = 0 + for i in range(len(result) - 1, -1, -1): + msg = result[i] + if not isinstance(msg, dict) or msg.get("role") != "tool": + continue + content = msg.get("content") + if not _tool_content_has_images(content): + continue + seen += 1 + if seen <= keep_newest: + continue + if isinstance(content, dict) and content.get("_multimodal"): + summary = content.get("text_summary") or "[screenshot removed to save context]" + new_msg = {**msg, "content": f"[screenshot removed] {summary[:200]}"} + drop_stale_api_content(new_msg) + result[i] = new_msg + pruned += 1 + continue + stripped = _strip_image_parts_from_parts(content) + if stripped is None: + continue + new_msg = {**msg, "content": stripped} + drop_stale_api_content(new_msg) + result[i] = new_msg + pruned += 1 + return pruned + + def _truncate_tool_call_args_json(args: str, head_chars: int = 200) -> str: """Shrink long string values inside a tool-call arguments JSON blob while preserving JSON validity. @@ -3779,6 +3840,13 @@ def _truncate_tool_call_args_at(idx: int) -> bool: for i in range(max(0, prune_boundary)): _truncate_tool_call_args_at(i) + # Pass 3.5 (#92699): retire image payloads that pass 2 cannot reach + # because they sit inside the protected tail. Native vision_analyze + # embeds re-sent on every turn otherwise make compression look + # ineffective (savings < 10%) and anti-thrash disables it. Newest + # frames stay live for follow-up QA; older ones become placeholders. + pruned += _retire_stale_tool_result_images(result) + # Pass 4 (issue #61932): protected-tail pressure demotion. # After multiple in-place compactions the transcript can be short # enough that nearly every remaining message sits inside the diff --git a/tests/agent/test_compressor_stale_tool_images.py b/tests/agent/test_compressor_stale_tool_images.py new file mode 100644 index 000000000000..a6972b397a2a --- /dev/null +++ b/tests/agent/test_compressor_stale_tool_images.py @@ -0,0 +1,140 @@ +"""Retire stale vision_analyze / screenshot tool images inside the protected tail. + +Issue #92699: native embeds sitting in protect_last_n ride every later +request, compression savings stay tiny, and anti-thrash disables further +compaction. Pass 2 of ``_prune_old_tool_results`` only strips images +*outside* the tail; this suite pins the extra keep-newest pass. +""" + +from __future__ import annotations + +from agent.context_compressor import ( + ContextCompressor, + _MAX_KEEP_TOOL_IMAGES, + _content_has_images, + _tool_content_has_images, +) + + +def _compressor() -> ContextCompressor: + c = ContextCompressor.__new__(ContextCompressor) + c.quiet_mode = True + return c + + +def _image_tool(i: int, *, blob: str = "A" * 400) -> list[dict]: + return [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": f"call_{i}", + "type": "function", + "function": { + "name": "vision_analyze", + "arguments": f'{{"image_url":"shot{i}.png"}}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": f"call_{i}", + "content": [ + {"type": "text", "text": f"Image attached natively shot {i}"}, + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{blob}{i}"}, + }, + ], + }, + ] + + +def _image_bearing_tools(messages: list[dict]) -> list[dict]: + return [ + m + for m in messages + if m.get("role") == "tool" and _tool_content_has_images(m.get("content")) + ] + + +class TestRetireStaleToolImagesInProtectedTail: + def test_keep_newest_images_inside_a_large_protect_window(self): + """Five native embeds all fit in protect_last_n=20; only newest N stay.""" + msgs: list[dict] = [{"role": "user", "content": "screenshot QA"}] + for i in range(5): + msgs.extend(_image_tool(i)) + msgs.append({"role": "user", "content": "compare the last few"}) + + out, pruned = _compressor()._prune_old_tool_results( + msgs, protect_tail_count=20, + ) + assert pruned >= 5 - _MAX_KEEP_TOOL_IMAGES + kept = _image_bearing_tools(out) + assert len(kept) == _MAX_KEEP_TOOL_IMAGES + kept_ids = [m["tool_call_id"] for m in kept] + assert kept_ids == [f"call_{i}" for i in range(5 - _MAX_KEEP_TOOL_IMAGES, 5)] + + def test_multimodal_envelopes_outside_keep_window_become_text(self): + msgs: list[dict] = [{"role": "user", "content": "go"}] + for i in range(_MAX_KEEP_TOOL_IMAGES + 1): + msgs.append({ + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": f"env_{i}", + "type": "function", + "function": {"name": "vision_analyze", "arguments": "{}"}, + }], + }) + msgs.append({ + "role": "tool", + "tool_call_id": f"env_{i}", + "content": { + "_multimodal": True, + "content": [ + {"type": "text", "text": f"shot {i}"}, + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,XYZ{i}"}, + }, + ], + "text_summary": f"native shot {i}", + }, + }) + + out, _ = _compressor()._prune_old_tool_results( + msgs, protect_tail_count=20, + ) + oldest = next(m for m in out if m.get("tool_call_id") == "env_0") + assert isinstance(oldest["content"], str) + assert "screenshot removed" in oldest["content"] + assert "native shot 0" in oldest["content"] + newest = next( + m for m in out + if m.get("tool_call_id") == f"env_{_MAX_KEEP_TOOL_IMAGES}" + ) + assert _tool_content_has_images(newest["content"]) + + def test_user_uploads_are_not_retired(self): + user_img = { + "role": "user", + "content": [ + {"type": "text", "text": "look"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,USERUPLOAD"}, + }, + ], + } + msgs = [user_img] + for i in range(_MAX_KEEP_TOOL_IMAGES + 2): + msgs.extend(_image_tool(i)) + + out, _ = _compressor()._prune_old_tool_results( + msgs, protect_tail_count=20, + ) + assert _content_has_images(out[0]["content"]) + assert out[0]["content"][1]["image_url"]["url"].endswith("USERUPLOAD") From dff84f18901c3b3c082e7783ea03b7bb7b9ef6c7 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:56:47 +0530 Subject: [PATCH 060/666] fix(browser): cap browser_vision native embeds for history reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit browser_vision's native fast path base64-encoded screenshots at full resolution and baked them into the tool result uncapped — the exact sibling of the vision_analyze path #92699 fixed. Apply the same proactive 256KB/1568px resize before the embed enters reusable history. Fail-open by design: without Pillow the resize helper falls back to raw bytes and the compressor's keep-newest pass still retires stale embeds. Sibling-gap follow-up for the #92725 salvage; the shared-cap approach mirrors the policy-owner idea from #92748. Co-authored-by: joaomarcos --- agent/context_compressor.py | 17 ++++---- tests/tools/test_browser_console.py | 62 +++++++++++++++++++++++++++++ tools/browser_tool.py | 37 ++++++++++++++--- tools/vision_tools.py | 5 ++- 4 files changed, 107 insertions(+), 14 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index aa3d7a78460c..881e7cfe45ea 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -3942,16 +3942,19 @@ def prune_tool_results_only( threshold (≈100K tokens on a 1M window) and would protect the entire session, pruning nothing. - ``_prune_old_tool_results`` runs all three deterministic passes: + ``_prune_old_tool_results`` runs all deterministic passes: (1) dedup byte-identical tool results — keeps the newest full copy and back-references older exact duplicates ANYWHERE in the list (including the protected tail), so no unique content is ever lost; (2) summarize non-tail tool results larger than ``min_prune_chars``; (3) truncate - oversized tool_call arguments on non-tail assistant messages. Only - pass (2)'s floor is raised by ``proactive_prune_min_result_chars``; - passes (1) and (3) keep their own fixed floors. The recent-tail - protection applies to passes (2) and (3); pass (1) is tail-agnostic by - design because dedup is lossless. + oversized tool_call arguments on non-tail assistant messages; + (3.5) retire image payloads on all but the newest + ``_MAX_KEEP_TOOL_IMAGES`` image-bearing tool results — tail-agnostic + and lossy by design (#92699). Only pass (2)'s floor is raised by + ``proactive_prune_min_result_chars``; passes (1) and (3) keep their + own fixed floors. The recent-tail protection applies to passes (2) + and (3); pass (1) is tail-agnostic by design because dedup is + lossless. PROMPT-CACHE CONTRACT: a committed prune rewrites message bodies the provider has already seen, invalidating the cached prefix from the @@ -3975,7 +3978,7 @@ def prune_tool_results_only( before = sum(_estimate_msg_budget_tokens(m) for m in messages) if before < self._proactive_prune_rearm_tokens: return messages, 0 - # Capability gate BEFORE the expensive 3-pass scan: a bound store that + # Capability gate BEFORE the expensive multi-pass scan: a bound store that # can't persist the prune atomically (duck-typed/plugin session store # without archive_and_compact) makes every prune a permanent no-op, so # don't pay the scan for it on every eligible iteration. diff --git a/tests/tools/test_browser_console.py b/tests/tools/test_browser_console.py index abed1bb38041..fc5c4ab71e9b 100644 --- a/tests/tools/test_browser_console.py +++ b/tests/tools/test_browser_console.py @@ -318,6 +318,68 @@ def test_browser_vision_native_fast_path_returns_multimodal(self, tmp_path): mock_get_vision_model.assert_not_called() mock_llm.assert_not_called() + def test_browser_vision_native_fast_path_caps_history_embed(self, tmp_path): + """Oversized screenshots are resized before entering history (#92699). + + browser_vision's native fast path bakes the data URL into the tool + result exactly like vision_analyze — without the proactive resize a + full-res screenshot rides every later request uncapped. + """ + pytest.importorskip("PIL") + import base64 + from io import BytesIO + + from PIL import Image + + from agent.auxiliary_client import clear_runtime_main, set_runtime_main + from tools.browser_tool import browser_vision + from tools.vision_tools import _EMBED_MAX_DIMENSION, _EMBED_TARGET_BYTES + + shots_dir = tmp_path / "browser_screenshots" + shots_dir.mkdir() + screenshot = shots_dir / "shot.png" + # Taller than the long-edge cap so the resize path must fire. + Image.new("RGB", (400, _EMBED_MAX_DIMENSION + 500), (0, 100, 0)).save( + screenshot, format="PNG" + ) + + set_runtime_main("brand-new-provider", "llava-v1.6") + try: + with ( + patch("hermes_constants.get_hermes_dir", return_value=shots_dir), + patch("tools.browser_tool._cleanup_old_screenshots"), + patch( + "tools.browser_tool._run_browser_command", + return_value={ + "success": True, + "data": {"path": str(screenshot)}, + }, + ), + patch( + "hermes_cli.config.load_config", + return_value={"model": {"supports_vision": True}}, + ), + patch("tools.browser_tool.call_llm") as mock_llm, + ): + result = browser_vision("what is on the page?", task_id="test") + finally: + clear_runtime_main() + + assert isinstance(result, dict) + assert result["_multimodal"] is True + url = next( + p["image_url"]["url"] + for p in result["content"] + if p.get("type") == "image_url" + ) + assert len(url) <= _EMBED_TARGET_BYTES, ( + f"embedded browser screenshot {len(url) / 1024:.0f} KB exceeds the " + f"history-reuse cap {_EMBED_TARGET_BYTES / 1024:.0f} KB" + ) + with Image.open(BytesIO(base64.b64decode(url.partition(",")[2]))) as img: + assert max(img.size) <= _EMBED_MAX_DIMENSION + mock_llm.assert_not_called() + def test_browser_vision_text_mode_blocks_native_fast_path(self, tmp_path): """Explicit text routing → aux LLM used even with supports_vision.""" from agent.auxiliary_client import clear_runtime_main, set_runtime_main diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 3d191a874c77..0d595f0f5ab0 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -4711,26 +4711,46 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] ), }, ensure_ascii=False) - # Convert screenshot to base64 at full resolution. - _screenshot_bytes = screenshot_path.read_bytes() - _screenshot_b64 = base64.b64encode(_screenshot_bytes).decode("ascii") - data_url = f"data:image/png;base64,{_screenshot_b64}" + # NOTE: the full-resolution base64 encode is deliberately deferred. + # The native fast path below sizes its own history-reuse embed via + # _resize_image_for_vision (stat-based quick estimate — no full-res + # encode when oversized), and only the aux-LLM fallback path needs + # the one-shot full-res data URL. # Fast path: when native image routing is in effect for the active main # model, attach the screenshot directly instead of describing it through # an auxiliary vision LLM. The model inspects the pixels on its next # turn — no aux call, no information loss. Consistent with vision_analyze. from tools.vision_tools import ( + _EMBED_MAX_DIMENSION, + _EMBED_TARGET_BYTES, _build_native_vision_tool_result, + _resize_image_for_vision, _should_use_native_vision_fast_path, ) if _should_use_native_vision_fast_path(): + # History-reuse cap (#92699): this embed is baked into the tool + # result and re-sent on every later turn, exactly like + # vision_analyze's native path — apply the same proactive resize + # so full-res screenshots can't enter immutable history uncapped. + # The helper's internal stat/dimension quick-estimate skips the + # resize (and encodes directly) when the screenshot is already + # under both caps, so no full-res base64 is built just to be + # thrown away. Fail-open: without Pillow it falls back to the + # raw bytes and the compressor's keep-newest pass still retires + # stale embeds. + data_url = _resize_image_for_vision( + screenshot_path, + mime_type="image/png", + max_base64_bytes=_EMBED_TARGET_BYTES, + max_dimension=_EMBED_MAX_DIMENSION, + ) native_result = _build_native_vision_tool_result( image_url=str(screenshot_path), question=question, image_data_url=data_url, - image_size_bytes=len(_screenshot_bytes), + image_size_bytes=screenshot_path.stat().st_size, ) meta = native_result.setdefault("meta", {}) meta["screenshot_path"] = str(screenshot_path) @@ -4753,6 +4773,13 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] f"Focus on answering the user's specific question." ) + # Aux-LLM path: one-shot analysis, not baked into history — encode at + # full resolution here (the pre-existing 5 MB oversize guard below + # still applies). + _screenshot_bytes = screenshot_path.read_bytes() + _screenshot_b64 = base64.b64encode(_screenshot_bytes).decode("ascii") + data_url = f"data:image/png;base64,{_screenshot_b64}" + # Use the centralized LLM router vision_model = _get_vision_model() logger.debug("browser_vision: analysing screenshot (%d bytes)", diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 37898302edde..074dd14103d2 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -631,8 +631,9 @@ def _image_to_base64_data_url(image_path: Path, mime_type: Optional[str] = None) # safety nets; those are one-shot viewing limits, not history-reuse sizes. # A 4 MB / 7900px embed was observed at ~400K chars and ~100–260K billed # tokens per image (#92699), so we size for model reading instead: 256 KB -# is tens of KB after JPEG encode of a 1568px screenshot, well under every -# provider's per-image limit, and cheap enough to ride the session. +# keeps a 1568px screenshot cheap enough to ride the session (PNGs that +# exceed it are downscaled further by the byte-budget ladder), well under +# every provider's per-image limit. _EMBED_TARGET_BYTES = 256 * 1024 # Proactive embed dimension cap (px, longest side). Anthropic still rejects From 016ba661764fd59a87a728934feec6b98169647c Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:31:00 +0530 Subject: [PATCH 061/666] fix: stamp _db_persisted at row load time so resumed transcripts never re-append (#92231) Resumed sessions loaded message dicts from state.db WITHOUT the _DB_PERSISTED_MARKER, so any flush that lost the identity boundary (compression durable-snapshot adoption, incremental tool-call persists, rotation preflight on cold resume) re-appended the ENTIRE loaded transcript as new rows. Compression cycles then doubled the copies: the incident session grew 998 -> 1995 -> 3990 -> 7981 rows across three aborted rotations (15,962 active rows, only 472 distinct). Fix at the architectural chokepoint: SessionDB._rows_to_conversation (shared by get_messages_as_conversation and get_resume_conversations) now stamps the marker at row materialization time - a dict built FROM a durable row is persisted by construction, regardless of which caller loads it or how the list is later handed to a flush. Safety: - Wire-safe: every transport strips underscore-prefixed keys before the API request (chat_completion_helpers, anthropic_adapter), same contract as the existing _row_id stamp in the same function. - Rotation handoffs still write: compression's assembly copies strip the marker (_fresh_compaction_message_copy + the terminal _strip_persistence_markers sweep), so compacted transcripts still flush to the child session (#57491 invariant preserved). - Branch/seed copies unaffected: /branch and _persist_branch_seed build fresh field-projected dicts and write via append_messages_batch directly, not through the marker-gated flush. Tests: new regression suite (marker sync, load stamping, 3-cycle amplification repro, new-tail write guard, compaction-copy handoff); updated the #68454 control test that asserted the old double-write behavior and the ACP restore shape test. --- hermes_state.py | 20 +++ tests/acp/test_session.py | 3 + .../test_load_time_durability_stamp_92231.py | 147 ++++++++++++++++++ ...ession_rotation_flush_cold_resume_68454.py | 13 +- tests/test_message_reactions.py | 10 +- 5 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 tests/agent/test_load_time_durability_stamp_92231.py diff --git a/hermes_state.py b/hermes_state.py index a6a0dc7441e2..420c840014f2 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -776,6 +776,14 @@ def _strip_background_review_harness( # Matches a bare protocol/tool-name marker such as "[memory]" or "[skill_manage]". _STALE_TOOL_CALL_MARKER_RE = re.compile(r"^\[[A-Za-z_][A-Za-z0-9_.-]*\]$") +# Intrinsic persistence marker stamped on message dicts that are known-durable. +# MUST stay in sync with run_agent._DB_PERSISTED_MARKER and +# agent.context_compressor._DB_PERSISTED_MARKER (same literal; defined locally +# because hermes_state must not import run_agent — circular import — and +# importing the agent layer for one constant inverts the state/agent layering). +# Drift is guarded by test_marker_constant_in_sync (#92231). +_DB_PERSISTED_MARKER_KEY = "_db_persisted" + def _is_stale_tool_call_marker_message(msg: Dict[str, Any]) -> bool: """True when ``msg`` is a persisted assistant turn whose content is a bare @@ -11090,6 +11098,18 @@ def _rows_to_conversation( if row["role"] in {"user", "assistant"} and isinstance(content, str): content = sanitize_context(content).strip() msg = {"role": row["role"], "content": content} + # Born durable (#92231): this dict is materialized FROM a durable + # row, so stamp the persistence marker at the source instead of + # relying on every restore caller to thread the loaded list back + # through a flush as ``conversation_history=`` — any + # identity-losing handoff (compression's durable-snapshot + # adoption, incremental persists with no history arg) would + # otherwise re-append the ENTIRE transcript on flush. + # Underscore-prefixed like ``_row_id``: every transport strips it + # before the wire, and compression's assembly copies deliberately + # strip it so rotated child handoffs still flush (see + # _fresh_compaction_message_copy). + msg[_DB_PERSISTED_MARKER_KEY] = True # Durable per-message identity for surfaces that need to address a # specific row later (desktop reactions). OPT-IN: only the gateway # asks for it — every other consumer (ACP restore, export, diff --git a/tests/acp/test_session.py b/tests/acp/test_session.py index 3d8bc1908909..7922846725d6 100644 --- a/tests/acp/test_session.py +++ b/tests/acp/test_session.py @@ -326,6 +326,9 @@ def test_assistant_reasoning_fields_persisted(self, manager): assert restored is not None msg = restored.history[0] assert isinstance(msg.pop("timestamp", None), (int, float)) + # Load-time durability stamp (#92231): rows materialized from the DB + # are marked persisted so a later flush can't re-append them. + assert msg.pop("_db_persisted", None) is True assert restored.history == [{ "role": "assistant", "content": "hello", diff --git a/tests/agent/test_load_time_durability_stamp_92231.py b/tests/agent/test_load_time_durability_stamp_92231.py new file mode 100644 index 000000000000..32803071c240 --- /dev/null +++ b/tests/agent/test_load_time_durability_stamp_92231.py @@ -0,0 +1,147 @@ +"""Regression (#92231): resumed transcripts must not re-append to state.db. + +Root cause: ``get_messages_as_conversation`` / ``get_resume_conversations`` +returned plain dicts WITHOUT ``_DB_PERSISTED_MARKER``. Any flush that received +that loaded history without a matching ``conversation_history=`` identity +boundary (compression durable-snapshot adoption, incremental tool-call +persists, rotation preflight on cold resume) treated every loaded row as new +and re-appended the ENTIRE transcript. Compression cycles then doubled the +copies: the incident session grew 998 → 1995 → 3990 → 7981 rows across three +aborted rotations (15,962 active rows / 472 distinct contents). + +Fix: rows are stamped durable at materialization time in +``SessionDB._rows_to_conversation`` — a dict built FROM a durable row is +persisted by construction, no matter which caller loads it or how it is later +handed to a flush. +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from hermes_state import SessionDB, _DB_PERSISTED_MARKER_KEY +from run_agent import AIAgent + + +def _make_flush_agent(db: SessionDB, session_id: str): + """Minimal agent shell that owns the real flush implementation.""" + agent = SimpleNamespace( + _session_db=db, + _session_db_created=True, + _persist_disabled=False, + session_id=session_id, + _session_persist_lock=None, + _flushed_db_message_ids=set(), + _flushed_db_message_session_id=None, + _last_flushed_db_idx=0, + _persist_user_message_idx=None, + _persist_user_message_override=None, + _persist_user_message_timestamp=None, + _pending_cli_user_message=None, + ) + agent._ensure_db_session = lambda: None + agent._flush_messages_to_session_db = ( + AIAgent._flush_messages_to_session_db.__get__(agent, AIAgent) + ) + agent._flush_messages_to_session_db_unlocked = ( + AIAgent._flush_messages_to_session_db_unlocked.__get__(agent, AIAgent) + ) + return agent + + +def _seed_session(db: SessionDB, sid: str, turns: int = 3) -> None: + db.create_session(sid, source="cli") + for i in range(turns): + db.append_message(sid, "user", f"question {i}") + db.append_message(sid, "assistant", f"answer {i}") + + +def test_marker_constant_in_sync() -> None: + """hermes_state cannot import run_agent (circular) — literals must match.""" + import agent.context_compressor as cc + import run_agent + + assert _DB_PERSISTED_MARKER_KEY == run_agent._DB_PERSISTED_MARKER + assert _DB_PERSISTED_MARKER_KEY == cc._DB_PERSISTED_MARKER + + +def test_loaded_rows_are_stamped_durable(tmp_path: Path) -> None: + db = SessionDB(db_path=tmp_path / "state.db") + _seed_session(db, "S1") + + loaded = db.get_messages_as_conversation("S1") + assert loaded + assert all(m.get(_DB_PERSISTED_MARKER_KEY) is True for m in loaded) + + model_history, display_history = db.get_resume_conversations("S1") + assert model_history and display_history + assert all(m.get(_DB_PERSISTED_MARKER_KEY) is True for m in model_history) + assert all(m.get(_DB_PERSISTED_MARKER_KEY) is True for m in display_history) + + +def test_repeated_identityless_flushes_do_not_amplify(tmp_path: Path) -> None: + """The #92231 shape: reload + flush cycles must keep the row count flat. + + Each cycle simulates what compression's durable-snapshot adoption (or any + identity-losing handoff) used to do: load the transcript fresh from the DB + and flush it with NO ``conversation_history`` boundary. Pre-fix each cycle + doubled the row count (998 → 1995 → 3990 → 7981 in the incident session). + """ + db = SessionDB(db_path=tmp_path / "state.db") + _seed_session(db, "S2", turns=4) + baseline = len(db.get_messages("S2")) + + for _ in range(3): + loaded = db.get_messages_as_conversation("S2") + agent = _make_flush_agent(db, "S2") # fresh agent: no identity state + agent._flush_messages_to_session_db(loaded) + + assert len(db.get_messages("S2")) == baseline + + +def test_new_tail_after_loaded_history_still_flushes(tmp_path: Path) -> None: + """Guard against over-skipping: only loaded rows are exempt, new turns write.""" + db = SessionDB(db_path=tmp_path / "state.db") + _seed_session(db, "S3", turns=1) + + loaded = db.get_messages_as_conversation("S3") + live = [ + *loaded, + {"role": "user", "content": "new question"}, + {"role": "assistant", "content": "new answer"}, + ] + agent = _make_flush_agent(db, "S3") + agent._flush_messages_to_session_db(live) + + contents = [m.get("content") for m in db.get_messages("S3")] + assert contents == ["question 0", "answer 0", "new question", "new answer"] + + +def test_compaction_copy_strips_stamp_so_child_flush_writes(tmp_path: Path) -> None: + """Rotation handoff: compression copies must stay flushable to the child. + + ``_fresh_compaction_message_copy`` / ``_strip_persistence_markers`` remove + the marker from assembled compaction output so the rotation flush WRITES + the compacted transcript to the child session (#57491). Load-stamping must + not defeat that: a loaded row that goes through the compaction copy is + written to the child exactly once. + """ + from agent.context_compressor import _fresh_compaction_message_copy + + db = SessionDB(db_path=tmp_path / "state.db") + _seed_session(db, "PARENT", turns=2) + db.create_session("CHILD", source="cli", parent_session_id="PARENT") + + loaded = db.get_messages_as_conversation("PARENT") + compacted = [_fresh_compaction_message_copy(m) for m in loaded] + assert all(_DB_PERSISTED_MARKER_KEY not in m for m in compacted) + + agent = _make_flush_agent(db, "CHILD") + agent._flush_messages_to_session_db(compacted) + child_rows = db.get_messages("CHILD") + assert len(child_rows) == len(loaded) + + # Idempotent thereafter: the flush stamped the copies on write. + agent._flush_messages_to_session_db(compacted) + assert len(db.get_messages("CHILD")) == len(loaded) diff --git a/tests/agent/test_session_rotation_flush_cold_resume_68454.py b/tests/agent/test_session_rotation_flush_cold_resume_68454.py index c7650fa631da..48c29e0178c8 100644 --- a/tests/agent/test_session_rotation_flush_cold_resume_68454.py +++ b/tests/agent/test_session_rotation_flush_cold_resume_68454.py @@ -50,8 +50,15 @@ def _contents(rows): return [r.get("content") for r in rows] -def test_rotation_flush_without_history_boundary_duplicates(tmp_path: Path) -> None: - """Control: bare flush of unstamped cold-resume rows double-writes (#68454).""" +def test_rotation_flush_without_history_boundary_is_safe(tmp_path: Path) -> None: + """Bare flush of cold-resumed rows must NOT double-write (#68454 → #92231). + + Historically this was a control test asserting the double-write: loaded + rows carried no ``_DB_PERSISTED_MARKER``, so a flush without + ``conversation_history=`` re-appended the whole transcript. Since #92231 + the loaders stamp the marker at row-materialization time, so even the + "wrong" call shape (no history boundary) is idempotent. + """ db = SessionDB(db_path=tmp_path / "state.db") sid = "COLD_ROTATE_DUP" db.create_session(sid, source="cli") @@ -63,7 +70,7 @@ def test_rotation_flush_without_history_boundary_duplicates(tmp_path: Path) -> N agent._flush_messages_to_session_db(loaded) # missing conversation_history= rows = db.get_messages_as_conversation(sid, include_inactive=True) - assert _contents(rows).count("persisted question") == 2 + assert _contents(rows) == ["persisted question", "persisted answer"] def test_rotation_flush_with_history_boundary_is_noop(tmp_path: Path) -> None: diff --git a/tests/test_message_reactions.py b/tests/test_message_reactions.py index acc4aaac03d1..309693929a3e 100644 --- a/tests/test_message_reactions.py +++ b/tests/test_message_reactions.py @@ -164,12 +164,20 @@ def test_row_id_is_opt_in_and_never_reaches_the_provider(session, db): """Only include_row_ids=True consumers see _row_id — and it's underscore- prefixed so transports strip it before the wire even for them. Default consumers (ACP restore, export) get the transcript in its historical shape. + + ``_db_persisted`` is the other sanctioned underscore key: stamped on every + loaded row (#92231) so a flush can never re-append a resumed transcript. + Like ``_row_id`` it is stripped before the wire by every transport. """ key, _rows = session for message in db.get_messages_as_conversation(key): assert "_row_id" not in message + assert message.get("_db_persisted") is True for message in db.get_messages_as_conversation(key, include_row_ids=True): assert "_row_id" in message - assert all(not k.startswith("_") or k == "_row_id" for k in message) + assert all( + not k.startswith("_") or k in {"_row_id", "_db_persisted"} + for k in message + ) From 72ae855da517b80d73b825321cad2b5b8e25a56b Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:08:21 +0530 Subject: [PATCH 062/666] refactor: consolidate _db_persisted literal + marker-insensitive no-op check (#92231 follow-up) Review-pass follow-up on the load-time durability stamp: - hermes_state.py: import the marker from agent.context_compressor instead of a third synced literal (hermes_state already imports agent.* at module level; only run_agent is circular). Old comment claimed otherwise. - agent/turn_finalizer.py: replace the raw "_db_persisted" string at the fill-empty-tail pop site with the shared constant (was outside the drift guard). - agent/conversation_compression.py: the no-op progress check now falls back to a marker-insensitive comparison (_strip_marker_for_comparison). Loaded rows are stamped at materialization time while compress() output is marker-swept, so a semantically-identical no-op copy on a cold-resumed session would previously compare unequal and take the progress branch. Raw == still runs first so engine-returned list subclasses keep their __eq__ semantics. - test_marker_constant_in_sync extended to turn_finalizer + identity assertions; new test_noop_progress_check_is_marker_insensitive (mutation-checked: fails when the helper is neutered). --- agent/conversation_compression.py | 32 +++++++++++- agent/turn_finalizer.py | 3 +- hermes_state.py | 17 +++--- .../test_load_time_durability_stamp_92231.py | 52 ++++++++++++++++++- 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 90f3b5e879d5..e5dffd458b00 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -104,6 +104,27 @@ COMPACTION_DONE_STATUS = "✓ Context compaction complete — continuing turn..." +def _strip_marker_for_comparison(msgs: Any) -> Any: + """Copy ``msgs`` with the ``_db_persisted`` persistence marker removed. + + Used by the no-op progress check: live and loaded dicts carry the marker + (loaded rows are stamped at materialization time, #92231) while + ``compress()`` output is marker-swept, so a raw ``==`` would misclassify a + semantically-identical no-op copy as progress. Non-list inputs and + non-dict entries pass through unchanged. + """ + from agent.context_compressor import _DB_PERSISTED_MARKER + + if not isinstance(msgs, list): + return msgs + return [ + {k: v for k, v in m.items() if k != _DB_PERSISTED_MARKER} + if isinstance(m, dict) + else m + for m in msgs + ] + + def _emit_compaction_done(agent: Any) -> None: """Emit the structured terminal edge for a started compaction.""" status_callback = getattr(agent, "status_callback", None) @@ -3163,8 +3184,15 @@ def _release_lock() -> None: # Compare against the pre-dispatch semantic state, not object identity: # legacy/plugin engines may return an equal copy for a no-op, or mutate # the live list while returning an unchanged snapshot. Neither case may - # rotate or rewrite the session. - if compressed == messages_before_compression: + # rotate or rewrite the session. The raw ``==`` leg runs FIRST so a + # list subclass returned by an engine keeps its ``__eq__`` semantics + # (tests seam on this); the marker-insensitive leg (#92231) then covers + # the cold-resume shape where the stamped snapshot differs from the + # marker-swept compress() output only by ``_db_persisted``. + if compressed == messages_before_compression or ( + _strip_marker_for_comparison(compressed) + == _strip_marker_for_comparison(messages_before_compression) + ): if messages != messages_before_compression: messages[:] = copy.deepcopy(messages_before_compression) logger.info( diff --git a/agent/turn_finalizer.py b/agent/turn_finalizer.py index fbdf19cd91b4..e3b20d876e43 100644 --- a/agent/turn_finalizer.py +++ b/agent/turn_finalizer.py @@ -26,6 +26,7 @@ import os from agent.codex_responses_adapter import _summarize_user_message_for_log +from agent.context_compressor import _DB_PERSISTED_MARKER from agent.message_content import flatten_message_text from agent.message_metadata import append_message, stamp_message_timestamp from agent.message_sanitization import _sanitize_surrogates @@ -377,7 +378,7 @@ def finalize_turn( # re-writes the filled content to the durable store — # otherwise ``/resume`` reloads ``content=""`` and the bug # resurfaces cross-session. - _tail.pop("_db_persisted", None) + _tail.pop(_DB_PERSISTED_MARKER, None) # The bounded flush-scan cursor (run_agent.py) skips the # identity-matched prefix of its previous snapshot on the # assumption that no live dict loses the marker in place — diff --git a/hermes_state.py b/hermes_state.py index 420c840014f2..a2b1f94fe0a8 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -37,6 +37,15 @@ from agent.memory_manager import sanitize_context from agent.session_activity import ActivityProvenance from agent.message_sanitization import _sanitize_surrogates +# Intrinsic persistence marker stamped on message dicts that are known-durable +# (#92231). One shared constant with agent.context_compressor (this module +# already imports agent.* at module level, and context_compressor is a +# transitive dependency via hermes_state_common). run_agent keeps its own +# predating copy — hermes_state cannot import run_agent (circular) — guarded +# by test_marker_constant_in_sync. +from agent.context_compressor import ( + _DB_PERSISTED_MARKER as _DB_PERSISTED_MARKER_KEY, +) from agent.skill_commands import ( SKILL_EXCERPT_JOINT, SKILL_SCAFFOLD_SQL_LIKE, @@ -776,14 +785,6 @@ def _strip_background_review_harness( # Matches a bare protocol/tool-name marker such as "[memory]" or "[skill_manage]". _STALE_TOOL_CALL_MARKER_RE = re.compile(r"^\[[A-Za-z_][A-Za-z0-9_.-]*\]$") -# Intrinsic persistence marker stamped on message dicts that are known-durable. -# MUST stay in sync with run_agent._DB_PERSISTED_MARKER and -# agent.context_compressor._DB_PERSISTED_MARKER (same literal; defined locally -# because hermes_state must not import run_agent — circular import — and -# importing the agent layer for one constant inverts the state/agent layering). -# Drift is guarded by test_marker_constant_in_sync (#92231). -_DB_PERSISTED_MARKER_KEY = "_db_persisted" - def _is_stale_tool_call_marker_message(msg: Dict[str, Any]) -> bool: """True when ``msg`` is a persisted assistant turn whose content is a bare diff --git a/tests/agent/test_load_time_durability_stamp_92231.py b/tests/agent/test_load_time_durability_stamp_92231.py index 32803071c240..e5f65dd06f10 100644 --- a/tests/agent/test_load_time_durability_stamp_92231.py +++ b/tests/agent/test_load_time_durability_stamp_92231.py @@ -58,12 +58,25 @@ def _seed_session(db: SessionDB, sid: str, turns: int = 3) -> None: def test_marker_constant_in_sync() -> None: - """hermes_state cannot import run_agent (circular) — literals must match.""" + """One shared literal across every module that touches the marker. + + ``hermes_state`` and ``agent.turn_finalizer`` import the constant from + ``agent.context_compressor`` (single source), so the only genuine drift + surface left is ``run_agent``'s predating copy — hermes_state cannot + import run_agent (circular), and run_agent's own literal predates the + consolidation. + """ import agent.context_compressor as cc + import agent.turn_finalizer as tf import run_agent assert _DB_PERSISTED_MARKER_KEY == run_agent._DB_PERSISTED_MARKER assert _DB_PERSISTED_MARKER_KEY == cc._DB_PERSISTED_MARKER + assert _DB_PERSISTED_MARKER_KEY == tf._DB_PERSISTED_MARKER + # The import aliases must be the SAME object as the canonical constant, + # not re-defined literals. + assert _DB_PERSISTED_MARKER_KEY is cc._DB_PERSISTED_MARKER + assert tf._DB_PERSISTED_MARKER is cc._DB_PERSISTED_MARKER def test_loaded_rows_are_stamped_durable(tmp_path: Path) -> None: @@ -145,3 +158,40 @@ def test_compaction_copy_strips_stamp_so_child_flush_writes(tmp_path: Path) -> N # Idempotent thereafter: the flush stamped the copies on write. agent._flush_messages_to_session_db(compacted) assert len(db.get_messages("CHILD")) == len(loaded) + + +def test_noop_progress_check_is_marker_insensitive(tmp_path: Path) -> None: + """A marker-swept no-op copy must still compare equal to stamped input. + + The commit layer's "no progress" check compares compress() output against + a pre-dispatch deepcopy of the live messages. Load-stamping (#92231) puts + ``_db_persisted`` on cold-resumed dicts while compress() output is + marker-swept — a raw ``==`` would misclassify the semantically-identical + no-op as progress and rotate the session for nothing. + """ + from agent.conversation_compression import _strip_marker_for_comparison + + db = SessionDB(db_path=tmp_path / "state.db") + _seed_session(db, "NOOP", turns=2) + + stamped = db.get_messages_as_conversation("NOOP") + assert all(m.get(_DB_PERSISTED_MARKER_KEY) is True for m in stamped) + # What a no-op engine hands back after the terminal marker sweep. + swept = [ + {k: v for k, v in m.items() if k != _DB_PERSISTED_MARKER_KEY} + for m in stamped + ] + assert swept != stamped # raw equality is asymmetric — the bug shape + assert _strip_marker_for_comparison(swept) == _strip_marker_for_comparison( + stamped + ) + + # Genuine progress must still register as inequality. + compacted = swept[:-1] + assert _strip_marker_for_comparison(compacted) != _strip_marker_for_comparison( + stamped + ) + + # Defensive passthrough shapes. + assert _strip_marker_for_comparison(None) is None + assert _strip_marker_for_comparison(["not-a-dict"]) == ["not-a-dict"] From 183e53656ee7920ddd6795c61b3ddcd18e224f8e Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:23:02 +0530 Subject: [PATCH 063/666] docs: make the mutate-then-persist marker contract explicit (#92231 review) Reviewer point on #92539: nothing documented that an in-place content mutation of a stamped dict must pop _DB_PERSISTED_MARKER (and invalidate the bounded flush-scan prefix) or the DB silently goes stale. Both existing mutators (turn_finalizer fill-empty-tail, context_compressor micro-compaction defrag) already follow the contract; this states it at the constant so the next one does too. --- run_agent.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/run_agent.py b/run_agent.py index 6704ad7cbdd8..5da066ab183b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -276,6 +276,15 @@ def _is_ephemeral_scaffolding(msg: Any) -> bool: # (agent/transports/chat_completions.py, agent/chat_completion_helpers.py) strip # every top-level ``_``-prefixed key before the request leaves the process, so # this never reaches a strict OpenAI-compatible gateway. +# +# CONTRACT (#92231): the marker asserts "this dict's CONTENT is durable as +# written". Loaded rows are stamped at materialization time +# (hermes_state._rows_to_conversation), so any code that mutates a loaded or +# flushed dict's content in place and needs the change persisted MUST pop the +# marker (and invalidate _db_flush_scan_prefix if the dict may sit inside the +# bounded-scan prefix) — see agent/turn_finalizer.py (fill-empty-tail) and +# agent/context_compressor.py (micro-compaction defrag) for the two canonical +# pop sites. Mutating without popping leaves the DB silently stale. _DB_PERSISTED_MARKER = "_db_persisted" From 4621a2d699daeaa92efb93dae9db076308cbe823 Mon Sep 17 00:00:00 2001 From: Kshitij Kapoor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:28:02 +0530 Subject: [PATCH 064/666] test: drop vacuous identity assertions from marker sync guard (#92231 review) CPython interns identifier-like string literals, so 'is' cannot distinguish an import alias from a copy-pasted literal (verified: two exec'd namespaces each defining the literal share one object). The equality assertions three lines above are the full honest guard. Also reword a comment: raw == is marker-SENSITIVE, not asymmetric. --- tests/agent/test_load_time_durability_stamp_92231.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/agent/test_load_time_durability_stamp_92231.py b/tests/agent/test_load_time_durability_stamp_92231.py index e5f65dd06f10..7bad8923b305 100644 --- a/tests/agent/test_load_time_durability_stamp_92231.py +++ b/tests/agent/test_load_time_durability_stamp_92231.py @@ -73,10 +73,6 @@ def test_marker_constant_in_sync() -> None: assert _DB_PERSISTED_MARKER_KEY == run_agent._DB_PERSISTED_MARKER assert _DB_PERSISTED_MARKER_KEY == cc._DB_PERSISTED_MARKER assert _DB_PERSISTED_MARKER_KEY == tf._DB_PERSISTED_MARKER - # The import aliases must be the SAME object as the canonical constant, - # not re-defined literals. - assert _DB_PERSISTED_MARKER_KEY is cc._DB_PERSISTED_MARKER - assert tf._DB_PERSISTED_MARKER is cc._DB_PERSISTED_MARKER def test_loaded_rows_are_stamped_durable(tmp_path: Path) -> None: @@ -181,7 +177,7 @@ def test_noop_progress_check_is_marker_insensitive(tmp_path: Path) -> None: {k: v for k, v in m.items() if k != _DB_PERSISTED_MARKER_KEY} for m in stamped ] - assert swept != stamped # raw equality is asymmetric — the bug shape + assert swept != stamped # raw == is marker-sensitive — the bug shape assert _strip_marker_for_comparison(swept) == _strip_marker_for_comparison( stamped ) From ecf63ad3f3a0de84513c02786a481c63358aed0f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:31:25 -0700 Subject: [PATCH 065/666] fix: desktop plugins over 512 KiB no longer load truncated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime desktop plugins read their source through hermes:readFileText, the preview IPC that silently truncates at TEXT_PREVIEW_MAX_BYTES (512 KiB) — a larger plugin.js evaluated as a partial file (cryptic syntax error, or worse, a half-module that parses). - electron/main.ts: dedicated hermes:readPluginSource handler — full read, 16 MiB cap enforced as a hard EFBIG via resolveReadableFileForIpc (same path hardening + sensitive-file blocking), never truncation. - preload.ts / global.d.ts: bridge + types (optional — older shells). - runtime-loader.ts: loadDiskPlugin reads via readPluginSource; on older shells falls back to readFileText but FAILS LOUDLY on truncated:true (error toast + error inventory row) instead of evaluating a partial file. Existence probe keeps the preview read (metadata is enough). - tests: full-read path, loud old-shell truncation failure (sabotage- verified), small-plugin fallback. --- apps/desktop/electron/main.ts | 21 +++ apps/desktop/electron/preload.ts | 1 + .../src/contrib/runtime-loader.test.ts | 125 +++++++++++++++++- apps/desktop/src/contrib/runtime-loader.ts | 48 ++++++- apps/desktop/src/global.d.ts | 4 + 5 files changed, 194 insertions(+), 5 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 9657630872be..afba8100a1e6 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -14016,6 +14016,27 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => { } }) +// Runtime desktop plugins load their FULL source through this door. +// `hermes:readFileText` is the *preview* read and silently truncates at +// TEXT_PREVIEW_MAX_BYTES (512 KiB) — for a plugin that means evaluating half a +// file. Dedicated generous cap, full read, and a hard EFBIG (via maxBytes) +// instead of truncation when the source exceeds it. +const PLUGIN_SOURCE_MAX_BYTES = 16 * 1024 * 1024 + +ipcMain.handle('hermes:readPluginSource', async (_event: unknown, filePath: unknown) => { + const { resolvedPath, stat } = await resolveReadableFileForIpc(filePath, { + maxBytes: PLUGIN_SOURCE_MAX_BYTES, + purpose: 'Plugin source' + }) + + return { + byteSize: stat.size, + path: resolvedPath, + text: await fs.promises.readFile(resolvedPath, 'utf8'), + truncated: false + } +}) + ipcMain.handle('hermes:selectPaths', async (_event, options: any = {}) => { const properties = options?.directories ? ['openDirectory'] : ['openFile'] diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7eac06bd52e9..6b8affd2e8e0 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -196,6 +196,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { set: maxMb => ipcRenderer.invoke('hermes:data-url-read-max:set', maxMb) }, readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath), + readPluginSource: (filePath: string) => ipcRenderer.invoke('hermes:readPluginSource', filePath), selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options), selectSavePath: options => ipcRenderer.invoke('hermes:selectSavePath', options), writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text), diff --git a/apps/desktop/src/contrib/runtime-loader.test.ts b/apps/desktop/src/contrib/runtime-loader.test.ts index 0ed142e17fbf..cdc9f8ea6781 100644 --- a/apps/desktop/src/contrib/runtime-loader.test.ts +++ b/apps/desktop/src/contrib/runtime-loader.test.ts @@ -18,7 +18,8 @@ vi.mock('@/hermes', async importActual => ({ const desktopPluginsRoot = vi.fn<() => Promise>() const agentPluginsRoot = vi.fn<() => Promise>() const readDir = vi.fn<(path: string) => Promise>() -const readFileText = vi.fn<(path: string) => Promise<{ text: string }>>() +const readFileText = vi.fn<(path: string) => Promise<{ text: string; truncated?: boolean }>>() +const readPluginSource = vi.fn<(path: string) => Promise<{ text: string; truncated?: boolean }>>() const watchDirectory = vi.fn<(path: string) => Promise<{ id: string }>>() const watchPreviewFile = vi.fn<(path: string) => Promise<{ id: string }>>() const onPreviewFileChanged = vi.fn() @@ -28,6 +29,7 @@ beforeEach(() => { agentPluginsRoot.mockReset() readDir.mockReset() readFileText.mockReset() + readPluginSource.mockReset() watchDirectory.mockReset() watchPreviewFile.mockReset() onPreviewFileChanged.mockReset() @@ -179,6 +181,127 @@ describe('watchRuntimePlugins dir watch (#66899)', () => { }) }) +describe('plugin source reads (512 KiB preview-cap bug)', () => { + const blobToDataUrl = () => { + const createObjectURL = vi + .spyOn(URL, 'createObjectURL') + .mockImplementation( + blob => + `data:text/javascript;base64,${Buffer.from((blob as unknown as { parts: string[] }).parts.join('')).toString('base64')}` + ) + + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined) + const RealBlob = globalThis.Blob + vi.stubGlobal( + 'Blob', + class { + parts: string[] + constructor(parts: string[]) { + this.parts = parts + } + } + ) + + return () => { + createObjectURL.mockRestore() + revokeObjectURL.mockRestore() + vi.stubGlobal('Blob', RealBlob) + } + } + + it('loads the full source via readPluginSource when the shell offers it', async () => { + ;(window.hermesDesktop as unknown as { readPluginSource: unknown }).readPluginSource = readPluginSource + desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins') + agentPluginsRoot.mockResolvedValue('') + readDir.mockResolvedValue({ + entries: [{ isDirectory: true, name: 'big', path: '/local/.hermes/desktop-plugins/big' }] + }) + // The existence probe still uses the preview read (metadata is enough + // there) — it may report truncation without failing the scan. + readFileText.mockResolvedValue({ text: '// first 512 KiB only', truncated: true }) + + const register = vi.fn() + + ;(globalThis as unknown as { __bigRegister: unknown }).__bigRegister = register + readPluginSource.mockResolvedValue({ + text: 'export default { id: "big", register: globalThis.__bigRegister }' + }) + watchPreviewFile.mockResolvedValue({ id: 'w-big' }) + + const restore = blobToDataUrl() + + try { + await discoverRuntimePlugins() + + // The EVALUATED source came from the full read, not the truncated preview. + expect(readPluginSource).toHaveBeenCalledWith('/local/.hermes/desktop-plugins/big/plugin.js') + expect(register).toHaveBeenCalledTimes(1) + expect($pluginRecords.get().big).toMatchObject({ kind: 'disk', status: 'loaded' }) + } finally { + restore() + delete (globalThis as unknown as { __bigRegister?: unknown }).__bigRegister + } + }) + + it('older shell without readPluginSource: a truncated preview read fails LOUDLY, never evaluates', async () => { + desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins') + agentPluginsRoot.mockResolvedValue('') + readDir.mockResolvedValue({ + entries: [{ isDirectory: true, name: 'huge', path: '/local/.hermes/desktop-plugins/huge' }] + }) + // 512 KiB window of a larger file — parses fine, but is NOT the plugin. + readFileText.mockResolvedValue({ + text: 'export default { id: "huge", register: () => { throw new Error("must never evaluate") } }', + truncated: true + }) + watchPreviewFile.mockResolvedValue({ id: 'w-huge' }) + + const restore = blobToDataUrl() + + try { + await discoverRuntimePlugins() + + // No live plugin — an error inventory row names the folder instead. + expect($pluginRecords.get().huge).toMatchObject({ + kind: 'disk', + status: 'error', + file: '/local/.hermes/desktop-plugins/huge/plugin.js' + }) + expect($pluginRecords.get().huge.error).toMatch(/512 KiB/) + } finally { + restore() + } + }) + + it('older shell, small plugin (not truncated): still loads through readFileText', async () => { + desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins') + agentPluginsRoot.mockResolvedValue('') + readDir.mockResolvedValue({ + entries: [{ isDirectory: true, name: 'small', path: '/local/.hermes/desktop-plugins/small' }] + }) + + const register = vi.fn() + + ;(globalThis as unknown as { __smallRegister: unknown }).__smallRegister = register + readFileText.mockResolvedValue({ + text: 'export default { id: "small", register: globalThis.__smallRegister }' + }) + watchPreviewFile.mockResolvedValue({ id: 'w-small' }) + + const restore = blobToDataUrl() + + try { + await discoverRuntimePlugins() + + expect(register).toHaveBeenCalledTimes(1) + expect($pluginRecords.get().small).toMatchObject({ kind: 'disk', status: 'loaded' }) + } finally { + restore() + delete (globalThis as unknown as { __smallRegister?: unknown }).__smallRegister + } + }) +}) + describe('bundled-shadowed disk copies', () => { it('skips a disk copy of a bundled plugin but publishes a visible inventory row', async () => { // The bundled twin is already registered (build-time glob). diff --git a/apps/desktop/src/contrib/runtime-loader.ts b/apps/desktop/src/contrib/runtime-loader.ts index ba4c7fd0c41a..e1587cc7c55e 100644 --- a/apps/desktop/src/contrib/runtime-loader.ts +++ b/apps/desktop/src/contrib/runtime-loader.ts @@ -297,12 +297,37 @@ function dropOriginRecord(origin: string, except: DiskPlugin): void { dropPlugin(origin) } -async function loadDiskPlugin(entry: DiskPlugin): Promise { +/** A plugin source that could not be read in FULL. Evaluating a truncated + * file is never acceptable — half a module can still parse. */ +class PluginSourceOversizeError extends Error {} + +/** Read a plugin entry file in full. Prefers the dedicated readPluginSource + * IPC (16 MiB cap, no truncation). Older shells predate it and only offer + * the preview read, which silently truncates at 512 KiB — there the read + * fails loudly instead of handing a partial file to the evaluator. */ +async function readPluginSourceText(file: string): Promise { const desktop = window.hermesDesktop! + + if (desktop.readPluginSource) { + return (await desktop.readPluginSource(file)).text + } + + const result = await desktop.readFileText(file) + + if (result.truncated) { + throw new PluginSourceOversizeError( + 'plugin.js exceeds this shell\'s 512 KiB read limit — update Hermes Desktop to load larger plugins' + ) + } + + return result.text +} + +async function loadDiskPlugin(entry: DiskPlugin): Promise { const prevId = entry.id try { - const { text } = await desktop.readFileText(entry.file) + const text = await readPluginSourceText(entry.file) const id = await loadRuntimePlugin(text, entry.origin, { defaultEnabled: entry.defaultEnabled, @@ -324,8 +349,23 @@ async function loadDiskPlugin(entry: DiskPlugin): Promise { if (id && id !== entry.origin) { dropOriginRecord(entry.origin, entry) } - } catch { - // File vanished mid-read — the next scan reconciles. + } catch (error) { + // An oversize source is a REAL failure the user must see (the silent + // shape was the bug: a truncated file evaluated as a syntax error, or + // worse, as half a plugin). Everything else is a file vanishing + // mid-read — the next scan reconciles, stay quiet. + if (error instanceof PluginSourceOversizeError) { + console.error(`[plugins] ${entry.origin}: ${error.message}`) + notifyError(error, `Plugin "${entry.origin}" failed to load`) + publishPlugin({ + id: entry.origin, + name: entry.origin, + kind: 'disk', + file: entry.file, + status: 'error', + error: error.message + }) + } } } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 02d83de186bb..7b6d4fd84076 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -207,6 +207,10 @@ declare global { set: (maxMb: number) => Promise<{ defaultMaxMb: number; maxBytes: number; maxMb: number }> } readFileText: (filePath: string) => Promise + /** Full-source read for runtime desktop plugins (readFileText truncates + * at the 512 KiB preview cap). Absent on older shells — callers fall + * back to readFileText and must reject a `truncated` result. */ + readPluginSource?: (filePath: string) => Promise selectPaths: (options?: HermesSelectPathsOptions) => Promise /** Native save dialog; returns the chosen path or null on cancel. */ selectSavePath?: (options?: { From f377140e3ddb4c98a9e0b42c4497b8bb46ca697c Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:45:37 +0000 Subject: [PATCH 066/666] fmt(js): `npm run fix` on merge (#92815) Co-authored-by: github-actions[bot] --- apps/desktop/electron/main.ts | 3 +- .../electron/profile-delete-routing.test.ts | 14 +----- apps/desktop/src/api/profiles.ts | 4 +- .../src/app/chat/transcript-backfill.ts | 6 +-- .../session/hooks/use-route-resume.test.tsx | 14 +----- .../hooks/use-session-actions.test.tsx | 47 +++++++------------ .../hooks/use-session-actions/index.ts | 18 +++---- .../hooks/use-session-actions/utils.ts | 3 +- apps/desktop/src/contrib/runtime-loader.ts | 2 +- apps/desktop/src/hermes.test.ts | 6 +-- apps/desktop/src/sdk/host-state.test.ts | 12 +++-- apps/desktop/src/sdk/index.ts | 13 ++--- .../src/store/session-request-router.test.ts | 8 ++-- .../src/store/session-request-router.ts | 9 +--- apps/desktop/src/store/session.ts | 4 +- apps/desktop/src/store/transcript-tail.ts | 29 +++++++----- apps/desktop/src/store/updates.ts | 5 +- 17 files changed, 75 insertions(+), 122 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index afba8100a1e6..0351a5396bd7 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -13820,8 +13820,7 @@ ipcMain.handle('hermes:api', async (_event, request) => { isDefaultProfile: profile => profile === 'default', isValidProfileName: profile => PROFILE_NAME_RE.test(profile), prepareLocal: localRequest => prepareProfileDeleteRequest(localRequest).then(() => undefined), - teardownConnection: (connectionId, profile) => - teardownConnectionScopedProfileBackend(connectionId, profile) + teardownConnection: (connectionId, profile) => teardownConnectionScopedProfileBackend(connectionId, profile) }) } diff --git a/apps/desktop/electron/profile-delete-routing.test.ts b/apps/desktop/electron/profile-delete-routing.test.ts index 236d4b3fa1fd..a63e864bc962 100644 --- a/apps/desktop/electron/profile-delete-routing.test.ts +++ b/apps/desktop/electron/profile-delete-routing.test.ts @@ -186,12 +186,7 @@ test('explicit registered local DELETE holds one gate through teardown and dispa ) assert.equal(result, 'deleted') - assert.deepEqual(events, [ - 'gate:worker', - 'prepare:local:worker', - 'dispatch:primary', - 'release:worker' - ]) + assert.deepEqual(events, ['gate:worker', 'prepare:local:worker', 'dispatch:primary', 'release:worker']) }) test('explicit registered SSH DELETE tears down its source backend before dispatch', async () => { @@ -220,12 +215,7 @@ test('explicit registered SSH DELETE tears down its source backend before dispat ) assert.equal(result, 'deleted') - assert.deepEqual(events, [ - 'gate:worker', - 'teardown:build-host:worker', - 'dispatch:primary', - 'release:worker' - ]) + assert.deepEqual(events, ['gate:worker', 'teardown:build-host:worker', 'dispatch:primary', 'release:worker']) }) test('non-identity connection DELETE tears down the logical pool and deletes the backend target', async () => { diff --git a/apps/desktop/src/api/profiles.ts b/apps/desktop/src/api/profiles.ts index 514e367da541..b021639ab99f 100644 --- a/apps/desktop/src/api/profiles.ts +++ b/apps/desktop/src/api/profiles.ts @@ -45,9 +45,7 @@ export function deleteProfile(name: string, scope?: ProfileScope): Promise<{ ok: return hermesApi<{ ok: boolean; path: string }>({ ...capabilityScoped(scope), - ...(scope && typeof scope === 'object' && scope.connectionId?.trim() === 'local' - ? { connectionId: 'local' } - : {}), + ...(scope && typeof scope === 'object' && scope.connectionId?.trim() === 'local' ? { connectionId: 'local' } : {}), path: `/api/profiles/${encodeURIComponent(normalized)}`, method: 'DELETE' }) diff --git a/apps/desktop/src/app/chat/transcript-backfill.ts b/apps/desktop/src/app/chat/transcript-backfill.ts index 43b41354663d..c7151ec59fb6 100644 --- a/apps/desktop/src/app/chat/transcript-backfill.ts +++ b/apps/desktop/src/app/chat/transcript-backfill.ts @@ -17,11 +17,7 @@ import { getOlderSessionMessages } from '@/hermes' import { type ChatMessage, toChatMessages } from '@/lib/chat-messages' -import { - recordTranscriptBackfillPage, - type TranscriptProfileScope, - transcriptTailState -} from '@/store/transcript-tail' +import { recordTranscriptBackfillPage, type TranscriptProfileScope, transcriptTailState } from '@/store/transcript-tail' /** Older rows likely exist beyond what the in-memory store holds. */ export function transcriptBackfillAvailable( diff --git a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx index c33eeb2cbb6c..b7bdead6bbd3 100644 --- a/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-route-resume.test.tsx @@ -378,23 +378,13 @@ describe('useRouteResume', () => { } const { rerender } = render( - + ) expect(resumeSession).toHaveBeenCalledWith('session-1', true, ownerRoute) resumeSession.mockClear() - rerender( - - ) + rerender() expect(resumeSession).toHaveBeenCalledTimes(1) expect(resumeSession).toHaveBeenCalledWith('session-2', true) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index d21790c4b0f6..c0b0ec63591e 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -17,12 +17,7 @@ import { import { createClientSessionState } from '@/lib/chat-runtime' import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer' import { requestGatewayForAgent } from '@/store/gateway' -import { - $activeGatewayProfile, - $newChatProfile, - $newChatRoute, - ensureGatewayProfile -} from '@/store/profile' +import { $activeGatewayProfile, $newChatProfile, $newChatRoute, ensureGatewayProfile } from '@/store/profile' import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' import { $activeSessionId, @@ -183,7 +178,9 @@ describe('connection-qualified session deletion', () => { render( { actions = value }} + onReady={value => { + actions = value + }} requestGateway={requestGateway} selectedStoredSessionId="shared-session" /> @@ -198,12 +195,9 @@ describe('connection-qualified session deletion', () => { connectionId: 'source-a', profile: 'worker' }) - expect(requestGatewayForAgent).toHaveBeenCalledWith( - 'source-a', - 'worker', - 'session.close', - { session_id: 'runtime-shared' } - ) + expect(requestGatewayForAgent).toHaveBeenCalledWith('source-a', 'worker', 'session.close', { + session_id: 'runtime-shared' + }) expect(requestGateway).not.toHaveBeenCalledWith('session.close', expect.anything()) }) }) @@ -713,11 +707,7 @@ function ResumeHarness({ }: { onStateUpdate?: (sessionId: string, state: ClientSessionState) => void onReady: ( - resume: ( - storedSessionId: string, - replaceRoute?: boolean, - ownerRoute?: SessionProfileRoute - ) => Promise + resume: (storedSessionId: string, replaceRoute?: boolean, ownerRoute?: SessionProfileRoute) => Promise ) => void requestGateway: (method: string, params?: Record) => Promise runtimeIdByStoredSessionIdRef?: MutableRefObject> @@ -1801,7 +1791,9 @@ describe('resumeSession warm-cache mapping integrity', () => { setMessages([]) setSessions([]) vi.mocked(getSession).mockReset() - vi.mocked(getLatestSessionMessages).mockReset().mockResolvedValue({ messages: [] } as never) + vi.mocked(getLatestSessionMessages) + .mockReset() + .mockResolvedValue({ messages: [] } as never) vi.mocked(requestGatewayForAgent).mockReset() vi.restoreAllMocks() }) @@ -1850,11 +1842,9 @@ describe('resumeSession warm-cache mapping integrity', () => { const ambientRequest = vi.fn(async () => ({}) as never) - let resume: null | (( - storedSessionId: string, - replaceRoute?: boolean, - ownerRoute?: SessionProfileRoute - ) => Promise) = null + let resume: + null | ((storedSessionId: string, replaceRoute?: boolean, ownerRoute?: SessionProfileRoute) => Promise) = + null render( { 'session.activate', expect.objectContaining({ session_id: 'runtime-warm' }) ) - expect(requestGatewayForAgent).toHaveBeenCalledWith( - 'source-a', - 'default', - 'session.usage', - { session_id: 'runtime-warm' } - ) + expect(requestGatewayForAgent).toHaveBeenCalledWith('source-a', 'default', 'session.usage', { + session_id: 'runtime-warm' + }) expect(requestGatewayForAgent).toHaveBeenCalledWith( 'source-a', 'default', diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 7d2f11221e2f..75733e38ff43 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -72,7 +72,11 @@ import { setWorkspaceCwdOwner, setYoloActive } from '@/store/session' -import { requestForSessionProfile, type SessionOwnerScope, type SessionProfileRoute } from '@/store/session-request-router' +import { + requestForSessionProfile, + type SessionOwnerScope, + type SessionProfileRoute +} from '@/store/session-request-router' import { $sessionTiles, closeSessionTile, @@ -774,7 +778,8 @@ export function useSessionActions({ // A row spliced from a CONNECTED registry gateway (#88880) carries its // owning connection — activate THAT gateway, not a same-named local // profile. Rows without the tag keep the legacy profile path. - const sessionOwner: SessionOwnerScope = ownerRoute || + const sessionOwner: SessionOwnerScope = + ownerRoute || (storedForProfile?.connection_id ? { connectionId: storedForProfile.connection_id, @@ -1821,12 +1826,9 @@ export function useSessionActions({ try { if (closingRuntimeId) { - await requestForSessionProfile( - removedOwner, - requestGateway, - 'session.close', - { session_id: closingRuntimeId } - ).catch(() => undefined) + await requestForSessionProfile(removedOwner, requestGateway, 'session.close', { + session_id: closingRuntimeId + }).catch(() => undefined) } await deleteSession(storedSessionId, removedOwner) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index f2e80ba75852..07e5a2dbab5e 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -1318,7 +1318,8 @@ export async function resolveStoredSession( profile: ownerRoute.targetProfile || ownerRoute.profile } - const cachedOwnerMatches = cached && + const cachedOwnerMatches = + cached && cached.connection_id === ownerRoute.connectionId && (!cached.profile || normalizeProfileKey(cached.profile) === normalizeProfileKey(ownerRoute.profile)) diff --git a/apps/desktop/src/contrib/runtime-loader.ts b/apps/desktop/src/contrib/runtime-loader.ts index e1587cc7c55e..3c94f37dfae1 100644 --- a/apps/desktop/src/contrib/runtime-loader.ts +++ b/apps/desktop/src/contrib/runtime-loader.ts @@ -316,7 +316,7 @@ async function readPluginSourceText(file: string): Promise { if (result.truncated) { throw new PluginSourceOversizeError( - 'plugin.js exceeds this shell\'s 512 KiB read limit — update Hermes Desktop to load larger plugins' + "plugin.js exceeds this shell's 512 KiB read limit — update Hermes Desktop to load larger plugins" ) } diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index 0d4f0a2d36bd..5936f1d9b94d 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/apps/desktop/src/hermes.test.ts @@ -444,9 +444,9 @@ describe('Hermes REST helpers', () => { api.mockResolvedValue({ ok: true, path: '/profiles/worker' }) await deleteProfile('backend-worker', { connectionId: 'source-a', profile: 'backend-worker' }) - await expect( - deleteProfile('worker', { connectionId: 'source-a', profile: 'default' }) - ).rejects.toThrow(/default profile cannot be deleted/i) + await expect(deleteProfile('worker', { connectionId: 'source-a', profile: 'default' })).rejects.toThrow( + /default profile cannot be deleted/i + ) expect(api).toHaveBeenCalledOnce() expect(api).toHaveBeenCalledWith({ diff --git a/apps/desktop/src/sdk/host-state.test.ts b/apps/desktop/src/sdk/host-state.test.ts index 487b8edc1d22..2c2c2650c665 100644 --- a/apps/desktop/src/sdk/host-state.test.ts +++ b/apps/desktop/src/sdk/host-state.test.ts @@ -217,11 +217,13 @@ describe('host.state.focusedSessionProfile', () => { profile: 'worker', targetProfile: 'backend-worker-b' }) - session.$sessions.set([{ - connection_id: 'source-a', - id: 'ambiguous-hint', - profile: 'worker' - } as never]) + session.$sessions.set([ + { + connection_id: 'source-a', + id: 'ambiguous-hint', + profile: 'worker' + } as never + ]) session.$selectedStoredSessionId.set('ambiguous-hint') expect(host.state.focusedSessionOwner.get()).toBeNull() diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 1c9cf344aa15..c7e4328bd49e 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -132,9 +132,7 @@ export interface PluginFocusedSessionOwner { const $focusedSessionOwner = computed( [$focusedStoredSessionId, $sessions, $activeGatewayProfile, $connection], (focused, sessions, activeProfile, connection): PluginFocusedSessionOwner | null => { - const activeConnectionId = String( - connection?.connectionId || (connection?.mode === 'local' ? 'local' : '') - ).trim() + const activeConnectionId = String(connection?.connectionId || (connection?.mode === 'local' ? 'local' : '')).trim() const fallback = { connectionId: activeConnectionId, @@ -175,8 +173,8 @@ const $focusedSessionOwner = computed( const $focusedSessionProfile = computed( [$focusedSessionOwner, $focusedStoredSessionId, $sessions, $activeGatewayProfile], - (owner, focused, sessions, activeProfile) => owner?.profile || - rememberedSessionProfile(sessions, focused, activeProfile) + (owner, focused, sessions, activeProfile) => + owner?.profile || rememberedSessionProfile(sessions, focused, activeProfile) ) export interface PluginProfileRoute { @@ -577,10 +575,7 @@ export const host = { retireLocalProfileGateways(targetProfile) } - await deleteProfile( - targetProfile, - route ? { connectionId: route.connectionId, profile: route.profile } : undefined - ) + await deleteProfile(targetProfile, route ? { connectionId: route.connectionId, profile: route.profile } : undefined) // The profile rail paints from the shared $profiles cache; without a // refresh the deleted profile's badge survives and clicking it starts a diff --git a/apps/desktop/src/store/session-request-router.test.ts b/apps/desktop/src/store/session-request-router.test.ts index 05b4dae4f094..cd020dea619d 100644 --- a/apps/desktop/src/store/session-request-router.test.ts +++ b/apps/desktop/src/store/session-request-router.test.ts @@ -150,9 +150,11 @@ describe('requestForSessionProfile', () => { installDesktop() await ensureGatewayForProfile('other') - const desktop = (window as unknown as { - hermesDesktop: { getConnectionFor: ReturnType } - }).hermesDesktop + const desktop = ( + window as unknown as { + hermesDesktop: { getConnectionFor: ReturnType } + } + ).hermesDesktop const ambient = vi.fn(async () => ({ ambient: true })) diff --git a/apps/desktop/src/store/session-request-router.ts b/apps/desktop/src/store/session-request-router.ts index be59112cfc81..f147df5b7af8 100644 --- a/apps/desktop/src/store/session-request-router.ts +++ b/apps/desktop/src/store/session-request-router.ts @@ -95,14 +95,7 @@ export function requestForSessionProfile( return timeoutMs === undefined && signal === undefined ? requestGatewayForAgent(connectionId, normKey(ownerProfile.profile), method, routedParams) - : requestGatewayForAgent( - connectionId, - normKey(ownerProfile.profile), - method, - routedParams, - timeoutMs, - signal - ) + : requestGatewayForAgent(connectionId, normKey(ownerProfile.profile), method, routedParams, timeoutMs, signal) } if (!sessionRpcNeedsProfileRoute(ownerProfile)) { diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 330d9c57cc29..9539761593a2 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -645,9 +645,7 @@ export function setSessionOwnerHint(sessionId: string, route: SessionProfileRout export function getSessionOwnerHints(sessionId: string): SessionProfileRoute[] { const id = sessionId.trim() - return [...sessionOwnerHints.values()] - .filter(entry => entry.id === id) - .map(entry => ({ ...entry.route })) + return [...sessionOwnerHints.values()].filter(entry => entry.id === id).map(entry => ({ ...entry.route })) } export function getSessionOwnerHint( diff --git a/apps/desktop/src/store/transcript-tail.ts b/apps/desktop/src/store/transcript-tail.ts index 1016fa2a723d..8f18ed67c411 100644 --- a/apps/desktop/src/store/transcript-tail.ts +++ b/apps/desktop/src/store/transcript-tail.ts @@ -28,10 +28,13 @@ export interface TranscriptTailState { profile?: TranscriptProfileScope } -export type TranscriptProfileScope = null | string | { - connectionId?: null | string - profile?: null | string -} +export type TranscriptProfileScope = + | null + | string + | { + connectionId?: null | string + profile?: null | string + } export const $transcriptTailBySessionId = atom>({}) const TRANSCRIPT_TAIL_LIMIT = 256 @@ -60,9 +63,7 @@ function transcriptTailKey(storedSessionId: string, profile?: TranscriptProfileS return scope ? JSON.stringify([scope.connectionId, scope.profile, storedSessionId]) : storedSessionId } -function matchingTailEntries( - storedSessionId: string -): Array<[string, TranscriptTailState]> { +function matchingTailEntries(storedSessionId: string): Array<[string, TranscriptTailState]> { return Object.entries($transcriptTailBySessionId.get()).filter(([key]) => { if (key === storedSessionId) { return true @@ -131,9 +132,10 @@ export function recordTranscriptBackfillPage( ): void { const current = $transcriptTailBySessionId.get() - const selected: Array<[string, TranscriptTailState | undefined]> = profile === undefined - ? matchingTailEntries(storedSessionId) - : [[transcriptTailKey(storedSessionId, profile), current[transcriptTailKey(storedSessionId, profile)]]] + const selected: Array<[string, TranscriptTailState | undefined]> = + profile === undefined + ? matchingTailEntries(storedSessionId) + : [[transcriptTailKey(storedSessionId, profile), current[transcriptTailKey(storedSessionId, profile)]]] if (selected.length !== 1) { return @@ -168,9 +170,10 @@ export function transcriptTailState( export function clearTranscriptTail(storedSessionId: string, profile?: TranscriptProfileScope): void { const current = $transcriptTailBySessionId.get() - const keys = profile === undefined - ? matchingTailEntries(storedSessionId).map(([key]) => key) - : [transcriptTailKey(storedSessionId, profile)] + const keys = + profile === undefined + ? matchingTailEntries(storedSessionId).map(([key]) => key) + : [transcriptTailKey(storedSessionId, profile)] if (keys.length === 0) { return diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index 0cf805965164..9dd9e60c9570 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -592,10 +592,7 @@ function completedAfterRestart( * run started at-or-after we kicked the update off counts — an older * receipt describes a previous update, and a still-running one proves * nothing yet. The 60s slack absorbs client/backend clock skew. */ -function receiptProvesOutcome( - status: Awaited>, - applyStartedAtMs: number -): boolean { +function receiptProvesOutcome(status: Awaited>, applyStartedAtMs: number): boolean { const receipt = status.receipt if (!receipt || !receipt.finished_at || !receipt.started_at) { From 503d863fcd2cbfc0be5a6d6c536fae2e98aa4204 Mon Sep 17 00:00:00 2001 From: hsearcy Date: Sat, 22 Aug 2026 09:12:05 -0400 Subject: [PATCH 067/666] fix(install): never strand hermes.exe when a Windows update fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows the updater renames the live `hermes*.exe` shims aside (`hermes.exe.old.`) so uv can write replacements. When that quarantine succeeds but the install then fails, the recovery path could leave the install with no `hermes` on PATH at all — unrecoverable in place, because the command that would repair it IS `hermes update` (#75584). Restoring a quarantined shim happens at three sites: the updater, the early-recovery installer, and the startup sweep's orphan rescue. Each was a single un-retried rename whose OSError was swallowed in silence, while the OUTBOUND quarantine rename already retried a lock. That is backwards — a failed quarantine merely aborts an update, a failed restore removes `hermes` from PATH — and the two sites that had messages had already drifted apart. - `_early_recovery.restore_quarantined_shims()` is now the single implementation: retry ladder, one recovery message, returns the pairs it could not restore. It lives in the stdlib-only module that both `main` and `_install_repair` already import, so the layers cannot drift again. A pair is not a failure when the original reappeared or the quarantine file vanished — two processes sweeping the same orphan must not produce a spurious error. - `_cleanup_quarantined_exes` unlinked every `*.exe.old.*` on each invocation. When the original shim was already missing, that .old file was the ONLY surviving copy — deleting it converted a one-rename recovery into a full reinstall. It now rescues the orphan through the shared helper instead, and leaves anything inside a 15-minute grace window alone so it cannot destroy a concurrent update's in-flight quarantine. - Ordering is by the PARSED `.old.` stamp, not the raw filename. Lexicographic ordering only tracks recency while every stamp shares a digit width; a stray `.old.999` sorts above a 13-digit epoch-ms stamp and would be the copy rescued onto the live shim name. - Names whose suffix does not parse as int-ms are not ours: never rescued, never deleted. The sweep should not destroy files whose provenance it cannot establish, and they are not produced by the quarantiner. The stamp is read from the filename rather than st_mtime because `rename` preserves the original shim's mtime, which records when uv wrote the shim — days earlier, in general — not when it was quarantined. A regression test pins that distinction. Messages go to stderr: the sweep runs on EVERY hermes invocation and `hermes acp` speaks JSON-RPC on stdout. Scope note: `_quarantine_running_hermes_exe` is deliberately byte-identical to main here. Why the outbound rename fails in the first place (the launcher holding its own image without FILE_SHARE_DELETE) is #88121's subject; this is the net underneath, covering the case where quarantine SUCCEEDS and the install dies afterwards. The two touch disjoint functions and can merge in either order. Reproduced and verified on Windows 11 (26200), Python 3.11.15: stranded the shims, confirmed a normal `hermes` invocation now rescues the orphan instead of deleting it, and confirmed an exhausted rescue prints the recovery command. 16 new tests; 35 pass across the four quarantine suites. --- hermes_cli/_early_recovery.py | 79 ++++ hermes_cli/_install_repair.py | 17 +- hermes_cli/main.py | 104 +++++- .../test_quarantine_orphan_rescue.py | 347 ++++++++++++++++++ 4 files changed, 522 insertions(+), 25 deletions(-) create mode 100644 tests/hermes_cli/test_quarantine_orphan_rescue.py diff --git a/hermes_cli/_early_recovery.py b/hermes_cli/_early_recovery.py index 942eb632d2d0..6bae52b97332 100644 --- a/hermes_cli/_early_recovery.py +++ b/hermes_cli/_early_recovery.py @@ -56,6 +56,85 @@ "jwt": "PyJWT", } + +# --- Windows entry-point shim quarantine ----------------------------------- +# +# ``hermes update`` renames the live ``hermes*.exe`` shims aside +# (``hermes.exe.old.``) so uv can write replacements. Putting them BACK +# is the safety-critical direction: losing that rename leaves the install with +# no ``hermes`` on PATH, and the command that would repair it IS ``hermes +# update`` (#75584). +# +# Three call sites restore a quarantined shim -- the updater, the +# early-recovery installer, and the startup sweep's orphan rescue. They used to +# be separate one-shot renames with swallowed errors; the two that had messages +# had already drifted apart. The logic lives here, in the one stdlib-only module +# all of them can import, so the ladder and the recovery wording stay in +# lockstep. + +QUARANTINE_RESTORE_BACKOFF_MS: tuple[int, ...] = (0, 100, 250, 500, 1000) + + +def restore_quarantined_shims( + moved: list[tuple[Path, Path]], + *, + stream=None, + backoff_ms: tuple[int, ...] = QUARANTINE_RESTORE_BACKOFF_MS, +) -> list[tuple[Path, Path]]: + """Rename quarantined shims back, retrying a lock instead of giving up. + + ``moved`` holds ``(original, quarantined)`` pairs. Returns the pairs that + could NOT be restored, and prints an actionable recovery command for each. + + A pair is not a failure when ``original`` already exists or ``quarantined`` + has gone: the installer wrote a fresh shim, or a concurrent sweep won the + race. Both are silent, so two processes sweeping the same orphan cannot + produce a spurious error. + + Messages go to stderr by default -- the startup sweep runs on EVERY hermes + invocation, and ``hermes acp`` speaks JSON-RPC on stdout. + """ + if stream is None: + stream = sys.stderr + + failed: list[tuple[Path, Path]] = [] + + for original, quarantined in moved: + last_exc: OSError | None = None + + for delay_ms in backoff_ms: + try: + if os.path.exists(original) or not os.path.exists(quarantined): + last_exc = None + break + if delay_ms: + time.sleep(delay_ms / 1000.0) + os.rename(quarantined, original) + last_exc = None + break + except OSError as exc: + last_exc = exc + continue + + if last_exc is None: + continue + + failed.append((original, quarantined)) + name = os.path.basename(str(original)) + stem = name[:-4] if name.lower().endswith(".exe") else name + print( + f" ✖ FAILED to restore {name} " + f"({last_exc.__class__.__name__}) — it is still quarantined " + f"as {os.path.basename(str(quarantined))}.\n" + f" `{stem}` will NOT be on PATH until it is put back. Run this, " + f"then re-run the update:\n" + f' move "{quarantined}" "{original}"', + file=stream, + ) + + return failed + + # Set only when this process successfully finishes a deferred core install for # an ``update`` invocation. The normal CLI import that follows must not resolve # external secret sources: a configured source can map cryptography._rust and diff --git a/hermes_cli/_install_repair.py b/hermes_cli/_install_repair.py index 8d7ef15e086d..36e030c412f3 100644 --- a/hermes_cli/_install_repair.py +++ b/hermes_cli/_install_repair.py @@ -527,14 +527,15 @@ def _quarantine_running_hermes_exe( def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None: - """Put quarantined shims back when the installer did not replace them.""" - for original, quarantined in moved: - if original.exists(): - continue # installer wrote a fresh shim — the .old one is garbage - try: - os.rename(quarantined, original) - except OSError: - pass + """Put quarantined shims back when the installer did not replace them. + + Delegates to the shared helper in the stdlib-only ``_early_recovery`` + module: one retry ladder and one recovery message for every restore site, + instead of the near-identical copies that had already drifted (#75584). + Warnings land on stderr — this module runs in the early-recovery path and + ``hermes acp`` speaks JSON-RPC on stdout. + """ + _er.restore_quarantined_shims(moved) def _run_install_cmd(cmd: list[str], *, env: dict | None, root: Path) -> None: diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3d6242f8078a..90d6cd54c425 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9177,13 +9177,18 @@ def _cleanup_pending_shim_renames(scripts_dir: Path) -> int: def _restore_quarantined_exes(moved: list[tuple[Path, Path]]) -> None: - """Roll back ``_quarantine_running_hermes_exe`` if uv didn't write replacements.""" - for original, quarantined in moved: - try: - if not original.exists() and quarantined.exists(): - quarantined.rename(original) - except OSError: - pass + """Roll back ``_quarantine_running_hermes_exe`` if uv didn't write replacements. + + This is the safety-critical direction. A failed *quarantine* only aborts an + update; a failed *restore* leaves the install with no ``hermes`` on PATH, + and therefore no way to run the command that would repair it (#75584). The + outbound rename already retries a lock, so this one must too rather than + swallow the first ``OSError`` in silence. + + Delegates to the stdlib-only helper that the early-recovery copy in + ``_install_repair`` also uses, so the two cannot drift apart. + """ + _early_recovery_mod.restore_quarantined_shims(moved) class ShimQuarantineError(RuntimeError): @@ -9256,12 +9261,49 @@ def _run_quarantined_install( _restore_quarantined_exes(moved) +# A quarantine file younger than this may belong to an update running RIGHT +# NOW in another process, whose restore step still needs it. Deleting one +# mid-flight destroys the only copy of that shim. +_QUARANTINE_GRACE_SECONDS = 15 * 60 + + +def _quarantine_stamp_ms(stale: Path) -> int | None: + """The ``.old.`` stamp in a quarantine filename, or ``None``. + + ``None`` means the name was not produced by + :func:`_quarantine_running_hermes_exe`. We neither rescue nor delete those: + the sweep should not destroy files whose provenance it cannot establish, and + they are not ours to put back. + + Parsed from the NAME rather than ``st_mtime`` because ``rename`` preserves + the original shim's mtime, which records when uv wrote the shim — days + earlier, in general — not when it was quarantined. + """ + try: + return int(stale.name.rsplit(".old.", 1)[1]) + except (IndexError, ValueError): + return None + + def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None: - """Sweep ``hermes.exe.old.*`` and stale reboot renames left by prior updates. + """Sweep — and where necessary RESCUE — ``hermes.exe.old.*`` from updates. + + Called early on every hermes invocation. Two cases the old unconditional + ``unlink()`` got wrong, both ending with ``hermes`` gone from PATH: + + 1. **Orphan rescue.** If ``hermes.exe`` is missing while + ``hermes.exe.old.*`` is present, that .old file is the ONLY surviving + copy of the shim — an update died, or its restore failed, between + the rename and uv writing a replacement (#75584). Deleting it converts a + one-rename recovery into a full reinstall. Put it back instead, through + the same retry-and-report helper the update-time restore uses. + 2. **Concurrency.** A fresh quarantine file may belong to an update in + flight in another process (the desktop update button racing a shell + ``hermes update`` does exactly this). Leave anything inside the grace + window alone; a later run sweeps it. - Called early on every hermes invocation. The .old files are unlocked once - their owning process exited, so deletion succeeds the next run. Silent - no-op when nothing's there or on file-locked / permission errors. + Silent no-op on non-Windows, when there is nothing to do, or on + file-locked / permission errors. """ if not _is_windows(): return @@ -9270,14 +9312,42 @@ def _cleanup_quarantined_exes(scripts_dir: Path | None = None) -> None: if scripts_dir is None: return _cleanup_pending_shim_renames(scripts_dir) + + now = _time.time() + try: - for stale in scripts_dir.glob("*.exe.old.*"): - try: - stale.unlink() - except OSError: - pass # still locked or in use — try again next run + candidates = [ + (stamp, stale) + for stale, stamp in ( + (p, _quarantine_stamp_ms(p)) for p in scripts_dir.glob("*.exe.old.*") + ) + if stamp is not None + ] except OSError: - pass + return + + # Newest first by PARSED stamp. Sorting the raw filenames lexicographically + # only tracks recency while every stamp shares a digit width: a stray + # ``.old.999`` sorts above a 13-digit epoch-ms stamp and would be the copy + # rescued onto the live shim name. + candidates.sort(key=lambda pair: pair[0], reverse=True) + + for stamp, stale in candidates: + try: + original = stale.with_name(stale.name.rsplit(".old.", 1)[0]) + + if not original.exists(): + # Orphan rescue: this is the last copy of the shim, so it gets + # the retry ladder and the recovery message, not a bare rename. + _early_recovery_mod.restore_quarantined_shims([(original, stale)]) + continue + + if now - stamp / 1000.0 < _QUARANTINE_GRACE_SECONDS: + continue # may be a live quarantine from a concurrent update + + stale.unlink() + except OSError: + pass # still locked or in use — try again next run # Import probes for venv corruption after a failed lazy ``uv pip install``. diff --git a/tests/hermes_cli/test_quarantine_orphan_rescue.py b/tests/hermes_cli/test_quarantine_orphan_rescue.py new file mode 100644 index 000000000000..aef0704c7aff --- /dev/null +++ b/tests/hermes_cli/test_quarantine_orphan_rescue.py @@ -0,0 +1,347 @@ +"""Regression tests: a failed quarantine restore must never strand `hermes`. + +On Windows the updater renames the live ``hermes*.exe`` shims aside +(``hermes.exe.old.``) so uv can write replacements. Gaps in the +recovery path ended with ``hermes`` gone from PATH — and, because the command +that repairs it IS ``hermes update``, unrecoverable without a manual reinstall +(#75584): + +1. Restoring a shim got a single attempt whose ``OSError`` was swallowed in + silence, while the outbound quarantine rename already retried a lock. +2. The startup sweep unlinked every ``*.exe.old.*``. When the original shim was + already missing, that .old file was the ONLY surviving copy — deleting it + converted a one-rename recovery into a full reinstall. It also raced a + concurrent in-flight update, destroying the quarantine that update's own + restore was about to rename back. + +These tests pin the hardened behavior: retry, rescue, report, order by parsed +stamp, and leave files we did not create alone. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from unittest.mock import patch + +import pytest + +from hermes_cli import _early_recovery as er +from hermes_cli import _install_repair as ir +from hermes_cli import main as cli_main + + +def _make_scripts_dir(tmp_path: Path) -> Path: + scripts = tmp_path / "venv" / "Scripts" + scripts.mkdir(parents=True) + return scripts + + +def _stamp(ms_ago: int = 0) -> int: + return int(time.time() * 1000) - ms_ago + + +def _run_cleanup(scripts: Path): + """Drive the sweep with the Windows gate forced and the registry stubbed. + + ``_cleanup_pending_shim_renames`` reaches into PendingFileRenameOperations; + it has its own tests and must not run here. + """ + return patch.multiple( + cli_main, + _is_windows=lambda: True, + _cleanup_pending_shim_renames=lambda _scripts_dir: 0, + ) + + +# --------------------------------------------------------------------------- +# orphan rescue +# --------------------------------------------------------------------------- + + +def test_cleanup_rescues_orphan_when_original_missing(tmp_path): + """The .old file is the last copy of the shim — put it back, don't delete.""" + scripts = _make_scripts_dir(tmp_path) + orphan = scripts / f"hermes.exe.old.{_stamp()}" + orphan.write_bytes(b"MZ-orphan") + + with _run_cleanup(scripts): + cli_main._cleanup_quarantined_exes(scripts) + + assert (scripts / "hermes.exe").read_bytes() == b"MZ-orphan" + assert not orphan.exists() + + +def test_cleanup_rescue_survives_a_transient_lock(tmp_path, capsys): + """The rescue rename retries a lock instead of stranding on first failure. + + This is the window the sweep runs in: the shim is ALREADY gone from PATH, so + giving up here leaves the user stranded exactly as if the sweep had deleted + the file. + """ + scripts = _make_scripts_dir(tmp_path) + orphan = scripts / f"hermes.exe.old.{_stamp()}" + orphan.write_bytes(b"MZ-orphan") + + real_rename = os.rename + calls = {"n": 0} + + def flaky(src, dst): + calls["n"] += 1 + if calls["n"] == 1: + raise PermissionError(32, "being used by another process") + return real_rename(src, dst) + + with _run_cleanup(scripts), patch.object(er.os, "rename", flaky): + cli_main._cleanup_quarantined_exes(scripts) + + assert (scripts / "hermes.exe").read_bytes() == b"MZ-orphan" + assert calls["n"] >= 2, "rescue must retry after a transient lock" + assert capsys.readouterr().err == "", "a recovered rescue must stay quiet" + + +def test_cleanup_rescue_reports_when_it_cannot_recover(tmp_path, capsys): + """A rescue that exhausts its retries must say so, not fail silently.""" + scripts = _make_scripts_dir(tmp_path) + orphan = scripts / f"hermes.exe.old.{_stamp()}" + orphan.write_bytes(b"MZ-orphan") + + def always_locked(src, dst): + raise PermissionError(32, "being used by another process") + + with _run_cleanup(scripts), patch.object(er.os, "rename", always_locked): + cli_main._cleanup_quarantined_exes(scripts) + + captured = capsys.readouterr() + assert "FAILED to restore hermes.exe" in captured.err + assert "move" in captured.err, "must print the literal recovery command" + assert captured.out == "", "stdout must stay clean for JSON-RPC" + assert orphan.exists(), "the last copy must survive a failed rescue" + + +def test_cleanup_rescue_is_quiet_when_another_process_wins(tmp_path, capsys): + """Two sweeps, one orphan: the loser must no-op cleanly, not report failure.""" + scripts = _make_scripts_dir(tmp_path) + orphan = scripts / f"hermes.exe.old.{_stamp()}" + orphan.write_bytes(b"MZ-orphan") + original = scripts / "hermes.exe" + + def loses_race(src, dst): + # The "winner" lands the shim while our attempt is in flight. + original.write_bytes(b"MZ-from-winner") + raise PermissionError(32, "being used by another process") + + with _run_cleanup(scripts), patch.object(er.os, "rename", loses_race): + cli_main._cleanup_quarantined_exes(scripts) + + captured = capsys.readouterr() + assert original.read_bytes() == b"MZ-from-winner" + assert captured.err == "", "losing a benign race is not a failure" + assert captured.out == "" + + +# --------------------------------------------------------------------------- +# ordering and provenance +# --------------------------------------------------------------------------- + + +def test_cleanup_rescues_newest_by_parsed_stamp_not_lexicographic(tmp_path): + """Mixed-width stamps: ordering must follow the parsed integer. + + ``sorted(reverse=True)`` over raw filenames puts ``.old.999`` above a + 13-digit epoch-ms stamp, which would rescue the wrong bytes onto the live + shim name. + """ + scripts = _make_scripts_dir(tmp_path) + (scripts / "hermes.exe.old.999").write_bytes(b"MZ-stray-short-stamp") + (scripts / f"hermes.exe.old.{_stamp(60_000)}").write_bytes(b"MZ-genuine") + + with _run_cleanup(scripts): + cli_main._cleanup_quarantined_exes(scripts) + + assert (scripts / "hermes.exe").read_bytes() == b"MZ-genuine" + + +def test_cleanup_ignores_names_it_did_not_create(tmp_path): + """An unparseable suffix is not ours: never rescued, never deleted.""" + scripts = _make_scripts_dir(tmp_path) + (scripts / "hermes.exe").write_bytes(b"MZ-live") + foreign = scripts / "hermes.exe.old.backup" + foreign.write_bytes(b"MZ-someone-elses-file") + + with _run_cleanup(scripts): + cli_main._cleanup_quarantined_exes(scripts) + + assert foreign.exists(), "the sweep must not delete files of unknown provenance" + assert foreign.read_bytes() == b"MZ-someone-elses-file" + assert (scripts / "hermes.exe").read_bytes() == b"MZ-live" + + +def test_cleanup_does_not_rescue_from_a_foreign_name(tmp_path): + """Missing shim + only a foreign .old: leave it be rather than guess.""" + scripts = _make_scripts_dir(tmp_path) + foreign = scripts / "hermes.exe.old.backup" + foreign.write_bytes(b"MZ-someone-elses-file") + + with _run_cleanup(scripts): + cli_main._cleanup_quarantined_exes(scripts) + + assert not (scripts / "hermes.exe").exists() + assert foreign.exists() + + +# --------------------------------------------------------------------------- +# concurrency grace window +# --------------------------------------------------------------------------- + + +def test_cleanup_leaves_fresh_quarantine_for_concurrent_update(tmp_path): + """A young .old may belong to an update in flight elsewhere — hands off.""" + scripts = _make_scripts_dir(tmp_path) + (scripts / "hermes.exe").write_bytes(b"MZ-live") + fresh = scripts / f"hermes.exe.old.{_stamp()}" + fresh.write_bytes(b"MZ-inflight") + + with _run_cleanup(scripts): + cli_main._cleanup_quarantined_exes(scripts) + + assert fresh.exists(), "a live quarantine must survive another process's sweep" + + +def test_cleanup_still_sweeps_genuinely_stale_quarantine(tmp_path): + """Past the grace window, with the shim present, it's garbage — sweep it.""" + scripts = _make_scripts_dir(tmp_path) + (scripts / "hermes.exe").write_bytes(b"MZ-live") + ancient_ms = (cli_main._QUARANTINE_GRACE_SECONDS + 60) * 1000 + stale = scripts / f"hermes.exe.old.{_stamp(ancient_ms)}" + stale.write_bytes(b"MZ-stale") + + with _run_cleanup(scripts): + cli_main._cleanup_quarantined_exes(scripts) + + assert not stale.exists() + assert (scripts / "hermes.exe").read_bytes() == b"MZ-live" + + +def test_cleanup_age_comes_from_filename_not_mtime(tmp_path): + """rename() preserves mtime, so only the name records the quarantine time.""" + scripts = _make_scripts_dir(tmp_path) + (scripts / "hermes.exe").write_bytes(b"MZ-live") + fresh = scripts / f"hermes.exe.old.{_stamp()}" + fresh.write_bytes(b"MZ-inflight") + week_ago = time.time() - 7 * 24 * 3600 + os.utime(fresh, (week_ago, week_ago)) + + with _run_cleanup(scripts): + cli_main._cleanup_quarantined_exes(scripts) + + assert fresh.exists(), "grace window must key off the .old. stamp" + + +def test_quarantine_stamp_ms_parses_and_rejects(): + assert cli_main._quarantine_stamp_ms(Path("hermes.exe.old.1787020473885")) == 1787020473885 + assert cli_main._quarantine_stamp_ms(Path("hermes.exe.old.backup")) is None + assert cli_main._quarantine_stamp_ms(Path("hermes.exe")) is None + + +# --------------------------------------------------------------------------- +# the shared restore helper +# --------------------------------------------------------------------------- + + +def test_helper_retries_then_succeeds(tmp_path): + scripts = _make_scripts_dir(tmp_path) + quarantined = scripts / "hermes.exe.old.123" + quarantined.write_bytes(b"MZ-old-hermes") + original = scripts / "hermes.exe" + + real_rename = os.rename + calls = {"n": 0} + + def flaky(src, dst): + calls["n"] += 1 + if calls["n"] == 1: + raise PermissionError(32, "being used by another process") + return real_rename(src, dst) + + with patch.object(er.os, "rename", flaky): + failed = er.restore_quarantined_shims([(original, quarantined)]) + + assert failed == [] + assert original.read_bytes() == b"MZ-old-hermes" + assert calls["n"] >= 2 + + +def test_helper_reports_failure_and_returns_the_pair(tmp_path, capsys): + scripts = _make_scripts_dir(tmp_path) + quarantined = scripts / "hermes.exe.old.123" + quarantined.write_bytes(b"MZ-old-hermes") + original = scripts / "hermes.exe" + + def always_locked(src, dst): + raise PermissionError(32, "being used by another process") + + with patch.object(er.os, "rename", always_locked): + failed = er.restore_quarantined_shims([(original, quarantined)]) + + captured = capsys.readouterr() + assert failed == [(original, quarantined)] + assert "FAILED to restore hermes.exe" in captured.err + assert "hermes.exe.old.123" in captured.err + assert "move" in captured.err + assert captured.out == "" + + +def test_helper_is_a_noop_when_installer_wrote_a_fresh_shim(tmp_path, capsys): + scripts = _make_scripts_dir(tmp_path) + quarantined = scripts / "hermes.exe.old.123" + quarantined.write_bytes(b"MZ-old") + original = scripts / "hermes.exe" + original.write_bytes(b"MZ-fresh") + + failed = er.restore_quarantined_shims([(original, quarantined)]) + + assert failed == [] + assert original.read_bytes() == b"MZ-fresh", "must not clobber the fresh shim" + assert capsys.readouterr().err == "" + + +# --------------------------------------------------------------------------- +# both call sites route through the helper +# --------------------------------------------------------------------------- + + +def test_main_restore_reports_on_stderr(tmp_path, capsys): + scripts = _make_scripts_dir(tmp_path) + quarantined = scripts / "hermes.exe.old.123" + quarantined.write_bytes(b"MZ-old-hermes") + original = scripts / "hermes.exe" + + def always_locked(src, dst): + raise PermissionError(32, "being used by another process") + + with patch.object(er.os, "rename", always_locked): + cli_main._restore_quarantined_exes([(original, quarantined)]) + + captured = capsys.readouterr() + assert "FAILED to restore hermes.exe" in captured.err + assert captured.out == "" + + +def test_repair_restore_reports_on_stderr(tmp_path, capsys): + """The early-recovery path must warn on stderr (acp speaks JSON-RPC on stdout).""" + scripts = _make_scripts_dir(tmp_path) + quarantined = scripts / "hermes.exe.old.123" + quarantined.write_bytes(b"MZ-old-hermes") + original = scripts / "hermes.exe" + + def always_locked(src, dst): + raise PermissionError(32, "being used by another process") + + with patch.object(er.os, "rename", always_locked): + ir._restore_quarantined_exes([(original, quarantined)]) + + captured = capsys.readouterr() + assert "FAILED to restore hermes.exe" in captured.err + assert captured.out == "", "stdout must stay clean for JSON-RPC" From 6a4e212ee034f01f2253d6fac9549bfc9a02901d Mon Sep 17 00:00:00 2001 From: Axl Ibiza <84248988+andrexibiza@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:32:39 +0000 Subject: [PATCH 068/666] fix(desktop): stop missing plugin entries from flooding IPC logs --- .../src/contrib/runtime-loader.test.ts | 107 +++++++++++++----- apps/desktop/src/contrib/runtime-loader.ts | 88 +++++++++++--- 2 files changed, 152 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/contrib/runtime-loader.test.ts b/apps/desktop/src/contrib/runtime-loader.test.ts index cdc9f8ea6781..deac8921c359 100644 --- a/apps/desktop/src/contrib/runtime-loader.test.ts +++ b/apps/desktop/src/contrib/runtime-loader.test.ts @@ -22,6 +22,7 @@ const readFileText = vi.fn<(path: string) => Promise<{ text: string; truncated?: const readPluginSource = vi.fn<(path: string) => Promise<{ text: string; truncated?: boolean }>>() const watchDirectory = vi.fn<(path: string) => Promise<{ id: string }>>() const watchPreviewFile = vi.fn<(path: string) => Promise<{ id: string }>>() +const stopPreviewFileWatch = vi.fn<(id: string) => Promise>() const onPreviewFileChanged = vi.fn() beforeEach(() => { @@ -32,6 +33,8 @@ beforeEach(() => { readPluginSource.mockReset() watchDirectory.mockReset() watchPreviewFile.mockReset() + stopPreviewFileWatch.mockReset() + stopPreviewFileWatch.mockResolvedValue(true) onPreviewFileChanged.mockReset() getStatus.mockClear() ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { @@ -40,6 +43,7 @@ beforeEach(() => { onPreviewFileChanged, readDir, readFileText, + stopPreviewFileWatch, watchDirectory, watchPreviewFile } @@ -74,22 +78,28 @@ describe('scanDiskPlugins (#66899)', () => { expect(readDir).not.toHaveBeenCalled() }) - it('probes desktop/plugin.js inside agent-plugin packages (unified packaging)', async () => { + it('treats a package without a Desktop half as metadata, not a throwing file read', async () => { desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins') agentPluginsRoot.mockResolvedValue('/local/.hermes/plugins') - readDir.mockImplementation(async dir => - dir === '/local/.hermes/plugins' - ? { entries: [{ isDirectory: true, name: 'my-feature', path: '/local/.hermes/plugins/my-feature' }] } - : { entries: [] } - ) - // No desktop half in this package — probe must target desktop/plugin.js. - readFileText.mockRejectedValue(new Error('ENOENT')) + readDir.mockImplementation(async dir => { + if (dir === '/local/.hermes/plugins') { + return { entries: [{ isDirectory: true, name: 'my-feature', path: '/local/.hermes/plugins/my-feature' }] } + } + + if (dir === '/local/.hermes/plugins/my-feature') { + return { + entries: [{ isDirectory: false, name: 'plugin.yaml', path: '/local/.hermes/plugins/my-feature/plugin.yaml' }] + } + } + + return { entries: [] } + }) await discoverRuntimePlugins() - expect(readFileText).toHaveBeenCalledWith('/local/.hermes/plugins/my-feature/desktop/plugin.js') - // The Python half's files must never be probed as a desktop entry. - expect(readFileText).not.toHaveBeenCalledWith('/local/.hermes/plugins/my-feature/plugin.js') + expect(readDir).toHaveBeenCalledWith('/local/.hermes/plugins/my-feature') + expect(readDir).not.toHaveBeenCalledWith('/local/.hermes/plugins/my-feature/desktop') + expect(readFileText).not.toHaveBeenCalled() }) it('still scans the standalone root when agentPluginsRoot is absent (older shell)', async () => { @@ -106,11 +116,35 @@ describe('scanDiskPlugins (#66899)', () => { it('loads a unified desktop half OPT-IN: inventoried but not activated by default', async () => { desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins') agentPluginsRoot.mockResolvedValue('/local/.hermes/plugins') - readDir.mockImplementation(async dir => - dir === '/local/.hermes/plugins' - ? { entries: [{ isDirectory: true, name: 'uni', path: '/local/.hermes/plugins/uni' }] } - : { entries: [] } - ) + let desktopEntryPresent = true + + readDir.mockImplementation(async dir => { + if (dir === '/local/.hermes/plugins') { + return { entries: [{ isDirectory: true, name: 'uni', path: '/local/.hermes/plugins/uni' }] } + } + + if (dir === '/local/.hermes/plugins/uni') { + return { + entries: [{ isDirectory: true, name: 'desktop', path: '/local/.hermes/plugins/uni/desktop' }] + } + } + + if (dir === '/local/.hermes/plugins/uni/desktop') { + return { + entries: desktopEntryPresent + ? [ + { + isDirectory: false, + name: 'plugin.js', + path: '/local/.hermes/plugins/uni/desktop/plugin.js' + } + ] + : [] + } + } + + return { entries: [] } + }) const register = vi.fn() @@ -154,6 +188,14 @@ describe('scanDiskPlugins (#66899)', () => { await setPluginEnabled('uni', true) expect(register).toHaveBeenCalledTimes(1) expect($pluginRecords.get().uni.status).toBe('loaded') + + // Removing only desktop/plugin.js (while the Python package folder + // remains) unloads the previous Desktop registration instead of leaving + // a live ghost behind. + desktopEntryPresent = false + await discoverRuntimePlugins() + expect($pluginRecords.get().uni).toBeUndefined() + expect(stopPreviewFileWatch).toHaveBeenCalledWith('w-uni') } finally { createObjectURL.mockRestore() revokeObjectURL.mockRestore() @@ -209,15 +251,30 @@ describe('plugin source reads (512 KiB preview-cap bug)', () => { } } + /** Two-level standalone-root listing the metadata-walk probe needs: + * the root lists the package folder, the folder lists plugin.js. */ + const standaloneRootWith = (name: string) => { + const folder = `/local/.hermes/desktop-plugins/${name}` + + readDir.mockImplementation(async dir => { + if (dir === '/local/.hermes/desktop-plugins') { + return { entries: [{ isDirectory: true, name, path: folder }] } + } + + if (dir === folder) { + return { entries: [{ isDirectory: false, name: 'plugin.js', path: `${folder}/plugin.js` }] } + } + + return { entries: [] } + }) + } + it('loads the full source via readPluginSource when the shell offers it', async () => { ;(window.hermesDesktop as unknown as { readPluginSource: unknown }).readPluginSource = readPluginSource desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins') agentPluginsRoot.mockResolvedValue('') - readDir.mockResolvedValue({ - entries: [{ isDirectory: true, name: 'big', path: '/local/.hermes/desktop-plugins/big' }] - }) - // The existence probe still uses the preview read (metadata is enough - // there) — it may report truncation without failing the scan. + standaloneRootWith('big') + // The preview read would truncate this source — it must never be used. readFileText.mockResolvedValue({ text: '// first 512 KiB only', truncated: true }) const register = vi.fn() @@ -246,9 +303,7 @@ describe('plugin source reads (512 KiB preview-cap bug)', () => { it('older shell without readPluginSource: a truncated preview read fails LOUDLY, never evaluates', async () => { desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins') agentPluginsRoot.mockResolvedValue('') - readDir.mockResolvedValue({ - entries: [{ isDirectory: true, name: 'huge', path: '/local/.hermes/desktop-plugins/huge' }] - }) + standaloneRootWith('huge') // 512 KiB window of a larger file — parses fine, but is NOT the plugin. readFileText.mockResolvedValue({ text: 'export default { id: "huge", register: () => { throw new Error("must never evaluate") } }', @@ -276,9 +331,7 @@ describe('plugin source reads (512 KiB preview-cap bug)', () => { it('older shell, small plugin (not truncated): still loads through readFileText', async () => { desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins') agentPluginsRoot.mockResolvedValue('') - readDir.mockResolvedValue({ - entries: [{ isDirectory: true, name: 'small', path: '/local/.hermes/desktop-plugins/small' }] - }) + standaloneRootWith('small') const register = vi.fn() diff --git a/apps/desktop/src/contrib/runtime-loader.ts b/apps/desktop/src/contrib/runtime-loader.ts index 3c94f37dfae1..a4c8d2215d93 100644 --- a/apps/desktop/src/contrib/runtime-loader.ts +++ b/apps/desktop/src/contrib/runtime-loader.ts @@ -233,8 +233,10 @@ interface DiskRoot { /** Root-level enable posture, forwarded to the loader (see LoadOptions). */ defaultEnabled?: boolean dir: string - /** Resolve a scanned folder to its candidate plugin entry file. */ - entry: (folderPath: string) => string + /** Path segments below each scanned package folder. Discovery walks + * directory metadata to this file instead of throwing a content read for + * every ordinary package that has no Desktop half. */ + entrySegments: readonly string[] } /** Both scan roots, resolved fresh each pass (Electron-local, never the @@ -251,7 +253,7 @@ async function diskRoots(): Promise { const standalone = await desktop.desktopPluginsRoot?.() if (standalone) { - roots.push({ dir: standalone, entry: folder => `${folder}/plugin.js` }) + roots.push({ dir: standalone, entrySegments: ['plugin.js'] }) } const unified = await desktop.agentPluginsRoot?.() @@ -261,7 +263,7 @@ async function diskRoots(): Promise { // user allowlists the Python half (plugins.enabled), so the desktop half // matches that posture — inventoried in Settings → Plugins, off until // toggled. The standalone desktop-plugins door keeps its default-on trust. - roots.push({ defaultEnabled: false, dir: unified, entry: folder => `${folder}/desktop/plugin.js` }) + roots.push({ defaultEnabled: false, dir: unified, entrySegments: ['desktop', 'plugin.js'] }) } return roots @@ -323,7 +325,10 @@ async function readPluginSourceText(file: string): Promise { return result.text } -async function loadDiskPlugin(entry: DiskPlugin): Promise { +/** Returns false when the entry file could not be read (vanished mid-read) so + * the caller can reconcile/unload the registration instead of retaining a + * live ghost for a missing entry. */ +async function loadDiskPlugin(entry: DiskPlugin): Promise { const prevId = entry.id try { @@ -349,11 +354,14 @@ async function loadDiskPlugin(entry: DiskPlugin): Promise { if (id && id !== entry.origin) { dropOriginRecord(entry.origin, entry) } + + return true } catch (error) { // An oversize source is a REAL failure the user must see (the silent // shape was the bug: a truncated file evaluated as a syntax error, or - // worse, as half a plugin). Everything else is a file vanishing - // mid-read — the next scan reconciles, stay quiet. + // worse, as half a plugin). It is a completed read of an existing file, + // so report it and keep the registration (true) — everything else is a + // file vanishing mid-read, where false lets the caller reconcile/unload. if (error instanceof PluginSourceOversizeError) { console.error(`[plugins] ${entry.origin}: ${error.message}`) notifyError(error, `Plugin "${entry.origin}" failed to load`) @@ -365,10 +373,45 @@ async function loadDiskPlugin(entry: DiskPlugin): Promise { status: 'error', error: error.message }) + + return true } + + return false } } +async function resolveDiskPluginEntry( + desktop: Window['hermesDesktop'], + folderPath: string, + segments: readonly string[] +): Promise { + let currentDir = folderPath + + for (let index = 0; index < segments.length; index += 1) { + const { entries } = await desktop.readDir(currentDir) + const entry = entries.find(candidate => candidate.name === segments[index]) + + if (!entry) { + return null + } + + const last = index === segments.length - 1 + + if (last) { + return entry.isDirectory ? null : entry.path + } + + if (!entry.isDirectory) { + return null + } + + currentDir = entry.path + } + + return null +} + async function scanDiskPlugins(): Promise { const desktop = window.hermesDesktop @@ -399,19 +442,24 @@ async function scanDiskPlugins(): Promise { } for (const dir of entries.filter(e => e.isDirectory)) { - const file = root.entry(dir.path) + let file: string | null + + try { + file = await resolveDiskPluginEntry(desktop, dir.path, root.entrySegments) + } catch { + continue // Folder changed during the metadata walk; the next tick reconciles. + } + + if (!file) { + continue // Ordinary agent package with no Desktop half — not an error. + } + seen.add(file) if (disk.has(file)) { continue } - try { - await desktop.readFileText(file) - } catch { - continue // No entry file (yet) — not a plugin folder for this root. - } - const record: DiskPlugin = { defaultEnabled: root.defaultEnabled, file, @@ -421,7 +469,11 @@ async function scanDiskPlugins(): Promise { } disk.set(file, record) - await loadDiskPlugin(record) + + if (!(await loadDiskPlugin(record))) { + disk.delete(file) + continue + } try { record.watchId = (await desktop.watchPreviewFile(file)).id @@ -485,7 +537,11 @@ export function watchRuntimePlugins(): void { for (const record of disk.values()) { if (record.watchId === id) { - void loadDiskPlugin(record) + void loadDiskPlugin(record).then(readable => { + if (!readable) { + void scanDiskPlugins() + } + }) return } From 0430e3c719ff690fb031658a3a9b58b7756edb65 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:52:39 -0700 Subject: [PATCH 069/666] fix: harden the metadata-walk probe (directory-named plugin.js regression test, lint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the #91828 salvage: regression test for the subtle resolveDiskPluginEntry branch that rejects a DIRECTORY literally named plugin.js, and lint fix. The truncation-fix semantics from #92809 are preserved: an oversize source keeps its error inventory row (returns true — the file exists and was fully probed), while a vanished file returns false so the scanner reconciles the ghost. --- .../src/contrib/runtime-loader.test.ts | 24 +++++++++++++++++++ apps/desktop/src/contrib/runtime-loader.ts | 1 + 2 files changed, 25 insertions(+) diff --git a/apps/desktop/src/contrib/runtime-loader.test.ts b/apps/desktop/src/contrib/runtime-loader.test.ts index deac8921c359..02d60addf8e4 100644 --- a/apps/desktop/src/contrib/runtime-loader.test.ts +++ b/apps/desktop/src/contrib/runtime-loader.test.ts @@ -102,6 +102,30 @@ describe('scanDiskPlugins (#66899)', () => { expect(readFileText).not.toHaveBeenCalled() }) + it('a DIRECTORY named plugin.js is not a plugin entry (metadata walk rejects it)', async () => { + desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins') + agentPluginsRoot.mockResolvedValue('') + readDir.mockImplementation(async dir => { + if (dir === '/local/.hermes/desktop-plugins') { + return { entries: [{ isDirectory: true, name: 'odd', path: '/local/.hermes/desktop-plugins/odd' }] } + } + + if (dir === '/local/.hermes/desktop-plugins/odd') { + // A folder literally named plugin.js — must resolve to "no entry". + return { + entries: [{ isDirectory: true, name: 'plugin.js', path: '/local/.hermes/desktop-plugins/odd/plugin.js' }] + } + } + + return { entries: [] } + }) + + await discoverRuntimePlugins() + + expect(readFileText).not.toHaveBeenCalled() + expect($pluginRecords.get().odd).toBeUndefined() + }) + it('still scans the standalone root when agentPluginsRoot is absent (older shell)', async () => { delete (window.hermesDesktop as unknown as { agentPluginsRoot?: unknown }).agentPluginsRoot desktopPluginsRoot.mockResolvedValue('/local/.hermes/desktop-plugins') diff --git a/apps/desktop/src/contrib/runtime-loader.ts b/apps/desktop/src/contrib/runtime-loader.ts index a4c8d2215d93..a28a84fd053f 100644 --- a/apps/desktop/src/contrib/runtime-loader.ts +++ b/apps/desktop/src/contrib/runtime-loader.ts @@ -472,6 +472,7 @@ async function scanDiskPlugins(): Promise { if (!(await loadDiskPlugin(record))) { disk.delete(file) + continue } From 63a9c26fbe6ca50138814111c21a4f4550ea85c1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:17:12 -0700 Subject: [PATCH 070/666] feat(model): Codex GPT slugs default back to 272K; explicit -900k picker variants opt into the verified large window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Aug 16 change that auto-raised gpt-5.4/5.6 Codex OAuth context to the live-verified 900K burned through subscription usage for users who never asked for the larger window (bigger window = more input tokens per request). - Base Codex slugs (gpt-5.6-sol/terra/luna, gpt-5.4) now resolve to the advertised 272K again — the cheaper limit is the default. - The model picker synthesizes explicit -900k variants (e.g. gpt-5.6-sol-900k) for every live-verified slug; selecting one opts into the 900K window. Slugs that genuinely enforce 272K (gpt-5.5, gpt-5.4-mini) get no variant. - The -900k suffix is Hermes-side only: stripped before the model id hits the wire (main transport + auxiliary Responses adapter), and pricing aliases the variants onto the base entries. - Docs: new opt-in section in context-compression-and-caching.md. --- agent/auxiliary_client.py | 11 ++- agent/model_metadata.py | 87 ++++++++++++++++--- agent/transports/codex.py | 11 ++- agent/usage_pricing.py | 7 +- hermes_cli/codex_models.py | 38 +++++++- hermes_cli/models.py | 4 +- tests/agent/test_model_metadata.py | 77 +++++++++++++--- .../agent/transports/test_codex_transport.py | 18 +++- tests/hermes_cli/test_codex_models.py | 17 ++++ .../context-compression-and-caching.md | 16 ++++ 10 files changed, 253 insertions(+), 33 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index b5aeae274f09..8d387b86d1e0 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -161,7 +161,11 @@ def aux_probe_mode(): _aux_probe_state.active = prev from agent.credential_pool import load_pool -from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length +from agent.model_metadata import ( + MINIMUM_CONTEXT_LENGTH, + get_model_context_length, + strip_codex_context_variant_suffix as _strip_codex_ctx_variant, +) from hermes_cli.config import get_hermes_home from hermes_constants import OPENROUTER_BASE_URL from utils import base_url_host_matches, base_url_hostname, env_float, is_truthy_value, model_forces_max_completion_tokens, normalize_proxy_env_vars @@ -1531,7 +1535,10 @@ def create(self, **kwargs) -> Any: ) resp_kwargs: Dict[str, Any] = { - "model": model, + # Strip the Hermes-side ``-900k`` large-context picker suffix — + # the Codex backend only knows the base slug (mirrors the main + # transport in agent/transports/codex.py::build_kwargs). + "model": _strip_codex_ctx_variant(model), "instructions": instructions, "input": input_items or [{"role": "user", "content": ""}], "store": False, diff --git a/agent/model_metadata.py b/agent/model_metadata.py index bad065573a0b..51643ed5caf8 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -2418,9 +2418,18 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> # ≥11K margin under the observed ceiling and matches the compaction point # Codex's own client config documents for the 1M window. # -# Applied ONLY when the resolved value (live probe or fallback table) is -# exactly the known-stale 272,000 advertisement — if OpenAI moves the -# advertised number in either direction (the gpt-5.6 family shifted +# OPT-IN ONLY (Aug 2026 policy, Teknium): the large window is exposed via +# explicit ``-900k`` picker variants (e.g. ``gpt-5.6-sol-900k``) — the base +# slugs keep the advertised 272K so the cheaper limit is the default. A +# week of the 900K default burned through subscription usage for people +# who never asked for it. The variant suffix is a Hermes-side alias: it is +# stripped before the model id hits the wire (see +# ``strip_codex_context_variant_suffix`` callers in agent/transports/codex.py +# and agent/auxiliary_client.py). +# +# The bump is applied ONLY when the resolved value (live probe or fallback +# table) is exactly the known-stale 272,000 advertisement — if OpenAI moves +# the advertised number in either direction (the gpt-5.6 family shifted # 272K → 372K → 272K during July 2026), the catalog is trusted again and # this table is inert. ``gpt-5.6`` is a FAMILY PREFIX (sol/terra/luna and # dated snapshots; ``-pro`` slugs are not routable on Codex OAuth — the @@ -2438,14 +2447,63 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> # The advertised value the verified-above table is allowed to override. _CODEX_OAUTH_STALE_ADVERTISED_CTX = 272_000 +# Hermes-side picker suffix that opts a Codex slug into the live-verified +# large window. Never sent on the wire. +CODEX_CONTEXT_VARIANT_SUFFIX = "-900k" + + +def is_codex_context_variant(model: Optional[str]) -> bool: + """True when the model id carries the Hermes ``-900k`` opt-in suffix.""" + return (model or "").strip().lower().endswith(CODEX_CONTEXT_VARIANT_SUFFIX) + + +def strip_codex_context_variant_suffix(model: Optional[str]) -> str: + """Return the wire-safe slug with any ``-900k`` opt-in suffix removed. + + The suffix is a Hermes picker alias (``gpt-5.6-sol-900k``); the Codex + backend only knows the base slug. Case-insensitive; non-variant ids are + returned unchanged. + """ + raw = (model or "").strip() + if raw.lower().endswith(CODEX_CONTEXT_VARIANT_SUFFIX): + return raw[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)] + return raw + + +def has_codex_context_variant(model_bare: str) -> bool: + """True when a Codex BASE slug has a live-verified ``-900k`` variant. + + Used by the model pickers to decide which base slugs get a synthetic + ``-900k`` entry. Exact table first, then family prefixes — + mirrors ``_verified_codex_ctx_for_slug`` minus the suffix requirement. + """ + slug = (model_bare or "").strip().lower() + if not slug or slug.endswith(CODEX_CONTEXT_VARIANT_SUFFIX): + return False + if slug in _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT: + return True + for key in _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_PREFIXES: + if slug == key or slug.startswith(key + "-") or slug.startswith(key + "."): + return True + return False + def _verified_codex_ctx_for_slug(model_bare: str) -> Optional[int]: - """Return the live-verified Codex cap for a slug, or ``None``. + """Return the live-verified Codex cap for an OPTED-IN slug, or ``None``. + + The large window is opt-in: only ``-900k``-suffixed picker variants + (e.g. ``gpt-5.6-sol-900k``) resolve to the verified cap. Base slugs + keep the advertised 272K so the cheaper default limit applies unless + the user explicitly selects the large-context variant. - Exact slugs first, then family prefixes (````, ``-``, - ``.``) so dated snapshots of a verified family inherit the bump. + After stripping the suffix: exact slugs first, then family prefixes + (````, ``-``, ``.``) so dated snapshots of a verified + family inherit the bump. """ slug = (model_bare or "").strip().lower() + if not slug.endswith(CODEX_CONTEXT_VARIANT_SUFFIX): + return None + slug = slug[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)] if not slug: return None exact = _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT.get(slug) @@ -2586,8 +2644,9 @@ def _resolve_codex_oauth_context_length_with_source( def _apply_verified_bump(ctx: int, source: str) -> Tuple[int, str]: """Lift a known-stale 272K advertisement to the live-verified cap. - Only fires when the resolved value is EXACTLY the stale 272,000 - advertisement for a slug we have probed above it (see + Only fires for explicit ``-900k`` picker variants (opt-in), and only + when the resolved value is EXACTLY the stale 272,000 advertisement + for a slug we have probed above it (see ``_verified_codex_ctx_for_slug``). Any other advertised value — higher or lower — is trusted as a real server-side change. """ @@ -2600,19 +2659,23 @@ def _apply_verified_bump(ctx: int, source: str) -> Tuple[int, str]: return bumped, source return ctx, source + # ``-900k`` variants are Hermes picker aliases — the Codex catalog only + # knows the base slug, so resolve against the stripped id. + lookup_bare = strip_codex_context_variant_suffix(model_bare) + if access_token: live, fresh_probe = _fetch_codex_oauth_context_lengths_with_source(access_token) live_source = "live" if fresh_probe else "memory" - if model_bare in live: - return _apply_verified_bump(live[model_bare], live_source) + if lookup_bare in live: + return _apply_verified_bump(live[lookup_bare], live_source) # Case-insensitive match in case casing drifts - model_lower = model_bare.lower() + model_lower = lookup_bare.lower() for slug, ctx in live.items(): if slug.lower() == model_lower: return _apply_verified_bump(ctx, live_source) # Fallback: longest-key-first substring match over hardcoded defaults. - model_lower = model_bare.lower() + model_lower = lookup_bare.lower() for slug, ctx in sorted( _CODEX_OAUTH_CONTEXT_FALLBACK.items(), key=lambda x: len(x[0]), reverse=True ): diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 38e86a654cfe..45b53410c58b 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -567,8 +567,17 @@ def build_kwargs( # request is issued (openai==2.24.0). Reported for the # ``openai-codex`` / ``gpt-5.5`` combo on chatgpt.com/backend-api/codex # (#32892) when the agent runs without external tools registered. + # Function-level import: agent.model_metadata is imported lazily + # because provider plugins import this transport during + # model_metadata's own module init (circular otherwise). + from agent.model_metadata import ( + strip_codex_context_variant_suffix as _strip_ctx_variant, + ) kwargs = { - "model": model, + # ``-900k`` large-context picker variants are Hermes-side aliases + # (gpt-5.6-sol-900k etc.) — the Codex/OpenAI backend only knows + # the base slug, so strip the suffix before it hits the wire. + "model": _strip_ctx_variant(model), "instructions": instructions, "input": _chat_messages_to_responses_input( payload_messages, diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index c955bdc7f048..4aaf7409a7ef 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -994,11 +994,16 @@ class CostResult: # GPT-5.6 "-pro" high-effort variants bill at the same per-token rates as # their base tiers (more tokens per task, not a higher rate). Alias them -# onto the base entries so the snapshot stays single-source. +# onto the base entries so the snapshot stays single-source. The Hermes-side +# "-900k" large-context Codex picker variants are the same underlying model +# (the suffix is stripped on the wire), so they alias identically. for _base_56 in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"): _OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-pro")] = _OFFICIAL_DOCS_PRICING[ ("openai", _base_56) ] + _OFFICIAL_DOCS_PRICING[("openai", f"{_base_56}-900k")] = _OFFICIAL_DOCS_PRICING[ + ("openai", _base_56) + ] del _base_56 # The direct Gemini provider currently exposes preview IDs for these two diff --git a/hermes_cli/codex_models.py b/hermes_cli/codex_models.py index f058107676b8..2b17802d87d7 100644 --- a/hermes_cli/codex_models.py +++ b/hermes_cli/codex_models.py @@ -89,6 +89,38 @@ def _add_forward_compat_models(model_ids: List[str]) -> List[str]: return ordered +def _add_context_variants(model_ids: List[str]) -> List[str]: + """Insert ``-900k`` large-context picker variants after eligible base slugs. + + The ChatGPT Codex backend advertises 272K for the gpt-5.4 / gpt-5.6 + families but accepts ~911K (live-verified Aug 2026). The base slugs keep + the cheaper advertised 272K limit by default; each verified slug gets an + explicit ``-900k`` picker entry that opts into the large window. + The suffix is Hermes-side only — it is stripped before the model id hits + the wire (agent/transports/codex.py, agent/auxiliary_client.py). + """ + from agent.model_metadata import ( + CODEX_CONTEXT_VARIANT_SUFFIX, + has_codex_context_variant, + ) + + out: List[str] = [] + present = set(model_ids) + for model_id in model_ids: + out.append(model_id) + variant = model_id + CODEX_CONTEXT_VARIANT_SUFFIX + if variant in present or variant in out: + continue + if has_codex_context_variant(model_id): + out.append(variant) + return out + + +def _finalize_codex_models(model_ids: List[str]) -> List[str]: + """Forward-compat synthesis + large-context variant synthesis.""" + return _add_context_variants(_add_forward_compat_models(model_ids)) + + def _extract_chatgpt_account_id(access_token: str) -> Optional[str]: """Best-effort extraction of ``chatgpt_account_id`` from the OAuth JWT. @@ -160,7 +192,7 @@ def _fetch_models_from_api(access_token: str) -> List[str]: sortable.append((rank, slug)) sortable.sort(key=lambda x: (x[0], x[1])) - return _add_forward_compat_models([slug for _, slug in sortable]) + return _finalize_codex_models([slug for _, slug in sortable]) def _read_default_model(codex_home: Path) -> Optional[str]: @@ -232,7 +264,7 @@ def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]: if access_token: api_models = _fetch_models_from_api(access_token) if api_models: - return _add_forward_compat_models(api_models) + return _finalize_codex_models(api_models) # Fall back to local sources default_model = _read_default_model(codex_home) @@ -247,4 +279,4 @@ def get_codex_model_ids(access_token: Optional[str] = None) -> List[str]: if model_id not in ordered: ordered.append(model_id) - return _add_forward_compat_models(ordered) + return _finalize_codex_models(ordered) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 5dd8cccbb2df..f638be4419d5 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -178,8 +178,8 @@ def _codex_curated_models() -> list[str]: This keeps the gateway /model picker in sync with the CLI `hermes model` flow without maintaining a separate static list. """ - from hermes_cli.codex_models import DEFAULT_CODEX_MODELS, _add_forward_compat_models - return _add_forward_compat_models(list(DEFAULT_CODEX_MODELS)) + from hermes_cli.codex_models import DEFAULT_CODEX_MODELS, _finalize_codex_models + return _finalize_codex_models(list(DEFAULT_CODEX_MODELS)) # Static fallback for xAI when the models.dev disk cache is empty (fresh diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 1d589355ca05..a0f3b1fb6b9c 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -521,11 +521,11 @@ def test_live_codex_context_replaces_stale_cache_in_both_directions( "gpt-daybreak-blue-latest", # Sol alias; exact verified slug ], ) - def test_stale_272k_advertisement_bumped_to_live_verified_900k(self, slug): - """Codex advertises 272K for these slugs but the backend accepts ~911K - (verified live Aug 2026, after OpenAI enabled the large-context window - for ChatGPT-subscription accounts); the resolver lifts exactly-272K - to 900K.""" + def test_900k_variant_slug_bumped_to_live_verified_900k(self, slug): + """The backend accepts ~911K for these slugs (verified live Aug 2026), + but the large window is OPT-IN: only the explicit ``-900k`` picker + variant resolves to 900K. The catalog only knows the base slug, so + the resolver strips the suffix for the lookup, then applies the bump.""" from agent.model_metadata import get_model_context_length fake_response = MagicMock() @@ -537,16 +537,50 @@ def test_stale_272k_advertisement_bumped_to_live_verified_900k(self, slug): patch("agent.model_metadata.get_cached_context_length", return_value=None), \ patch("agent.model_metadata.save_context_length"): ctx = get_model_context_length( - model=slug, + model=slug + "-900k", base_url="https://chatgpt.com/backend-api/codex", api_key="fake-token", provider="openai-codex", ) assert ctx == 900_000 + @pytest.mark.parametrize( + "slug", + [ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.4", + "gpt-daybreak-blue-latest", + ], + ) + def test_base_slug_keeps_advertised_272k(self, slug): + """Base slugs (no ``-900k`` suffix) keep the advertised 272K — the + cheaper default limit. The verified-above bump is opt-in only.""" + from agent.model_metadata import get_model_context_length + + fake_response = MagicMock() + fake_response.status_code = 200 + fake_response.json.return_value = { + "models": [{"slug": slug, "context_window": 272_000}] + } + import agent.model_metadata as mm + mm._codex_oauth_context_cache = {} + with patch("agent.model_metadata.requests.get", return_value=fake_response), \ + patch("agent.model_metadata.get_cached_context_length", return_value=None), \ + patch("agent.model_metadata.save_context_length"): + ctx = get_model_context_length( + model=slug, + base_url="https://chatgpt.com/backend-api/codex", + api_key="fake-token", + provider="openai-codex", + ) + assert ctx == 272_000 + def test_non_272k_advertisement_is_trusted_verbatim(self): """Any advertised value other than the known-stale 272,000 — higher or - lower — is a real server-side change and must NOT be overridden.""" + lower — is a real server-side change and must NOT be overridden, even + for an explicit ``-900k`` opt-in variant.""" from agent.model_metadata import get_model_context_length for advertised in (372_000, 200_000, 1_050_000): @@ -561,7 +595,7 @@ def test_non_272k_advertisement_is_trusted_verbatim(self): patch("agent.model_metadata.get_cached_context_length", return_value=None), \ patch("agent.model_metadata.save_context_length"): ctx = get_model_context_length( - model="gpt-5.6-sol", + model="gpt-5.6-sol-900k", base_url="https://chatgpt.com/backend-api/codex", api_key="fake-token", provider="openai-codex", @@ -591,10 +625,11 @@ def test_slugs_that_enforce_272k_keep_advertised_value(self, slug): ) assert ctx == 272_000 - @pytest.mark.parametrize("slug", ["gpt-5.6-sol", "gpt-daybreak-blue-latest"]) + @pytest.mark.parametrize("slug", ["gpt-5.6-sol-900k", "gpt-daybreak-blue-latest-900k"]) def test_fallback_table_resolution_also_bumped(self, slug): - """When the live probe fails, the 272K fallback-table value for a - verified slug is bumped the same way (same enforcement applies).""" + """When the live probe fails, the 272K fallback-table value for an + opted-in ``-900k`` variant is bumped the same way (same enforcement + applies — the fallback lookup strips the suffix first).""" from agent.model_metadata import get_model_context_length fake_response = MagicMock() @@ -611,6 +646,26 @@ def test_fallback_table_resolution_also_bumped(self, slug): ) assert ctx == 900_000 + @pytest.mark.parametrize("slug", ["gpt-5.6-sol", "gpt-daybreak-blue-latest"]) + def test_fallback_table_base_slug_stays_272k(self, slug): + """Fallback-table resolution for BASE slugs stays at the advertised + 272K — the opt-in rule applies on the offline path too.""" + from agent.model_metadata import get_model_context_length + + fake_response = MagicMock() + fake_response.status_code = 401 + fake_response.json.return_value = {} + with patch("agent.model_metadata.requests.get", return_value=fake_response), \ + patch("agent.model_metadata.get_cached_context_length", return_value=None), \ + patch("agent.model_metadata.save_context_length"): + ctx = get_model_context_length( + model=slug, + base_url="https://chatgpt.com/backend-api/codex", + api_key="expired-token", + provider="openai-codex", + ) + assert ctx == 272_000 + diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 47b5c4e04747..9d80ffed4778 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -39,8 +39,24 @@ def test_convert_tools(self, transport): class TestCodexBuildKwargs: + def test_900k_context_variant_suffix_stripped_on_wire(self, transport): + """``-900k`` large-context picker variants are Hermes-side aliases — + the Codex backend only knows the base slug, so build_kwargs must + strip the suffix from the wire model id.""" + messages = [{"role": "user", "content": "Hi"}] + kw = transport.build_kwargs( + model="gpt-5.6-sol-900k", messages=messages, tools=[], + params={"is_codex_backend": True}, + ) + assert kw["model"] == "gpt-5.6-sol" - + def test_base_slug_model_id_unchanged_on_wire(self, transport): + messages = [{"role": "user", "content": "Hi"}] + kw = transport.build_kwargs( + model="gpt-5.6-sol", messages=messages, tools=[], + params={"is_codex_backend": True}, + ) + assert kw["model"] == "gpt-5.6-sol" diff --git a/tests/hermes_cli/test_codex_models.py b/tests/hermes_cli/test_codex_models.py index afd2a332b219..be27a4e62a90 100644 --- a/tests/hermes_cli/test_codex_models.py +++ b/tests/hermes_cli/test_codex_models.py @@ -35,6 +35,23 @@ def test_curated_codex_fallback_excludes_chatgpt_rejected_pro_slugs(monkeypatch) assert CHATGPT_REJECTED_CODEX_PRO_SLUGS.isdisjoint(model_ids) +def test_picker_synthesizes_900k_variants_for_verified_slugs(): + """Every live-verified large-context slug gets an explicit ``-900k`` + picker variant directly after its base entry; slugs that genuinely + enforce 272K (gpt-5.5, gpt-5.4-mini) never get one. Base slugs stay + in the list as the cheaper 272K default.""" + model_ids = get_codex_model_ids() # offline curated path + + for base in ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.4"): + assert base in model_ids + assert f"{base}-900k" in model_ids + assert model_ids.index(f"{base}-900k") == model_ids.index(base) + 1 + + assert "gpt-5.5-900k" not in model_ids + assert "gpt-5.4-mini-900k" not in model_ids + assert "gpt-5.3-codex-900k" not in model_ids + + def test_setup_wizard_codex_import_resolves(): diff --git a/website/docs/developer-guide/context-compression-and-caching.md b/website/docs/developer-guide/context-compression-and-caching.md index 31e3c9a0cb75..08f2854c01fa 100644 --- a/website/docs/developer-guide/context-compression-and-caching.md +++ b/website/docs/developer-guide/context-compression-and-caching.md @@ -192,6 +192,22 @@ To keep the 85% autoraise but hide only the one-time notice: hermes config set compression.codex_gpt55_autoraise_notice false ``` +### Codex large-context `-900k` picker variants (opt-in) + +The ChatGPT Codex backend *advertises* a 272K window for the gpt-5.4 and +gpt-5.6 (Sol/Terra/Luna) families, but actually accepts ~911K input tokens +for ChatGPT-subscription accounts (live-verified Aug 2026). Hermes keeps the +**advertised 272K as the default** for the base slugs — a bigger window means +more tokens per request and much faster subscription-usage burn, so the large +window is strictly opt-in. + +To use the large window, pick the explicit `-900k` variant in `/model` (e.g. +`gpt-5.6-sol-900k`, `gpt-5.6-terra-900k`, `gpt-5.6-luna-900k`, +`gpt-5.4-900k`). These are Hermes-side aliases: the suffix is stripped before +the model id is sent to the backend, and pricing/usage accounting treats them +as the base model. Slugs that genuinely enforce 272K (gpt-5.5, gpt-5.4-mini) +have no `-900k` variant. + ### Codex app-server thread compaction Codex app-server sessions (`api_mode: codex_app_server` — the codex CLI/agent From 7b89e177745fab390d2e7bdbf5e231bf164be24f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:02:31 -0700 Subject: [PATCH 071/666] fix(model): single exact eligibility predicate for -900k variants; reject ineligible aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on #92797 (@100yenadmin): - is_codex_900k_base() is now the single source of truth used by picker synthesis, context resolution, /model validation, and wire stripping. Eligibility is an exact table (sol/terra/luna, gpt-5.4, daybreak alias) plus date-shaped 5.6 snapshots — family-prefix matching removed, so non-routable -pro slugs and unknown descendants never gain variants. - strip_codex_context_variant_suffix() strips conditionally: ineligible aliases (gpt-5.5-900k) are returned unchanged and fail honestly at the API instead of silently running as the base model at 272K. - validate_requested_model() rejects ineligible *-900k aliases before the hidden-slug soft-accept, and accepts valid variants missing from a stale catalog without letting the typo auto-corrector eat the suffix. - Codex context resolver drops vendor/ namespaces, so openai/gpt-5.6-sol-900k resolves to 900K like the bare id. - Table-driven regression covering eligible bases/snapshots/namespaced ids and rejected -pro/-mini/5.5/unknown aliases, asserting context AND wire model. --- agent/model_metadata.py | 117 ++++++++++++++++------ hermes_cli/models.py | 39 ++++++++ tests/agent/test_model_metadata.py | 58 +++++++++++ tests/hermes_cli/test_codex_models.py | 12 +++ tests/hermes_cli/test_model_validation.py | 30 ++++++ 5 files changed, 223 insertions(+), 33 deletions(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 51643ed5caf8..ba240171a9ef 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -2451,66 +2451,114 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> # large window. Never sent on the wire. CODEX_CONTEXT_VARIANT_SUFFIX = "-900k" +# The ONLY base slugs eligible for a ``-900k`` variant: routable, +# live-verified models. gpt-5.6 family-prefix matching is deliberately NOT +# used here — it would synthesize dead variants for ``-pro`` slugs (the +# Codex backend 400s them) and accept arbitrary future descendants that +# were never probed. Dated snapshots of the routable 5.6 bases are allowed +# via _CODEX_900K_SNAPSHOT_RE. +_CODEX_900K_ELIGIBLE_BASES = frozenset({ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.4", # exact; gpt-5.4-mini enforces 272K + "gpt-daybreak-blue-latest", # verified Sol alias +}) +_CODEX_900K_SNAPSHOT_BASES = ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna") +_CODEX_900K_SNAPSHOT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _bare_codex_slug(model: Optional[str]) -> str: + """Lowercased slug with any ``vendor/`` namespace removed. + + Display/auxiliary callers pass ids like ``openai/gpt-5.6-sol-900k``; + the main-agent path normalizes the namespace away earlier, but this + resolver must accept both shapes (#92797 review). + """ + return (model or "").strip().lower().rsplit("/", 1)[-1] + + +def is_codex_900k_base(model: Optional[str]) -> bool: + """True when *model* (a BASE slug, no suffix) may carry a ``-900k`` variant. + + Single source of truth for the eligibility check — used by picker + synthesis, context resolution, `/model` validation, and wire stripping + so the four sites can never drift apart. + """ + slug = _bare_codex_slug(model) + if not slug or slug.endswith(CODEX_CONTEXT_VARIANT_SUFFIX): + return False + if slug in _CODEX_900K_ELIGIBLE_BASES: + return True + # Dated snapshots of the routable 5.6 bases (gpt-5.6-sol-2026-07-09). + for base in _CODEX_900K_SNAPSHOT_BASES: + if slug.startswith(base + "-") and _CODEX_900K_SNAPSHOT_RE.match( + slug[len(base) + 1:] + ): + return True + return False + def is_codex_context_variant(model: Optional[str]) -> bool: - """True when the model id carries the Hermes ``-900k`` opt-in suffix.""" - return (model or "").strip().lower().endswith(CODEX_CONTEXT_VARIANT_SUFFIX) + """True when the model id is a VALID ``-900k`` opt-in variant. + + Requires both the suffix and an eligible base — ``gpt-5.5-900k`` is not + a variant, it's an invalid alias. + """ + slug = _bare_codex_slug(model) + if not slug.endswith(CODEX_CONTEXT_VARIANT_SUFFIX): + return False + return is_codex_900k_base(slug[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)]) def strip_codex_context_variant_suffix(model: Optional[str]) -> str: - """Return the wire-safe slug with any ``-900k`` opt-in suffix removed. + """Return the wire-safe slug with a VALID ``-900k`` suffix removed. The suffix is a Hermes picker alias (``gpt-5.6-sol-900k``); the Codex - backend only knows the base slug. Case-insensitive; non-variant ids are - returned unchanged. + backend only knows the base slug. Stripping is conditional on base + eligibility: an ineligible alias like ``gpt-5.5-900k`` is returned + unchanged so it fails honestly at the API instead of silently running + as a different model. Case-insensitive; preserves any ``vendor/`` + namespace prefix. """ raw = (model or "").strip() - if raw.lower().endswith(CODEX_CONTEXT_VARIANT_SUFFIX): - return raw[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)] + if not raw.lower().endswith(CODEX_CONTEXT_VARIANT_SUFFIX): + return raw + base = raw[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)] + if is_codex_900k_base(base): + return base return raw def has_codex_context_variant(model_bare: str) -> bool: - """True when a Codex BASE slug has a live-verified ``-900k`` variant. + """True when a Codex BASE slug should get a synthetic ``-900k`` entry. - Used by the model pickers to decide which base slugs get a synthetic - ``-900k`` entry. Exact table first, then family prefixes — - mirrors ``_verified_codex_ctx_for_slug`` minus the suffix requirement. + Thin alias over :func:`is_codex_900k_base` kept for the picker call + sites' readability. """ - slug = (model_bare or "").strip().lower() - if not slug or slug.endswith(CODEX_CONTEXT_VARIANT_SUFFIX): - return False - if slug in _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT: - return True - for key in _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_PREFIXES: - if slug == key or slug.startswith(key + "-") or slug.startswith(key + "."): - return True - return False + return is_codex_900k_base(model_bare) def _verified_codex_ctx_for_slug(model_bare: str) -> Optional[int]: """Return the live-verified Codex cap for an OPTED-IN slug, or ``None``. - The large window is opt-in: only ``-900k``-suffixed picker variants + The large window is opt-in: only VALID ``-900k`` picker variants (e.g. ``gpt-5.6-sol-900k``) resolve to the verified cap. Base slugs keep the advertised 272K so the cheaper default limit applies unless - the user explicitly selects the large-context variant. - - After stripping the suffix: exact slugs first, then family prefixes - (````, ``-``, ``.``) so dated snapshots of a verified - family inherit the bump. + the user explicitly selects the large-context variant; ineligible + aliases (``gpt-5.5-900k``) never resolve here. """ - slug = (model_bare or "").strip().lower() + slug = _bare_codex_slug(model_bare) if not slug.endswith(CODEX_CONTEXT_VARIANT_SUFFIX): return None - slug = slug[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)] - if not slug: + base = slug[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)] + if not is_codex_900k_base(base): return None - exact = _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT.get(slug) + exact = _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT.get(base) if exact is not None: return exact for key, ctx in _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_PREFIXES.items(): - if slug == key or slug.startswith(key + "-") or slug.startswith(key + "."): + if base == key or base.startswith(key + "-") or base.startswith(key + "."): return ctx return None @@ -2660,8 +2708,11 @@ def _apply_verified_bump(ctx: int, source: str) -> Tuple[int, str]: return ctx, source # ``-900k`` variants are Hermes picker aliases — the Codex catalog only - # knows the base slug, so resolve against the stripped id. - lookup_bare = strip_codex_context_variant_suffix(model_bare) + # knows the base slug, so resolve against the stripped id. Also drop any + # ``vendor/`` namespace (``openai/gpt-5.6-sol-900k``): the main-agent + # path normalizes it away before reaching here, but display/auxiliary + # callers pass it through (#92797 review). + lookup_bare = _bare_codex_slug(strip_codex_context_variant_suffix(model_bare)) if access_token: live, fresh_probe = _fetch_codex_oauth_context_lengths_with_source(access_token) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index f638be4419d5..b939780b4e2b 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -6558,6 +6558,45 @@ def validate_requested_model( catalog_models = provider_model_ids(normalized) except Exception: catalog_models = [] + # Ineligible ``-900k`` aliases (e.g. `gpt-5.5-900k`) must be rejected + # BEFORE the hidden-slug soft-accept below: the suffix is a Hermes + # picker convention, so an unknown `*-900k` name can never be a real + # hidden provider slug — soft-accepting one silently runs at 272K on + # a different model than the user thinks (#92797 review). + if normalized == "openai-codex": + from agent.model_metadata import ( + CODEX_CONTEXT_VARIANT_SUFFIX, + is_codex_context_variant, + ) + _req_lower = requested_for_lookup.strip().lower() + if ( + _req_lower.endswith(CODEX_CONTEXT_VARIANT_SUFFIX) + and requested_for_lookup not in set(catalog_models) + ): + if is_codex_context_variant(requested_for_lookup): + # Valid variant that a stale catalog hasn't synthesized + # yet. Accept it directly — falling through would let the + # typo auto-corrector "fix" it to the base slug and + # silently drop the large-context opt-in. + return { + "accepted": True, + "persist": True, + "recognized": True, + "message": None, + } + _base_guess = requested_for_lookup[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)] + return { + "accepted": False, + "persist": False, + "recognized": False, + "message": ( + f"`{requested}` is not a valid large-context variant — " + f"`{_base_guess}` enforces the standard 272K window on " + f"Codex, so no `-900k` option exists for it. Pick the " + f"base model, or a verified variant from the `/model` " + f"picker (e.g. `gpt-5.6-sol-900k`)." + ), + } if catalog_models: if requested_for_lookup in set(catalog_models): return { diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index a0f3b1fb6b9c..3a91b968bca5 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -666,6 +666,64 @@ def test_fallback_table_base_slug_stays_272k(self, slug): ) assert ctx == 272_000 + # Table-driven eligibility contract (#92797 review): one predicate + # (is_codex_900k_base) drives picker synthesis, context resolution, + # validation, and wire stripping — this table pins all of them. + # (model_id, is_valid_variant, expected_ctx, expected_wire_model) + _900K_TABLE = [ + ("gpt-5.6-sol-900k", True, 900_000, "gpt-5.6-sol"), + ("gpt-5.6-terra-900k", True, 900_000, "gpt-5.6-terra"), + ("gpt-5.6-luna-900k", True, 900_000, "gpt-5.6-luna"), + ("gpt-5.4-900k", True, 900_000, "gpt-5.4"), + ("gpt-daybreak-blue-latest-900k", True, 900_000, "gpt-daybreak-blue-latest"), + # dated snapshot of a routable 5.6 base + ("gpt-5.6-sol-2026-07-09-900k", True, 900_000, "gpt-5.6-sol-2026-07-09"), + # vendor-namespaced variant (display/aux callers) resolves too + ("openai/gpt-5.6-sol-900k", True, 900_000, "openai/gpt-5.6-sol"), + # -pro slugs are not routable on Codex OAuth: never a valid variant, + # never stripped (fails honestly at the API instead) + ("gpt-5.6-sol-pro-900k", False, 272_000, "gpt-5.6-sol-pro-900k"), + # genuine 272K enforcers get no variant + ("gpt-5.5-900k", False, 272_000, "gpt-5.5-900k"), + ("gpt-5.4-mini-900k", False, 272_000, "gpt-5.4-mini-900k"), + # arbitrary future family descendants are not auto-eligible + ("gpt-5.6-nova-900k", False, 272_000, "gpt-5.6-nova-900k"), + ] + + @pytest.mark.parametrize("model_id,valid,expected_ctx,wire", _900K_TABLE) + def test_900k_eligibility_table(self, model_id, valid, expected_ctx, wire): + from agent.model_metadata import ( + get_model_context_length, + is_codex_context_variant, + strip_codex_context_variant_suffix, + ) + + assert is_codex_context_variant(model_id) is valid + assert strip_codex_context_variant_suffix(model_id) == wire + + bare = model_id.rsplit("/", 1)[-1] + catalog_slug = strip_codex_context_variant_suffix(bare) + if catalog_slug.endswith("-900k"): + # invalid alias — catalog advertises the underlying family slug + catalog_slug = catalog_slug[: -len("-900k")] + fake_response = MagicMock() + fake_response.status_code = 200 + fake_response.json.return_value = { + "models": [{"slug": catalog_slug, "context_window": 272_000}] + } + import agent.model_metadata as mm + mm._codex_oauth_context_cache = {} + with patch("agent.model_metadata.requests.get", return_value=fake_response), \ + patch("agent.model_metadata.get_cached_context_length", return_value=None), \ + patch("agent.model_metadata.save_context_length"): + ctx = get_model_context_length( + model=model_id, + base_url="https://chatgpt.com/backend-api/codex", + api_key="fake-token", + provider="openai-codex", + ) + assert ctx == expected_ctx + diff --git a/tests/hermes_cli/test_codex_models.py b/tests/hermes_cli/test_codex_models.py index be27a4e62a90..50a5d51d64c8 100644 --- a/tests/hermes_cli/test_codex_models.py +++ b/tests/hermes_cli/test_codex_models.py @@ -52,6 +52,18 @@ def test_picker_synthesizes_900k_variants_for_verified_slugs(): assert "gpt-5.3-codex-900k" not in model_ids +def test_picker_never_synthesizes_900k_for_pro_or_unknown_slugs(): + """Eligibility is an exact predicate, not a family-prefix match: + ``-pro`` slugs are not routable on Codex OAuth (backend 400s them) and + unknown future descendants were never probed — neither may gain a + synthetic ``-900k`` entry (#92797 review).""" + from hermes_cli.codex_models import _finalize_codex_models + + out = _finalize_codex_models(["gpt-5.6-sol-pro", "gpt-5.6-nova"]) + assert "gpt-5.6-sol-pro-900k" not in out + assert "gpt-5.6-nova-900k" not in out + + def test_setup_wizard_codex_import_resolves(): diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index aa3bc4c3a2b9..966eae73e7c4 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -1,5 +1,6 @@ """Tests for provider-aware `/model` validation in hermes_cli.models.""" +import pytest from unittest.mock import MagicMock, patch from hermes_cli.models import ( @@ -504,6 +505,35 @@ def test_exact_match_no_correction(self): assert result["message"] is None +class TestValidateCodex900kVariants: + """`-900k` is a Hermes picker convention: valid variants come from the + catalog; ineligible aliases are hard-rejected BEFORE the hidden-slug + soft-accept (#92797 review).""" + + _CATALOG = ["gpt-5.6-sol", "gpt-5.6-sol-900k", "gpt-5.5", "gpt-5.4-mini"] + + def test_catalog_listed_variant_accepted(self): + with patch("hermes_cli.models.provider_model_ids", return_value=self._CATALOG): + result = validate_requested_model("gpt-5.6-sol-900k", "openai-codex") + assert result["accepted"] is True + assert result["recognized"] is True + + @pytest.mark.parametrize("alias", ["gpt-5.5-900k", "gpt-5.4-mini-900k", "gpt-5.6-sol-pro-900k"]) + def test_ineligible_900k_alias_rejected_not_soft_accepted(self, alias): + with patch("hermes_cli.models.provider_model_ids", return_value=self._CATALOG): + result = validate_requested_model(alias, "openai-codex") + assert result["accepted"] is False + assert result["persist"] is False + assert "272K" in result["message"] + + def test_valid_variant_missing_from_catalog_still_accepted(self): + """A verified variant not yet in the (possibly stale) catalog is + accepted via the eligibility predicate, not the soft-accept.""" + with patch("hermes_cli.models.provider_model_ids", return_value=["gpt-5.6-sol"]): + result = validate_requested_model("gpt-5.6-sol-900k", "openai-codex") + assert result["accepted"] is True + + # -- probe_api_models — Cloudflare UA mitigation -------------------------------- class TestProbeApiModelsUserAgent: From d3e087fd8c2441577bac614c755ec46a1c4e7ee8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:43:28 -0700 Subject: [PATCH 072/666] feat(bot-mode): bots on every Desktop connection can message each other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connections ARE the peer set: every gateway connected to the Desktop (local, remote URL, SSH, Hermes Cloud, docker) is now message_agent- reachable. The Desktop relays over the persistent sockets it already holds — roster sync per connection, envelope drain/deliver/reply loops — so cross-connection DMs work exactly like local ones, replies included. Also fixes the legacy-SOUL gate bug: profiles whose SOUL.md carries the old plugin-appended protocol silently lost the message_agent tool because the injection/execution gates keyed on protocol-section non-emptiness instead of managed-install. --- .../desktop/src/plugins/hermes-bots/plugin.js | 230 +++++++++++- .../hermes-bots/tests/bot-relay.test.mjs | 55 +++ tests/tools/test_bot_relay.py | 290 +++++++++++++++ tests/tui_gateway/test_bot_relay_methods.py | 107 ++++++ tools/bot_mode_dm.py | 102 +++++- tools/bot_mode_probe.py | 63 ++++ tools/bot_relay.py | 339 ++++++++++++++++++ tui_gateway/methods_bot_relay.py | 168 +++++++++ tui_gateway/server.py | 10 + website/docs/user-guide/bot-mode.md | 12 +- 10 files changed, 1360 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/bot-relay.test.mjs create mode 100644 tests/tools/test_bot_relay.py create mode 100644 tests/tui_gateway/test_bot_relay_methods.py create mode 100644 tools/bot_relay.py create mode 100644 tui_gateway/methods_bot_relay.py diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 68c9f3c2b9aa..7b6b6690a596 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -1190,6 +1190,222 @@ function handleSessionsGatewayTransition() { .then(() => scheduleGroupChatServerSync($groupChats.get())) } +// ── cross-connection bot relay ──────────────────────────────────────────── +// Connections ARE the peer set: every gateway this Desktop holds a socket +// to (local, remote URL, SSH, Hermes Cloud, docker) must be able to find +// every other connection's agents and message them via message_agent. The +// Desktop is the relay — it owns every socket. Two loops: +// - roster loop: pushes each gateway the union roster of agents on the +// OTHER connections (bot_relay.roster.sync), so message_agent resolves +// cross-connection targets and Bot Chat prompts list them; +// - drain loop: collects queued envelopes from every gateway +// (bot_relay.outbox.drain), delivers each on the target connection's +// own socket (bot_relay.deliver), and posts the reply back to the +// sender gateway (bot_relay.reply) where a waiter wakes the sender. +// Older backends without the RPCs fail per-call and are skipped — the +// relay degrades to whatever subset of connections supports it. +const RELAY_ROSTER_INTERVAL_MS = 60_000 +const RELAY_DRAIN_INTERVAL_MS = 4_000 +let relayDisposed = false +let relayRosterTimer = null +let relayDrainTimer = null +let relayRosterBusy = false +let relayDrainBusy = false + +/** One representative route per reachable connection id. */ +async function relayConnections() { + if (typeof host.profileRoutes !== 'function' || typeof host.requestProfile !== 'function') { + return [] + } + + try { + const routes = await host.profileRoutes() + const byConnection = new Map() + + for (const route of Array.isArray(routes) ? routes : []) { + const id = String(route?.connectionId || '') + + if (id && !byConnection.has(id)) { + byConnection.set(id, route) + } + } + + return [...byConnection.entries()].map(([id, route]) => ({ id, route })) + } catch { + return [] + } +} + +/** The agents living on one connection, as relay roster rows. */ +async function relayAgentsOn(connection) { + try { + const res = await host.requestProfile(connection.route, 'profiles.list', { include_sessions: false }) + const profiles = Array.isArray(res?.profiles) ? res.profiles : [] + const label = String( + connection.route?.connectionLabel || connection.route?.label || connection.id + ) + + return profiles + .map(profile => ({ + profile: String(profile?.name || ''), + handle: botHandle(profile?.name, profile), + connection_id: connection.id, + connection_label: label, + title: String(profile?.ui_meta?.['hermes-bots']?.title || profile?.display_name || ''), + description: String(profile?.description || '') + })) + .filter(row => row.profile) + } catch { + return [] + } +} + +/** Push every gateway the union roster of agents on the OTHER connections. */ +async function syncRelayRosters() { + if (relayDisposed || relayRosterBusy) { + return + } + + relayRosterBusy = true + + try { + const connections = await relayConnections() + + if (connections.length < 2) { + return + } + + const agentsByConnection = new Map() + await Promise.all( + connections.map(async connection => { + agentsByConnection.set(connection.id, await relayAgentsOn(connection)) + }) + ) + + await Promise.all( + connections.map(async connection => { + const others = [] + + for (const [id, agents] of agentsByConnection) { + if (id !== connection.id) { + others.push(...agents) + } + } + + try { + await host.requestProfile(connection.route, 'bot_relay.roster.sync', { agents: others }) + } catch { + // Older backend without the relay RPCs — skip this connection. + } + }) + ) + } finally { + relayRosterBusy = false + } +} + +/** Drain every gateway's outbox and deliver each envelope on the target + * connection's own socket; the reply (or error) is posted back to the + * sender gateway for its waiter. */ +async function drainRelayOutboxes() { + if (relayDisposed || relayDrainBusy) { + return + } + + relayDrainBusy = true + + try { + const connections = await relayConnections() + + if (connections.length < 2) { + return + } + + const byId = new Map(connections.map(connection => [connection.id, connection])) + + for (const sender of connections) { + let envelopes = [] + + try { + const res = await host.requestProfile(sender.route, 'bot_relay.outbox.drain', {}) + envelopes = Array.isArray(res?.envelopes) ? res.envelopes : [] + } catch { + continue + } + + for (const envelope of envelopes) { + if (relayDisposed) { + return + } + + const envelopeId = String(envelope?.id || '') + const target = byId.get(String(envelope?.target_connection || '')) + const postReply = async payload => { + try { + await host.requestProfile(sender.route, 'bot_relay.reply', { id: envelopeId, ...payload }) + } catch { + // Sender gateway unreachable — its waiter times out with guidance. + } + } + + if (!envelopeId) { + continue + } + + if (!target) { + await postReply({ error: `connection '${envelope?.target_connection}' is not connected to this Desktop right now` }) + continue + } + + try { + const res = await host.requestProfile(target.route, 'bot_relay.deliver', { + profile: String(envelope?.target_profile || ''), + message: String(envelope?.message || '') + }) + await postReply({ reply: String(res?.reply || '') }) + } catch (error) { + await postReply({ error: String(error?.message || error || 'delivery failed') }) + } + } + } + } finally { + relayDrainBusy = false + } +} + +function startBotRelay() { + relayDisposed = false + + // Source-shape test harnesses evaluate plugin.js without DOM timers — + // the relay only runs where a real event loop exists. + if (typeof setInterval !== 'function' || typeof clearInterval !== 'function') { + return + } + + if (relayRosterTimer === null) { + relayRosterTimer = setInterval(() => void syncRelayRosters(), RELAY_ROSTER_INTERVAL_MS) + void syncRelayRosters() + } + + if (relayDrainTimer === null) { + relayDrainTimer = setInterval(() => void drainRelayOutboxes(), RELAY_DRAIN_INTERVAL_MS) + } +} + +function stopBotRelay() { + relayDisposed = true + + if (relayRosterTimer !== null) { + clearInterval(relayRosterTimer) + relayRosterTimer = null + } + + if (relayDrainTimer !== null) { + clearInterval(relayDrainTimer) + relayDrainTimer = null + } +} + /** Per-bot appearance + display meta, persisted via ctx.storage: * { [botName]: { shape, color, title } } */ const $botMeta = atom({}) @@ -11954,10 +12170,14 @@ export default { pluginCtx = ctx groupChatSyncDisposed = false startFaceClock() + // The cross-connection relay rides every gateway socket this Desktop + // holds: roster sync + envelope drain/deliver/reply loops. + startBotRelay() // Disabling the plugin (or a hot reload) must actually stop the clock — // before this, the rAF loop + 1Hz document scan ran until app restart. if (typeof ctx.onDispose === 'function') { ctx.onDispose(stopFaceClock) + ctx.onDispose(stopBotRelay) } // @-mention autocomplete: typing "@rese…" in ANY composer offers the @@ -12306,20 +12526,22 @@ export default { // Identification only. Each line names the agent the user's tag // resolves to (friendly title + device for cross-connection rows), - // so the agent knows exactly who "@research-buddy" is without the - // renderer ever acting on the user's behalf. + // so the agent knows exactly who "@research-buddy" is. Cross- + // connection targets carry the '@connection' suffix message_agent + // resolves against the Desktop-synced relay roster. const lines = mentionedBots.map(bot => { const handle = botHandle(bot.name, bot) const title = String(botRosterMeta(bot, $botMeta.get())?.title || bot.ui_meta?.['hermes-bots']?.title || bot.title || '').trim() + const target = bot.remoteSource && bot.connectionId ? `${handle}@${bot.connectionId}` : handle const where = bot.remoteSource - ? ` — on ${bot.connectionLabel || bot.connectionId}` + ? ` — on ${bot.connectionLabel || bot.connectionId} (message_agent target: "${target}")` : '' return `@${handle} = agent profile "${bot.name}"${title ? ` ("${title}")` : ''}${where}` }) const note = '\n\n[@mentions resolved from the Bot Mode roster — the user is referring to: ' + lines.join('; ') + - '. If they want one of these agents contacted, compose your own message and send it with your message_agent tool; never forward the user\u2019s text verbatim. If this session has no message_agent tool, agent messaging is unavailable here — say so.]' + '. If they want one of these agents contacted, compose your own message and send it with your message_agent tool (agents on other connected machines are reachable too — the Desktop relays it); never forward the user\u2019s text verbatim. If this session has no message_agent tool, agent messaging is unavailable here — say so.]' return { ...draft, text: text + note } } } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bot-relay.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bot-relay.test.mjs new file mode 100644 index 000000000000..86fb0d46eefb --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/bot-relay.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +// The cross-connection bot relay (Aug 2026 ruling: connections ARE the peer +// set). Source-shape contracts: +// - both relay loops exist, start in register(), and stop via ctx.onDispose; +// - the roster loop pushes bot_relay.roster.sync with agents from OTHER +// connections only; +// - the drain loop wires drain → deliver → reply, and posts an error reply +// when the target connection is gone (waiter must never dangle); +// - the remote-row toast no longer tells users messaging is device-local; +// - the middleware note carries the message_agent target for cross- +// connection rows instead of implying they're unreachable. + +const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +test('relay loops start in register() and stop on dispose', () => { + assert.match(pluginSource, /startBotRelay\(\)/) + assert.match(pluginSource, /ctx\.onDispose\(stopBotRelay\)/) + // teardown really clears both timers + const stop = pluginSource.slice(pluginSource.indexOf('function stopBotRelay')) + assert.match(stop.slice(0, 500), /clearInterval\(relayRosterTimer\)/) + assert.match(stop.slice(0, 500), /clearInterval\(relayDrainTimer\)/) +}) + +test('roster loop syncs OTHER connections agents to each gateway', () => { + const sync = pluginSource.slice( + pluginSource.indexOf('async function syncRelayRosters'), + pluginSource.indexOf('async function drainRelayOutboxes') + ) + assert.match(sync, /bot_relay\.roster\.sync/) + assert.match(sync, /id !== connection\.id/) +}) + +test('drain loop wires drain → deliver → reply with error fallback', () => { + const drain = pluginSource.slice( + pluginSource.indexOf('async function drainRelayOutboxes'), + pluginSource.indexOf('function startBotRelay') + ) + assert.match(drain, /bot_relay\.outbox\.drain/) + assert.match(drain, /bot_relay\.deliver/) + assert.match(drain, /bot_relay\.reply/) + // a missing target connection still posts a reply (error) for the waiter + assert.match(drain, /is not connected to this Desktop right now/) +}) + +test('remote-row dead-end toast is gone (rows open; relay carries DMs)', () => { + assert.doesNotMatch(pluginSource, /Gateway stays on this device/) +}) + +test('middleware note names the cross-connection message_agent target', () => { + assert.match(pluginSource, /message_agent target: "\$\{target\}"/) + assert.match(pluginSource, /agents on other connected machines are reachable too/) +}) diff --git a/tests/tools/test_bot_relay.py b/tests/tools/test_bot_relay.py new file mode 100644 index 000000000000..8692cd663666 --- /dev/null +++ b/tests/tools/test_bot_relay.py @@ -0,0 +1,290 @@ +"""Tests: cross-connection bot relay (tools/bot_relay.py + message_agent route). + +Connections ARE the peer set: every Desktop-connected gateway must be +message_agent-reachable. These tests pin the gateway-side plumbing — +roster validation, target resolution (incl. ambiguity), outbox claim +atomicity, reply write validation — and the two behavior contracts the +relay adds to message_agent: + +- a target resolving against the Desktop-synced relay roster is queued as + an envelope and acknowledged like any DM (fire-and-forget, waiter spawned); +- the legacy-SOUL dedupe (empty protocol section) NO LONGER strips the tool: + the injection/execution gates key on managed-install, not section text. +""" + +import json +import re +from pathlib import Path + +import pytest + +from tools import bot_relay +from tools.bot_mode_dm import ( + MESSAGE_AGENT_TOOL_NAME, + ensure_message_agent_tool, + message_agent_tool, +) + + +@pytest.fixture() +def root(tmp_path): + return tmp_path + + +def _rows(): + return [ + { + "profile": "default", + "handle": "hermes", + "connection_id": "cloud-1", + "connection_label": "Hermes Cloud", + "title": "Moxie", + "description": "Main cloud agent", + }, + { + "profile": "researcher", + "handle": "researcher", + "connection_id": "ssh-vps", + "connection_label": "VPS", + }, + ] + + +# ── roster ─────────────────────────────────────────────────────────────────── + + +def test_roster_roundtrip_and_validation(root): + rows = _rows() + [ + {"profile": "", "handle": "x", "connection_id": "c"}, # no profile + {"profile": "bad name!", "connection_id": "c"}, # bad charset + "not-a-dict", + {"profile": "default", "handle": "hermes", "connection_id": "cloud-1"}, # dupe + ] + count = bot_relay.write_remote_roster(root, rows) + assert count == 2 + back = bot_relay.read_remote_roster(root) + assert [r["profile"] for r in back] == ["default", "researcher"] + assert back[0]["title"] == "Moxie" + + +def test_roster_read_missing_and_corrupt(root): + assert bot_relay.read_remote_roster(root) == [] + base = bot_relay.relay_root(root) + base.mkdir(parents=True) + (base / bot_relay.ROSTER_FILE).write_text("{corrupt", encoding="utf-8") + assert bot_relay.read_remote_roster(root) == [] + + +def test_resolve_remote_target_forms(root): + bot_relay.write_remote_roster(root, _rows()) + roster = bot_relay.read_remote_roster(root) + assert bot_relay.resolve_remote_target("researcher", roster)["connection_id"] == "ssh-vps" + assert bot_relay.resolve_remote_target("@hermes", roster)["profile"] == "default" + # profile name resolves too + assert bot_relay.resolve_remote_target("default", roster)["connection_id"] == "cloud-1" + # exact connection-qualified form + assert bot_relay.resolve_remote_target("hermes@cloud-1", roster)["profile"] == "default" + assert bot_relay.resolve_remote_target("hermes@nope", roster) is None + assert bot_relay.resolve_remote_target("ghost", roster) is None + + +def test_resolve_ambiguous_handle_across_connections(root): + rows = _rows() + [ + {"profile": "researcher", "handle": "researcher", "connection_id": "cloud-1"} + ] + bot_relay.write_remote_roster(root, rows) + roster = bot_relay.read_remote_roster(root) + assert bot_relay.resolve_remote_target("researcher", roster) == "ambiguous" + match = bot_relay.resolve_remote_target("researcher@ssh-vps", roster) + assert match["connection_id"] == "ssh-vps" + forms = bot_relay.remote_target_forms(roster) + assert "researcher@ssh-vps" in forms and "researcher@cloud-1" in forms + assert "hermes" in forms # unique handle stays bare + + +# ── outbox / replies ───────────────────────────────────────────────────────── + + +def test_enqueue_claim_is_atomic_and_single_shot(root): + bot_relay.write_remote_roster(root, _rows()) + roster = bot_relay.read_remote_roster(root) + target = bot_relay.resolve_remote_target("researcher", roster) + env = bot_relay.enqueue_envelope( + root, target=target, message="hi", sender_profile="work", sender_handle="work" + ) + assert re.match(r"^[0-9a-f]{32}$", env["id"]) + claimed = bot_relay.claim_pending_envelopes(root) + assert [e["id"] for e in claimed] == [env["id"]] + assert claimed[0]["target_connection"] == "ssh-vps" + assert claimed[0]["message"] == "hi" + # second drain: nothing (no double delivery) + assert bot_relay.claim_pending_envelopes(root) == [] + + +def test_write_reply_validates_envelope_id(root): + with pytest.raises(ValueError): + bot_relay.write_reply(root, "../../etc/passwd", reply="x") + path = bot_relay.write_reply(root, "a" * 32, reply="pong") + data = json.loads(Path(path).read_text(encoding="utf-8")) + assert data["reply"] == "pong" and not data["error"] + + +def test_waiter_command_quotes_and_targets_reply_file(root): + env = {"id": "b" * 32, "target_handle": "researcher", "target_connection": "ssh-vps"} + cmd = bot_relay.waiter_command(root, env) + assert ("b" * 32) in cmd and "-c" in cmd + assert "rm -rf" not in cmd # sanity: single quoted -c payload + + +# ── message_agent integration: relay route + legacy-SOUL gate fix ─────────── + +import textwrap + + +def _managed_home(tmp_path, *, legacy_soul=False): + home = tmp_path / ".hermes" + home.mkdir(exist_ok=True) + d = home / "profiles" / "researcher" + d.mkdir(parents=True, exist_ok=True) + (d / "profile.yaml").write_text( + textwrap.dedent( + """\ + description: teammate for tests + ui_meta: + hermes-bots: + shape: cloud + """ + ), + encoding="utf-8", + ) + if legacy_soul: + (home / "SOUL.md").write_text( + "# Soul\n\n## Messaging other agents\nold shellout protocol\n", + encoding="utf-8", + ) + return home + + +class _FakeDB: + def __init__(self, home, title): + self.db_path = str(home / "state.db") + self._title = title + + def get_session_title(self, _sid): + return self._title + + +class _FakeAgent: + def __init__(self, home, title="Bot Chat"): + self._session_db = _FakeDB(home, title) + self.session_id = "sess-1" + self._session_title_hint = None + self._bot_mode_protocol = True + self.tools: list = [] + self.valid_tool_names: set = set() + + +@pytest.fixture(autouse=True) +def _fresh_probe_cache(): + from tools import bot_mode_probe + + bot_mode_probe._reset_cache_for_tests() + yield + bot_mode_probe._reset_cache_for_tests() + + +def test_tool_injects_despite_legacy_soul_protocol(tmp_path): + """The legacy-SOUL dedupe empties the SECTION, never the TOOL. + + Regression: upgraded installs whose SOUL.md still carries the old + plugin-appended protocol silently lost message_agent because the gate + keyed on section non-emptiness. + """ + from tools import bot_mode_probe + + home = _managed_home(tmp_path, legacy_soul=True) + # Premise: the dedupe really does empty the section for this profile... + assert bot_mode_probe.get_bot_mode_protocol_section(home) == "" + # ...but the install is managed, so the tool must still inject. + agent = _FakeAgent(home) + assert ensure_message_agent_tool(agent) is True + assert [t["function"]["name"] for t in agent.tools] == [MESSAGE_AGENT_TOOL_NAME] + + +def test_relay_route_queues_envelope_and_spawns_waiter(tmp_path, monkeypatch): + home = _managed_home(tmp_path) + bot_relay.write_remote_roster(home, [ + {"profile": "default", "handle": "hermes", "connection_id": "cloud-1", + "connection_label": "Hermes Cloud", "title": "Moxie"}, + ]) + + spawned = {} + + def _fake_spawn(command, label, *, task_id, agent): + spawned["command"] = command + spawned["label"] = label + return json.dumps({"status": "sent", "to": label}) + + monkeypatch.setattr("tools.bot_mode_dm._spawn_delivery", _fake_spawn) + agent = _FakeAgent(home) + out = json.loads(message_agent_tool(target="hermes", message="ping", agent=agent)) + assert out.get("status") == "sent" + assert "Hermes Cloud" in spawned["label"] + # envelope landed in the outbox with attribution prefixed + pending = bot_relay.claim_pending_envelopes(home) + assert len(pending) == 1 + assert pending[0]["target_connection"] == "cloud-1" + assert pending[0]["target_profile"] == "default" + assert pending[0]["message"].startswith("Message from 🤖 hermes (@hermes): ping") + # waiter watches this envelope's reply file + assert pending[0]["id"] in spawned["command"] + + +def test_relay_route_ambiguous_target_errors_with_forms(tmp_path, monkeypatch): + home = _managed_home(tmp_path) + bot_relay.write_remote_roster(home, [ + {"profile": "scout", "handle": "scout", "connection_id": "cloud-1"}, + {"profile": "scout", "handle": "scout", "connection_id": "ssh-vps"}, + ]) + monkeypatch.setattr( + "tools.bot_mode_dm._spawn_delivery", + lambda *a, **k: json.dumps({"status": "sent"}), + ) + agent = _FakeAgent(home) + out = json.loads(message_agent_tool(target="scout", message="hi", agent=agent)) + assert "scout@cloud-1" in out.get("error", "") and "scout@ssh-vps" in out["error"] + # connection-qualified form goes through + out2 = json.loads(message_agent_tool(target="scout@ssh-vps", message="hi", agent=agent)) + assert out2.get("status") == "sent" + + +def test_unknown_target_error_mentions_connected_machines(tmp_path): + home = _managed_home(tmp_path) + agent = _FakeAgent(home) + out = json.loads(message_agent_tool(target="ghost", message="hi", agent=agent)) + assert "connected machine" in out.get("error", "") + + +def test_protocol_section_lists_remote_teammates(tmp_path): + from tools import bot_mode_probe + + home = _managed_home(tmp_path) + bot_relay.write_remote_roster(home, [ + {"profile": "default", "handle": "hermes", "connection_id": "cloud-1", + "connection_label": "Hermes Cloud", "title": "Moxie"}, + ]) + section = bot_mode_probe.get_bot_mode_protocol_section(home, force_refresh=True) + assert "OTHER connected machines" in section + assert "`@hermes` — on Hermes Cloud — Moxie" in section + + +def test_capability_fingerprint_changes_with_relay_roster(tmp_path): + from tools import bot_mode_probe + + home = _managed_home(tmp_path) + before = bot_mode_probe.capability_fingerprint(home) + bot_relay.write_remote_roster(home, [ + {"profile": "default", "handle": "hermes", "connection_id": "cloud-1"}, + ]) + after = bot_mode_probe.capability_fingerprint(home) + assert before != after # eternal Bot Chats refresh once on roster change diff --git a/tests/tui_gateway/test_bot_relay_methods.py b/tests/tui_gateway/test_bot_relay_methods.py new file mode 100644 index 000000000000..747c031f1026 --- /dev/null +++ b/tests/tui_gateway/test_bot_relay_methods.py @@ -0,0 +1,107 @@ +"""Tests: bot_relay.* JSON-RPC handlers (tui_gateway/methods_bot_relay.py). + +The Desktop's relay door on each connected gateway. Contracts: +- roster.sync persists validated rows and reports the accepted count; +- outbox.drain returns queued envelopes exactly once; +- deliver validates the target profile against THIS install and runs the + one-turn Bot Chat transport (subprocess is faked here — the argv contract + is what's pinned); +- reply writes the waiter's file and rejects malformed envelope ids. +""" + +from __future__ import annotations + +import json + +import pytest + +import tui_gateway.server as srv +from tools import bot_relay + + +@pytest.fixture +def home(tmp_path, monkeypatch): + h = tmp_path / ".hermes" + (h / "profiles" / "ops").mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(h)) + return h + + +def _result(envelope): + assert "error" not in envelope, envelope + return envelope["result"] + + +def test_roster_sync_persists_and_counts(home): + out = _result( + srv._methods["bot_relay.roster.sync"]( + 1, + { + "agents": [ + {"profile": "scout", "handle": "scout", "connection_id": "cloud-1"}, + {"profile": "", "connection_id": "cloud-1"}, # dropped + ] + }, + ) + ) + assert out["count"] == 1 + assert [r["profile"] for r in bot_relay.read_remote_roster(home)] == ["scout"] + + +def test_outbox_drain_returns_each_envelope_once(home): + target = {"profile": "scout", "handle": "scout", "connection_id": "cloud-1", + "connection_label": "", "title": "", "description": ""} + env = bot_relay.enqueue_envelope( + home, target=target, message="m", sender_profile="default", sender_handle="hermes" + ) + first = _result(srv._methods["bot_relay.outbox.drain"](1, {})) + assert [e["id"] for e in first["envelopes"]] == [env["id"]] + second = _result(srv._methods["bot_relay.outbox.drain"](2, {})) + assert second["envelopes"] == [] + + +def test_deliver_validates_profile_and_runs_transport(home, monkeypatch): + calls = {} + + class _Proc: + returncode = 0 + stdout = "pong from ops" + stderr = "" + + def _fake_run(argv, **kwargs): + calls["argv"] = argv + return _Proc() + + monkeypatch.setattr("subprocess.run", _fake_run) + out = _result( + srv._methods["bot_relay.deliver"](1, {"profile": "ops", "message": "ping"}) + ) + assert out["reply"] == "pong from ops" + argv = calls["argv"] + assert argv[:3] == ["hermes", "-p", "ops"] + assert "Bot Chat" in argv and "--query-file" in argv + + # 'hermes' alias resolves to default + _result(srv._methods["bot_relay.deliver"](2, {"profile": "hermes", "message": "x"})) + assert calls["argv"][:3] == ["hermes", "-p", "default"] + + # unknown profile refuses without spawning + calls.clear() + err = srv._methods["bot_relay.deliver"](3, {"profile": "ghost", "message": "x"}) + assert "error" in err and "ghost" in err["error"]["message"] + assert not calls + + +def test_deliver_requires_params(home): + err = srv._methods["bot_relay.deliver"](1, {"profile": "", "message": ""}) + assert "error" in err + + +def test_reply_roundtrip_and_id_validation(home): + envelope_id = "c" * 32 + _result(srv._methods["bot_relay.reply"](1, {"id": envelope_id, "reply": "hi"})) + path = bot_relay.relay_root(home) / bot_relay.REPLIES_DIR / f"{envelope_id}.json" + assert json.loads(path.read_text(encoding="utf-8"))["reply"] == "hi" + + err = srv._methods["bot_relay.reply"](2, {"id": "../evil"}) + assert "error" in err diff --git a/tools/bot_mode_dm.py b/tools/bot_mode_dm.py index c286984c5f14..25785a98a7d8 100644 --- a/tools/bot_mode_dm.py +++ b/tools/bot_mode_dm.py @@ -84,9 +84,11 @@ def message_agent_tool_schema() -> dict: "clearly relevant teammate when it genuinely helps the user's goal; " "don't fan out to several agents unless the user explicitly asked. " "Use the teammate roster in your system prompt (names + roles) to pick " - "the right recipient; targets: a teammate name (e.g. 'researcher'), or " + "the right recipient; targets: a teammate name (e.g. 'researcher'), " "'/' for an agent on a registered peer gateway " - "(e.g. 'spark/researcher', or just '' for the peer's main agent)." + "(e.g. 'spark/researcher', or just '' for the peer's main agent), " + "or an agent on another connected machine from your roster (use " + "'@' if the same handle exists on several)." ), "parameters": { "type": "object", @@ -135,11 +137,15 @@ def ensure_message_agent_tool(agent: Any) -> bool: and tool.get("function", {}).get("name") == MESSAGE_AGENT_TOOL_NAME ): return True - from tools.bot_mode_probe import BOT_CHAT_TITLE, get_bot_mode_protocol_section + from tools.bot_mode_probe import BOT_CHAT_TITLE, is_bot_mode_managed if _session_title(agent) != BOT_CHAT_TITLE: return False - if not get_bot_mode_protocol_section(_agent_home(agent)): + # Managed-install check, NOT section non-emptiness: a profile whose + # SOUL.md carries the legacy plugin-appended protocol text gets an + # empty section (dedupe) but must still receive the tool — otherwise + # upgraded installs silently lose A2A messaging (Aug 2026). + if not is_bot_mode_managed(_agent_home(agent)): return False if agent.tools is None: agent.tools = [] @@ -235,7 +241,7 @@ def message_agent_tool( # ── defense-in-depth gate: only a canonical Bot Chat may deliver ── home = _agent_home(agent) try: - from tools.bot_mode_probe import BOT_CHAT_TITLE, get_bot_mode_protocol_section + from tools.bot_mode_probe import BOT_CHAT_TITLE, is_bot_mode_managed title = _session_title(agent) if title != BOT_CHAT_TITLE: @@ -243,7 +249,7 @@ def message_agent_tool( "message_agent is only available in a Bot Mode 'Bot Chat' session. " "This session is not one; do not retry." ) - if not get_bot_mode_protocol_section(home): + if not is_bot_mode_managed(home): return _err( "This install is not Bot-Mode-managed (no bot roster); " "message_agent is unavailable. Do not retry." @@ -289,17 +295,35 @@ def message_agent_tool( return _spawn_delivery(command, label, task_id=task_id, agent=agent) # ── local teammate ── - if not _LOCAL_TARGET_RE.match(raw_target): + if not _LOCAL_TARGET_RE.match(raw_target) and "@" not in raw_target: return _err(f"Invalid target: {raw_target!r}.", roster=teammates, peers=peers) - resolved = _resolve_local_name(raw_target, roster) + resolved = _resolve_local_name(raw_target, roster) if _LOCAL_TARGET_RE.match(raw_target) else None if resolved is None: + # ── cross-connection teammate (Desktop relay) ── + # Every gateway connected to the user's Desktop is reachable: the + # relay roster lists agents on the other connections; delivery rides + # the Desktop's own persistent socket to that gateway. + relayed = _try_relay_delivery( + root, raw_target, body, me, sender_handle, task_id=task_id, agent=agent + ) + if relayed is not None: + return relayed return _err( - f"No teammate named '{raw_target}' on this install. " - "Pick a name from the roster (roles are listed in your system prompt).", + f"No teammate named '{raw_target}' on this install, on a connected " + "machine, or on a registered peer. Pick a name from the roster " + "(roles are listed in your system prompt).", roster=teammates, peers=peers, ) if resolved == me: + # Same-name target on ANOTHER connection (e.g. this gateway's + # 'default' messaging the cloud 'default') — try the relay before + # calling it a self-message. + relayed = _try_relay_delivery( + root, raw_target, body, me, sender_handle, task_id=task_id, agent=agent + ) + if relayed is not None: + return relayed return _err("You can't message yourself. Pick a teammate from the roster.") dm_file = _write_dm_file(prefix + body) @@ -310,6 +334,64 @@ def message_agent_tool( return _spawn_delivery(command, f"@{_handle(resolved)}", task_id=task_id, agent=agent) +def _try_relay_delivery( + root: Path, + raw_target: str, + body: str, + me: str, + sender_handle: str, + *, + task_id: Optional[str], + agent: Any, +) -> Optional[str]: + """Cross-connection delivery via the Desktop relay, or None if the + target doesn't resolve against the relay roster. + + The envelope is queued on disk; the Desktop drains it over RPC and + delivers on the target connection's own socket. A background waiter is + spawned immediately so the relayed reply wakes the sender through the + standard completion-notification path — identical UX to a local DM. + """ + try: + from tools.bot_relay import ( + enqueue_envelope, + read_remote_roster, + resolve_remote_target, + waiter_command, + ) + + roster = read_remote_roster(root) + if not roster: + return None + match = resolve_remote_target(raw_target, roster) + if match is None: + return None + if match == "ambiguous": + forms = ", ".join( + f"{r['handle']}@{r['connection_id']}" + for r in roster + if r["handle"].lower() == raw_target.strip().lstrip("@").lower() + ) + return _err( + f"'{raw_target}' exists on several connected machines — " + f"disambiguate with one of: {forms}." + ) + envelope = enqueue_envelope( + root, + target=match, + message=f"Message from 🤖 {sender_handle} (@{sender_handle}): {body}", + sender_profile=me, + sender_handle=sender_handle, + ) + label = f"@{match['handle']} on {match['connection_label'] or match['connection_id']}" + return _spawn_delivery( + waiter_command(root, envelope), label, task_id=task_id, agent=agent + ) + except Exception: + logger.debug("relay delivery attempt failed", exc_info=True) + return None + + def _write_dm_file(content: str) -> str: """The message rides a temp file — never inline shell text.""" fd, path = tempfile.mkstemp(prefix="hermes-dm-", suffix=".txt", text=True) diff --git a/tools/bot_mode_probe.py b/tools/bot_mode_probe.py index 66935f8c7eb4..ca92c6f26a41 100644 --- a/tools/bot_mode_probe.py +++ b/tools/bot_mode_probe.py @@ -92,6 +92,24 @@ def _roster(root: Path) -> list[tuple[str, Path]]: return entries +def is_bot_mode_managed(home: str | os.PathLike | None = None) -> bool: + """True when ANY profile on this install is Bot-Mode-managed. + + The tool-injection gate for ``message_agent`` — deliberately independent + of :func:`get_bot_mode_protocol_section`'s emptiness: a profile whose + SOUL.md carries the legacy plugin-appended protocol gets an empty + section (text dedupe) but must still get the tool. Never raises. + """ + try: + resolved = Path( + str(home) if home else (os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes")) + ) + root = _hermes_root(resolved) + return any(_is_bot_managed(d) for _n, d in _roster(root)) + except Exception: + return False + + def _soul_has_protocol(profile_dir: Path) -> bool: try: soul = profile_dir / "SOUL.md" @@ -174,6 +192,38 @@ def _peers(root: Path) -> list[str]: return [] +def _remote_paragraph(root: Path) -> str: + """Protocol addendum for agents on OTHER connected machines. + + Fed by the Desktop relay roster (``tools/bot_relay.py``) — every gateway + connected to the user's Desktop (local, remote URL, SSH, Hermes Cloud, + docker) syncs its agents here, so bots can DM across machines with the + same message_agent tool. Only rendered when the relay roster is + non-empty. + """ + try: + from tools.bot_relay import read_remote_roster, remote_target_forms + + roster = read_remote_roster(root) + except Exception: + return "" + if not roster: + return "" + lines = [] + for row, form in zip(roster, remote_target_forms(roster)): + where = row["connection_label"] or row["connection_id"] + role = " — ".join(p for p in (row["title"], row["description"]) if p) + lines.append( + f"- `@{form}` — on {where}" + (f" — {role}" if role else "") + ) + return ( + "\n\nTeammates on OTHER connected machines (reachable through the " + "Desktop relay — message them with message_agent exactly like local " + "teammates; replies arrive as completion notifications the same " + "way):\n" + "\n".join(lines) + ) + + def _peer_paragraph(root: Path) -> str: """Protocol addendum for cross-machine DMs — only when peers exist.""" peers = _peers(root) @@ -230,6 +280,7 @@ def _build_section(home: Path) -> str: f"You are `@{handle}`. Your teammates (live roster; roles from their " "profiles):\n" f"{roster_block}" + + _remote_paragraph(root) + _peer_paragraph(root) ) @@ -340,6 +391,18 @@ def capability_fingerprint(home: str | os.PathLike | None = None) -> str: surface["peers"] = _peers(_hermes_root(resolved)) except Exception: surface["peers"] = [] + try: + # The Desktop relay roster is part of the messaging surface too: + # connecting/disconnecting a machine, or agents appearing on one, + # must refresh eternal Bot Chat prompts the same way. + from tools.bot_relay import read_remote_roster + + surface["remote_roster"] = sorted( + f"{r['connection_id']}:{r['profile']}:{r['title']}" + for r in read_remote_roster(_hermes_root(resolved)) + ) + except Exception: + surface["remote_roster"] = [] try: blob = json.dumps(surface, sort_keys=True).encode("utf-8") return hashlib.sha256(blob).hexdigest()[:12] diff --git a/tools/bot_relay.py b/tools/bot_relay.py new file mode 100644 index 000000000000..2e7e20a85c41 --- /dev/null +++ b/tools/bot_relay.py @@ -0,0 +1,339 @@ +"""Bot Mode cross-connection relay — connections ARE the peer set. + +Every gateway connected to the user's Desktop (local, remote URL, SSH, +Hermes Cloud, docker) is a persistent line. This module is the gateway-side +half of the relay that rides those lines so agents on ANY connected gateway +can find and message agents on ANY other, with `message_agent` as the one +send path (Teknium ruling, Aug 2026 — the peers-vs-connections split was +itself the bug). + +How the relay works (three files under ``/bot_relay/``): + +- ``roster.json`` — the union roster of agents on OTHER connections, pushed + by the Desktop over each connection's WebSocket (``bot_relay.roster.sync``). + ``tools/bot_mode_probe.py`` folds it into the Bot Chat protocol section so + every bot knows every reachable teammate, and ``message_agent`` resolves + cross-connection targets against it. +- ``outbox/`` — envelopes queued by ``message_agent`` for targets that live + on another connection. The Desktop drains them (``bot_relay.outbox.drain``) + and delivers each to the target connection (``bot_relay.deliver``). +- ``replies/`` — one JSON per envelope, written when the Desktop relays the + target agent's reply back (``bot_relay.reply``). A background waiter + spawned at send time watches for it, so the reply wakes the sender through + the exact same completion-notification path local DMs already use. + +The gateway never holds another connection's credentials; the Desktop owns +every socket and does all cross-connection I/O. Everything here is plain +file plumbing on the gateway's own HERMES root — no network, never raises +out of the public helpers. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import shlex +import sys +import tempfile +import time +import uuid +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +RELAY_DIR_NAME = "bot_relay" +ROSTER_FILE = "roster.json" +OUTBOX_DIR = "outbox" +CLAIMED_DIR = "claimed" +REPLIES_DIR = "replies" + +# A reply must arrive before the waiter gives up. Cross-connection turns can +# be slow (remote model, cold gateway) — generous, but bounded. +REPLY_WAIT_SECONDS = 900 + +# Envelopes and replies older than this are stale artifacts (Desktop was +# closed, connection died) and are swept opportunistically. +STALE_AFTER_SECONDS = 6 * 3600 + +_HANDLE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$") + + +def relay_root(root: Path | str) -> Path: + return Path(root) / RELAY_DIR_NAME + + +def _ensure_dirs(root: Path | str) -> Path: + base = relay_root(root) + for sub in (OUTBOX_DIR, CLAIMED_DIR, REPLIES_DIR): + (base / sub).mkdir(parents=True, exist_ok=True) + return base + + +# ── remote roster ──────────────────────────────────────────────────────────── + + +def _normalize_roster_row(row: Any) -> Optional[dict]: + """Validated, minimal roster row or None. + + Rows come from the Desktop over RPC — treat as untrusted input. A row + names an agent on another connection: profile name, taggable handle, + the connection id/label of the gateway that owns it, and optional + friendly title/description for the protocol section. + """ + if not isinstance(row, dict): + return None + profile = str(row.get("profile") or "").strip() + handle = str(row.get("handle") or "").strip().lstrip("@") + connection_id = str(row.get("connection_id") or "").strip() + if not profile or not connection_id: + return None + if not handle: + handle = "hermes" if profile == "default" else profile + if not _HANDLE_RE.match(handle) or not _HANDLE_RE.match(profile): + return None + out = { + "profile": profile, + "handle": handle, + "connection_id": connection_id, + "connection_label": str(row.get("connection_label") or "").strip()[:80], + "title": str(row.get("title") or "").strip()[:120], + "description": " ".join(str(row.get("description") or "").split())[:160], + } + return out + + +def write_remote_roster(root: Path | str, rows: Any) -> int: + """Atomically persist the Desktop-pushed remote roster. Returns count.""" + base = _ensure_dirs(root) + cleaned: list[dict] = [] + seen: set[tuple[str, str]] = set() + for row in rows if isinstance(rows, list) else []: + norm = _normalize_roster_row(row) + if not norm: + continue + key = (norm["connection_id"], norm["profile"]) + if key in seen: + continue + seen.add(key) + cleaned.append(norm) + cleaned.sort(key=lambda r: (r["connection_id"], r["profile"])) + payload = {"updated_at": int(time.time()), "agents": cleaned} + target = base / ROSTER_FILE + fd, tmp = tempfile.mkstemp(dir=str(base), prefix=".roster-", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, sort_keys=True) + os.replace(tmp, target) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + return len(cleaned) + + +def read_remote_roster(root: Path | str) -> list[dict]: + """The current remote roster (possibly empty). Never raises.""" + try: + raw = (relay_root(root) / ROSTER_FILE).read_text(encoding="utf-8") + data = json.loads(raw) + agents = data.get("agents") if isinstance(data, dict) else None + if not isinstance(agents, list): + return [] + return [r for r in (_normalize_roster_row(a) for a in agents) if r] + except FileNotFoundError: + return [] + except Exception: + logger.debug("bot_relay roster read failed", exc_info=True) + return [] + + +def resolve_remote_target(raw_target: str, roster: list[dict]) -> Any: + """Resolve ``raw_target`` against the remote roster. + + Accepted forms: + - bare handle/profile (``moxie``) — must be unique across connections; + - ``@`` / ``@`` — exact. + + Returns the matched row, the string ``"ambiguous"`` when a bare form + matches agents on several connections, or None for no match. + """ + want = str(raw_target or "").strip().lstrip("@") + if not want: + return None + conn: Optional[str] = None + if "@" in want: + want, _, conn = want.partition("@") + want = want.strip() + conn = conn.strip() + if not want or not conn: + return None + matches = [] + for row in roster: + if want.lower() not in (row["handle"].lower(), row["profile"].lower()): + continue + if conn and row["connection_id"].lower() != conn.lower(): + continue + matches.append(row) + if not matches: + return None + if len(matches) > 1: + return "ambiguous" + return matches[0] + + +def remote_target_forms(roster: list[dict]) -> list[str]: + """Human/agent-facing target strings, ambiguity-aware.""" + by_handle: dict[str, int] = {} + for row in roster: + by_handle[row["handle"].lower()] = by_handle.get(row["handle"].lower(), 0) + 1 + forms = [] + for row in roster: + if by_handle[row["handle"].lower()] > 1: + forms.append(f"{row['handle']}@{row['connection_id']}") + else: + forms.append(row["handle"]) + return forms + + +# ── outbox / replies ───────────────────────────────────────────────────────── + + +def enqueue_envelope( + root: Path | str, + *, + target: dict, + message: str, + sender_profile: str, + sender_handle: str, +) -> dict: + """Queue a cross-connection DM for the Desktop relay. Returns envelope.""" + base = _ensure_dirs(root) + envelope = { + "id": uuid.uuid4().hex, + "created_at": int(time.time()), + "from_profile": sender_profile, + "from_handle": sender_handle, + "target_connection": target["connection_id"], + "target_profile": target["profile"], + "target_handle": target["handle"], + "message": message, + } + path = base / OUTBOX_DIR / f"{envelope['id']}.json" + fd, tmp = tempfile.mkstemp(dir=str(base / OUTBOX_DIR), prefix=".env-", suffix=".tmp") + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(envelope, f, ensure_ascii=False) + os.replace(tmp, path) + return envelope + + +def claim_pending_envelopes(root: Path | str) -> list[dict]: + """Drain the outbox (rename → claimed/, so a second drain can't double- + deliver). Sweeps stale claimed/reply artifacts opportunistically.""" + base = _ensure_dirs(root) + _sweep_stale(base) + out: list[dict] = [] + outbox = base / OUTBOX_DIR + for path in sorted(outbox.glob("*.json")): + claimed = base / CLAIMED_DIR / path.name + try: + os.replace(path, claimed) # atomic claim + out.append(json.loads(claimed.read_text(encoding="utf-8"))) + except (OSError, ValueError): + continue + return out + + +def write_reply( + root: Path | str, envelope_id: str, *, reply: str = "", error: str = "" +) -> Path: + """Persist the relayed reply (or delivery error) for the waiter.""" + base = _ensure_dirs(root) + safe = str(envelope_id or "").strip() + if not re.match(r"^[0-9a-f]{32}$", safe): + raise ValueError(f"invalid envelope id: {envelope_id!r}") + path = base / REPLIES_DIR / f"{safe}.json" + payload = { + "id": safe, + "at": int(time.time()), + "reply": str(reply or ""), + "error": str(error or ""), + } + fd, tmp = tempfile.mkstemp(dir=str(base / REPLIES_DIR), prefix=".rep-", suffix=".tmp") + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False) + os.replace(tmp, path) + return path + + +def _sweep_stale(base: Path) -> None: + cutoff = time.time() - STALE_AFTER_SECONDS + for sub in (CLAIMED_DIR, REPLIES_DIR, OUTBOX_DIR): + try: + for path in (base / sub).glob("*.json"): + try: + if path.stat().st_mtime < cutoff: + path.unlink() + except OSError: + continue + except OSError: + continue + + +# ── waiter (runs on the sender gateway via terminal background process) ───── + + +def waiter_command(root: Path | str, envelope: dict) -> str: + """Shell command that blocks until the reply file appears, then prints it. + + Spawned with ``terminal_tool(background=True, notify_on_complete=True)`` + so its stdout — the teammate's reply — arrives as the same completion + notification local DMs use. Stdlib-only; runs under the sender gateway's + interpreter. + """ + reply_path = str(relay_root(root) / REPLIES_DIR / f"{envelope['id']}.json") + label = f"@{envelope['target_handle']} on {envelope['target_connection']}" + code = ( + "import json,os,sys,time\n" + f"p = {reply_path!r}\n" + f"deadline = time.time() + {REPLY_WAIT_SECONDS}\n" + "while time.time() < deadline:\n" + " if os.path.exists(p):\n" + " d = json.load(open(p, encoding='utf-8'))\n" + " if d.get('error'):\n" + f" print('Delivery to {label} failed: ' + d['error'])\n" + " sys.exit(1)\n" + f" print('Reply from {label}:')\n" + " print(d.get('reply') or '(empty reply)')\n" + " sys.exit(0)\n" + " time.sleep(2)\n" + f"print('No reply from {label} within {REPLY_WAIT_SECONDS}s. The message may " + "still be delivered when the Desktop reconnects; do not resend blindly.')\n" + "sys.exit(1)\n" + ) + return f"{shlex.quote(sys.executable or 'python3')} -c {shlex.quote(code)}" + + +# ── delivery command (used by the deliver RPC on the TARGET gateway) ──────── + + +def local_delivery_command(profile: str, query_file: str) -> list[str]: + """argv that delivers a DM into ``profile``'s Bot Chat on THIS gateway.""" + return [ + "hermes", + "-p", + profile, + "chat", + "--in", + "~", + "-c", + "Bot Chat", + "--create-if-missing", + "-Q", + "--query-file", + query_file, + ] diff --git a/tui_gateway/methods_bot_relay.py b/tui_gateway/methods_bot_relay.py new file mode 100644 index 000000000000..e337fca3c9a4 --- /dev/null +++ b/tui_gateway/methods_bot_relay.py @@ -0,0 +1,168 @@ +"""Bot-relay JSON-RPC handlers — the gateway side of cross-connection A2A. + +Connections ARE the peer set: every gateway the Desktop holds a socket to +(local, remote URL, SSH, Hermes Cloud, docker) must be able to find every +other connection's agents and message them. The Desktop is the relay — it +owns every socket — and these four methods are the door it uses on EACH +connected gateway: + +- ``bot_relay.roster.sync`` — Desktop pushes the union roster of agents on + the OTHER connections into this gateway's ``bot_relay/roster.json``, so + ``message_agent`` can resolve cross-connection targets and Bot Chat + prompts list them (capability-epoch refresh picks up changes). +- ``bot_relay.outbox.drain`` — Desktop collects envelopes queued here by + ``message_agent`` for targets on other connections. +- ``bot_relay.deliver`` — Desktop hands an envelope to the TARGET + gateway; this method runs the same one-turn Bot Chat delivery local DMs + use and returns the reply text. +- ``bot_relay.reply`` — Desktop writes the reply (or a delivery + error) back on the SENDER gateway; the waiter spawned at send time picks + it up and wakes the sending agent via the standard completion path. + +Storage/validation plumbing lives in ``tools/bot_relay.py``. Handlers are +rebound onto server.py's globals at install time (see method_ctx.py) and may +reference server module globals (``_ok``, ``_err``) not imported here. +""" + +from .method_ctx import HandlerRegistry + +_registry = HandlerRegistry() +method = _registry.method + + +@method("bot_relay.roster.sync") +def _(rid, params: dict) -> dict: + """Replace this gateway's view of agents on OTHER connections. + + Params: ``agents`` — list of rows ``{profile, handle, connection_id, + connection_label?, title?, description?}``. Rows failing validation are + dropped, not fatal. Result: ``{count}`` (accepted rows). + """ + try: + import os + from pathlib import Path + + from tools.bot_relay import write_remote_roster + + home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes")) + root = home.parent.parent if home.parent.name == "profiles" else home + count = write_remote_roster(root, params.get("agents")) + return _ok(rid, {"count": count}) + except Exception as e: + return _err(rid, 5090, str(e)) + + +@method("bot_relay.outbox.drain") +def _(rid, params: dict) -> dict: + """Claim every pending cross-connection envelope queued on this gateway. + + Claimed envelopes move to ``claimed/`` atomically, so concurrent drains + (two Desktop windows) can't double-deliver. Result: ``{envelopes}``. + """ + try: + import os + from pathlib import Path + + from tools.bot_relay import claim_pending_envelopes + + home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes")) + root = home.parent.parent if home.parent.name == "profiles" else home + return _ok(rid, {"envelopes": claim_pending_envelopes(root)}) + except Exception as e: + return _err(rid, 5091, str(e)) + + +@method("bot_relay.deliver") +def _(rid, params: dict) -> dict: + """Deliver a relayed DM into a profile's Bot Chat ON THIS GATEWAY. + + Params: ``profile`` (target on this install), ``message`` (already + attribution-prefixed by the sender gateway). Runs the same one-turn + ``hermes -p chat -c "Bot Chat"`` transport local DMs use and + returns ``{reply}`` — the target agent's response text. Blocking by + design (the Desktop calls it from its relay worker, off any UI path; + the RPC pool keeps it off the WS reader thread). + """ + import os + import subprocess + import tempfile + from pathlib import Path + + profile = str(params.get("profile") or "").strip() + message = str(params.get("message") or "").strip() + if not profile or not message: + return _err(rid, 4090, "profile and message required") + try: + from tools.bot_mode_dm import MESSAGE_MAX_CHARS + from tools.bot_relay import local_delivery_command + + if len(message) > MESSAGE_MAX_CHARS + 200: # + attribution headroom + return _err(rid, 4091, "message too long") + + home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes")) + root = home.parent.parent if home.parent.name == "profiles" else home + known = {"default"} + profiles_dir = root / "profiles" + if profiles_dir.is_dir(): + known.update(c.name for c in profiles_dir.iterdir() if c.is_dir()) + resolved = "default" if profile.lower() == "hermes" else profile + if resolved not in known: + return _err(rid, 4092, f"no profile '{profile}' on this gateway") + + fd, tmp = tempfile.mkstemp(prefix="hermes-relay-dm-", suffix=".txt", text=True) + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(message) + try: + proc = subprocess.run( + local_delivery_command(resolved, tmp), + capture_output=True, + text=True, + timeout=600, + ) + finally: + try: + os.unlink(tmp) + except OSError: + pass + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "").strip()[-500:] + return _err(rid, 5092, f"delivery turn failed: {detail or proc.returncode}") + return _ok(rid, {"reply": (proc.stdout or "").strip()}) + except subprocess.TimeoutExpired: + return _err(rid, 5093, "delivery turn timed out") + except Exception as e: + return _err(rid, 5094, str(e)) + + +@method("bot_relay.reply") +def _(rid, params: dict) -> dict: + """Write a relayed reply (or delivery error) for a sender-side waiter. + + Params: ``id`` (envelope id), ``reply`` and/or ``error``. + """ + envelope_id = str(params.get("id") or "").strip() + if not envelope_id: + return _err(rid, 4093, "id required") + try: + import os + from pathlib import Path + + from tools.bot_relay import write_reply + + home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes")) + root = home.parent.parent if home.parent.name == "profiles" else home + write_reply( + root, + envelope_id, + reply=str(params.get("reply") or ""), + error=str(params.get("error") or ""), + ) + return _ok(rid, {"ok": True}) + except ValueError as e: + return _err(rid, 4094, str(e)) + except Exception as e: + return _err(rid, 5095, str(e)) + + +def register(server) -> None: + _registry.install(server) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 4fb51eeec420..ef1b98b5f9b2 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -275,6 +275,14 @@ def _thread_panic_hook(args): "profiles.get_asset", "profiles.list", "profiles.set_asset", + # Bot-relay RPCs: roster.sync/outbox.drain/reply are cheap file I/O, + # but bot_relay.deliver runs a FULL one-turn agent conversation + # (subprocess, up to 600s) — all four stay off the WS reader thread + # so a slow relay delivery can never block prompt.submit. + "bot_relay.roster.sync", + "bot_relay.outbox.drain", + "bot_relay.deliver", + "bot_relay.reply", # image.generate is a multi-second remote API round-trip. "image.generate", "projects.discover_repos", @@ -15703,6 +15711,7 @@ def _mcp_summarize_server(name, cfg): # noqa: E402 # over already exists; register() rebinds them onto this namespace. from . import ( # noqa: E402 methods_browser_control as _methods_browser_control, + methods_bot_relay as _methods_bot_relay, methods_complete as _methods_complete, methods_config as _methods_config, methods_images as _methods_images, @@ -15721,6 +15730,7 @@ def _mcp_summarize_server(name, cfg): # noqa: E402 _methods_tools, _methods_profiles, _methods_images, + _methods_bot_relay, ): _m.register(sys.modules[__name__]) del _m diff --git a/website/docs/user-guide/bot-mode.md b/website/docs/user-guide/bot-mode.md index 8a9eb0c9a785..7a9e198cb530 100644 --- a/website/docs/user-guide/bot-mode.md +++ b/website/docs/user-guide/bot-mode.md @@ -94,7 +94,7 @@ Groups are standalone rows in the same activity-ordered roster as Bot DMs. A Bot Bots message each other with attribution, and you can hand work off from any chat: -- **@mentions** — type `@researcher have a look at this` in any chat and the composer's `@` autocomplete helps you pick the right Bot; on send, the mention is resolved against the live roster and the active Bot is told exactly who you mean (profile, friendly name, and device for cross-connection Bots). The Bot then composes its own message and sends it with `message_agent` — your text is never forwarded verbatim, and the reply comes back attributed to that agent. An email address or an unknown `@` passes through untouched. Reaching a Bot on another machine goes through a registered peer gateway (see `hermes peer` below). +- **@mentions** — type `@researcher have a look at this` in any chat and the composer's `@` autocomplete helps you pick the right Bot; on send, the mention is resolved against the live roster and the active Bot is told exactly who you mean (profile, friendly name, and device for cross-connection Bots). The Bot then composes its own message and sends it with `message_agent` — your text is never forwarded verbatim, and the reply comes back attributed to that agent. An email address or an unknown `@` passes through untouched. Bots on other connected machines are reachable the same way: the Desktop relays the message over that connection's own socket (see *Bots across machines* below). - **Renamed Bots keep their tags in sync** — give a Bot a friendly name (the pencil in its chat header, or `hermes profile rename`) and it becomes taggable by that name: a Bot titled *Research Buddy* answers to `@research-buddy` (and `@researchbuddy`), in regular chats and in group rooms alike. The composer's `@` autocomplete offers the renamed tag and also matches when you type the old profile name, which keeps resolving too. - **Direct messages** — every Bot Chat carries the `message_agent` tool: a Bot messages a teammate by calling `message_agent(target="researcher", message="…")`. The tool validates the target against the live roster, prefixes the sender's `Message from 🤖 (@):` attribution automatically, and delivers into the teammate's canonical Bot Chat. Delivery is **fire-and-forget**: the sender gets an acknowledgement, finishes its turn, and the reply arrives later as a background completion notification. The message travels as a real parameter (nothing shell-interpreted — quotes, `$(...)`, and backticks arrive verbatim), and the Bot composes its own message rather than forwarding your words. The teammate roster — names **and roles** from each profile's title/description — is part of every Bot Chat's system prompt, so Bots know who does what before choosing a recipient. The tool exists **only** in canonical Bot Chat sessions on Bot-Mode-managed installs; regular chats, group-room member sessions, and CLI sessions never see it. @@ -109,6 +109,14 @@ agent: Bot-to-bot delivery is per-invocation: the receiving Bot picks the message up when it next runs. Live interrupt of a Bot mid-conversation is future work. ::: +### Messaging across connected machines (the Desktop relay) + +Every gateway you register in **Settings → Connections** — local, remote URL, SSH, Hermes Cloud, docker — is a persistent line the Desktop holds open, and Bot Mode uses those lines for messaging automatically. No extra setup: + +- **Rosters propagate on their own.** While the Desktop runs, it periodically tells each connected gateway which agents live on the *other* connections. Every Bot Chat's teammate roster then lists them ("Teammates on OTHER connected machines"), with names, roles, and which machine they're on — and the roster refreshes when agents appear, disappear, or get renamed (capability epoch). +- **`message_agent` reaches them directly.** A Bot on your laptop messages the cloud agent with `message_agent(target="moxie", …)` exactly like a local teammate. If the same handle exists on several machines, disambiguate with `target="moxie@"` (the tool's error tells the Bot the exact forms). Delivery rides the Desktop: the sending gateway queues the message, the Desktop relays it to the target connection's own gateway, the target Bot runs a turn in its canonical Bot Chat, and the reply comes back to the sender as the same background completion notification local DMs use. +- **The Desktop is the courier.** Cross-connection delivery works while a Desktop that knows both connections is running (it holds the sockets and the credentials — gateways never see each other's auth). If the Desktop is closed mid-delivery, the sender's Bot is told the reply didn't arrive rather than left hanging. For always-on machine-to-machine messaging with no Desktop in the loop, register a peer (`hermes peer`, below) — the two routes coexist. + ### Bot-initiated DMs across machines (`hermes peer`) Bots on one machine can message Bots on **another machine's gateway** without any desktop in the loop. Register the other gateway as a *peer* (its API server URL + `API_SERVER_KEY`): @@ -130,7 +138,7 @@ Requirements: the peer machine runs the `api_server` gateway platform with a str When you register several backends in **Settings → Connections** — the local runtime, remote gateways, SSH hosts, Hermes Cloud instances — the roster shows the Bots from **every** connected source, persistently: SSH sources are inventoried without spawning anything on the remote box, and machines that are momentarily unreachable keep their last-known rows instead of vanishing. When the same profile name exists on several sources, handles disambiguate as `@name-device` (for example `@research-homelab`). A Bot's chats, sessions, memory, and routines live on the machine that owns the profile. -Clicking a Connections Bot does **not** hop your window onto that machine — stay in your chat and `@mention` it, seat it in a group chat, or create new agents on it directly with the **Create on** picker. Cloud and local agents share one roster this way: register your Hermes Cloud instance and your desktop (say, over Tailscale or SSH) and their Bots can message each other and sit in the same rooms, with each agent's work running on its own machine. +Clicking a Connections Bot does **not** hop your window onto that machine — stay in your chat and `@mention` it, seat it in a group chat, or create new agents on it directly with the **Create on** picker. Cloud and local agents share one roster this way: register your Hermes Cloud instance and your desktop (say, over Tailscale or SSH) and their Bots can message each other and sit in the same rooms, with each agent's work running on its own machine. Bot-to-bot DMs across those machines go through the Desktop relay automatically (see *Messaging across connected machines* above). See [Connecting Desktop to Many Hermes Instances](./multi-connection-desktop.md) for the full multi-connection guide. From 13f9d18e33cc1bf5d8b13e7d6f480c4bb7f9a2b7 Mon Sep 17 00:00:00 2001 From: mrmixx-max Date: Mon, 10 Aug 2026 22:22:11 +0200 Subject: [PATCH 073/666] fix(update): pin uv installs to the running interpreter when VIRTUAL_ENV is stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Hermes is installed via pip / site-packages (e.g. the Windows installer), PROJECT_ROOT is the interpreter's site-packages directory and PROJECT_ROOT/venv is never created. The update and interrupted-install recovery paths still set VIRTUAL_ENV=PROJECT_ROOT/venv, so uv fails with 'Failed to inspect Python interpreter from active virtual environment' before installing anything — leaving the install partially updated. Detect the nonexistent VIRTUAL_ENV in the shared dependency-install helper and pin uv to the running interpreter (uv pip install --python sys.executable) instead, matching the fix already applied to lazy-deps (#83335) and the ZIP update path (#71510). --- hermes_cli/main.py | 30 ++++++++ .../test_update_stale_virtualenv.py | 76 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 tests/hermes_cli/test_update_stale_virtualenv.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 90d6cd54c425..b0251a3b908b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9581,10 +9581,40 @@ def _install_python_dependencies_with_optional_fallback( in the venv Scripts dir before each install attempt so uv can write fresh copies (Windows blocks REPLACE on a running .exe but allows RENAME). See ``_quarantine_running_hermes_exe`` for the rationale. + + When ``env`` carries a ``VIRTUAL_ENV`` that does not exist (a pip / + site-packages install whose ``PROJECT_ROOT`` is the interpreter's + ``site-packages`` directory, where ``PROJECT_ROOT / "venv"`` is never + created), ``uv pip`` fails with ``Failed to inspect Python interpreter from + active virtual environment`` before doing any work. Pin the install at the + running interpreter instead so the update/recovery path succeeds on those + installs (#71510 fixed the ZIP path, #83335 fixed lazy-deps; this closes the + shared helper for the remaining callers). """ scripts_dir = _venv_scripts_dir() if _is_windows() else None + # A pip / site-packages install has no PROJECT_ROOT/venv; the caller still + # passes VIRTUAL_ENV=PROJECT_ROOT/venv, which does not exist. uv would fail + # before installing anything ("Failed to inspect Python interpreter from + # active virtual environment"). Detect the stale pointer and pin the target + # interpreter explicitly instead of trusting the nonexistent venv. + pin_python = False + if ( + env + and env.get("VIRTUAL_ENV") + and not Path(env["VIRTUAL_ENV"]).is_dir() + and install_cmd_prefix + and "uv" in Path(install_cmd_prefix[0]).name.lower() + ): + # Only uv needs the explicit pin; pip resolves the target from + # sys.executable itself and has no --python flag. + pin_python = True + env = {**env} + env.pop("VIRTUAL_ENV", None) + def _install(args: list[str]) -> None: + if pin_python: + args = [args[0], "--python", str(sys.executable), *args[1:]] # strict_quarantine: this is the UPDATE dependency sync. A shim that # cannot be renamed aside proves a hard venv hold; running uv anyway # is how installs strand half-updated (#87331). ShimQuarantineError diff --git a/tests/hermes_cli/test_update_stale_virtualenv.py b/tests/hermes_cli/test_update_stale_virtualenv.py new file mode 100644 index 000000000000..90c56cc1cad3 --- /dev/null +++ b/tests/hermes_cli/test_update_stale_virtualenv.py @@ -0,0 +1,76 @@ +"""Test: _install_python_dependencies_with_optional_fallback mit stale VIRTUAL_ENV. + +Simuliert den heutigen Crash: pip/System-Python-Install, wo PROJECT_ROOT = +site-packages ist und VIRTUAL_ENV=PROJECT_ROOT/venv nicht existiert. +""" +import os +import sys +import types +import unittest +from pathlib import Path +from unittest import mock + +import hermes_cli.main as main_mod + + +class StaleVirtualEnvTest(unittest.TestCase): + def _call(self, uv_bin, venv_path, fake_executable): + """Führe die Funktion mit gemocktem uv/env aus und fange den subprocess-Aufruf.""" + captured = [] + + real_run_quarantined = main_mod._run_quarantined_install + + def fake_quarantine(cmd, *, env=None, scripts_dir=None): + captured.append((list(cmd), dict(env or {}))) + # Nicht wirklich installieren; Verhalten wie Erfolg simulieren. + return None + + def fake_verify(prefix, *, env=None): + return None + + with mock.patch.object(main_mod, "_run_quarantined_install", fake_quarantine), \ + mock.patch.object(main_mod, "_verify_console_scripts_installed", fake_verify), \ + mock.patch.object(main_mod, "_venv_scripts_dir", return_value=None), \ + mock.patch.object(main_mod, "_is_windows", return_value=False), \ + mock.patch.object(main_mod.sys, "executable", fake_executable), \ + mock.patch.object(main_mod, "PROJECT_ROOT", Path("/fake/project")): + main_mod._install_python_dependencies_with_optional_fallback( + [str(uv_bin), "pip"], + env={"VIRTUAL_ENV": str(venv_path)}, + group="all", + ) + return captured + + def test_stale_virtualenv_pins_python(self): + """VIRTUAL_ENV zeigt auf nicht-existierendes venv -> --python sys.executable.""" + captured = self._call( + uv_bin=Path("/fake/uv"), + venv_path=Path("/fake/project/venv"), # existiert nicht + fake_executable="/fake/python311/python.exe", + ) + self.assertTrue(captured, "kein subprocess-Aufruf erfasst") + cmd, env = captured[0] + # --python muss nach 'install' stehen: uv pip install --python ... + self.assertIn("install", cmd) + self.assertIn("--python", cmd) + self.assertEqual(cmd[cmd.index("--python") + 1], "/fake/python311/python.exe") + # VIRTUAL_ENV aus env entfernt + self.assertNotIn("VIRTUAL_ENV", env) + + def test_existing_virtualenv_keeps_env(self): + """VIRTUAL_ENV zeigt auf existierendes venv -> unverändert, kein --python.""" + real_venv = Path(sys.executable).resolve().parent.parent + if not real_venv.is_dir(): + self.skipTest("kein echtes venv im Testlauf verfügbar") + captured = self._call( + uv_bin=Path("/fake/uv"), + venv_path=real_venv, + fake_executable=sys.executable, + ) + cmd, env = captured[0] + self.assertNotIn("--python", cmd) + self.assertEqual(env.get("VIRTUAL_ENV"), str(real_venv)) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 33e813da17fc7aeaff90ae96a03c9674c46100f6 Mon Sep 17 00:00:00 2001 From: mrmixx-max Date: Sun, 16 Aug 2026 21:01:44 +0200 Subject: [PATCH 074/666] fix(update): address review on stale-VIRTUAL_ENV pin - _is_uv_command: detect 'python -m uv'/'python -m uvx' and launcher wrappers, not just a uv basename (review: naive check missed module form) - _insert_python_pin: never duplicate a caller-supplied --python (review: last-wins ambiguity) - _interpreter_scripts_dir: when pinning to sys.executable on Windows with no project venv, quarantine the running interpreter's Scripts dir so the hermes.exe shims uv rewrites are actually unlocked (review: quarantine path diverged from pinned interpreter) - tests: rewritten to repo English convention; added python -m uv, --python-guard and Windows quarantine-target cases (5 total) --- hermes_cli/main.py | 64 +++++++++++++- .../test_update_stale_virtualenv.py | 84 ++++++++++++++----- 2 files changed, 123 insertions(+), 25 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index b0251a3b908b..6ebaf4cf316d 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9566,6 +9566,58 @@ def _repair_venv_via_import_probes( return "failed" +def _is_uv_command(install_cmd_prefix: list[str]) -> bool: + """True when the install command is a uv/uvx invocation. + + Handles a bare uv binary (``uv`` / ``uvx``, any extension), a path to + one, and ``python -m uv`` / ``python -m uvx`` — the naive basename check + misses the module form and launcher wrappers whose name does not contain + "uv". + """ + if not install_cmd_prefix: + return False + first = str(install_cmd_prefix[0]).lower() + if "uv" in Path(first).name: + return True + # python -m uv / python -m uvx + if len(install_cmd_prefix) >= 3 and first.endswith(("python", "python.exe")): + return install_cmd_prefix[1] == "-m" and install_cmd_prefix[2] in ( + "uv", + "uvx", + ) + return False + + +def _insert_python_pin(args: list[str]) -> list[str]: + """Insert ``--python `` into a uv command line. + + If the caller already passed ``--python``, its value wins (uv's last-wins + semantics are ambiguous; the explicit caller intent should not be + overridden by the fallback pin). + """ + if "--python" in args: + return args + return [args[0], "--python", str(sys.executable), *args[1:]] + + +def _interpreter_scripts_dir() -> Path | None: + """Scripts/bin directory of the running interpreter (sys.executable). + + Used when pinning an install to ``sys.executable`` on a site-packages + install where ``PROJECT_ROOT / "venv"`` does not exist: the entry-point + shims uv rewrites live next to the interpreter, not under a project venv. + """ + exe = Path(sys.executable) + parent = exe.parent + scripts_candidates = ( + [parent / "Scripts"] if _is_windows() else [parent / "bin"] + ) + for cand in scripts_candidates: + if cand.is_dir(): + return cand + return None + + def _install_python_dependencies_with_optional_fallback( install_cmd_prefix: list[str], *, @@ -9604,17 +9656,25 @@ def _install_python_dependencies_with_optional_fallback( and env.get("VIRTUAL_ENV") and not Path(env["VIRTUAL_ENV"]).is_dir() and install_cmd_prefix - and "uv" in Path(install_cmd_prefix[0]).name.lower() + and _is_uv_command(install_cmd_prefix) ): # Only uv needs the explicit pin; pip resolves the target from # sys.executable itself and has no --python flag. pin_python = True env = {**env} env.pop("VIRTUAL_ENV", None) + # When we pin to sys.executable, the entry-point shims that uv will + # rewrite live in that interpreter's Scripts/bin directory, NOT in + # PROJECT_ROOT/venv (which does not exist on a site-packages install). + # Quarantining the wrong dir means the running hermes.exe stays locked + # on Windows and the install fails exactly like the original bug. Only + # override when the venv-derived dir is missing; otherwise keep it. + if scripts_dir is None and _is_windows(): + scripts_dir = _interpreter_scripts_dir() def _install(args: list[str]) -> None: if pin_python: - args = [args[0], "--python", str(sys.executable), *args[1:]] + args = _insert_python_pin(args) # strict_quarantine: this is the UPDATE dependency sync. A shim that # cannot be renamed aside proves a hard venv hold; running uv anyway # is how installs strand half-updated (#87331). ShimQuarantineError diff --git a/tests/hermes_cli/test_update_stale_virtualenv.py b/tests/hermes_cli/test_update_stale_virtualenv.py index 90c56cc1cad3..291edddecb80 100644 --- a/tests/hermes_cli/test_update_stale_virtualenv.py +++ b/tests/hermes_cli/test_update_stale_virtualenv.py @@ -1,11 +1,10 @@ -"""Test: _install_python_dependencies_with_optional_fallback mit stale VIRTUAL_ENV. +"""Test: _install_python_dependencies_with_optional_fallback with stale VIRTUAL_ENV. -Simuliert den heutigen Crash: pip/System-Python-Install, wo PROJECT_ROOT = -site-packages ist und VIRTUAL_ENV=PROJECT_ROOT/venv nicht existiert. +Simulates the real crash: a pip/system-Python install where PROJECT_ROOT is +site-packages and VIRTUAL_ENV=PROJECT_ROOT/venv does not exist. """ import os import sys -import types import unittest from pathlib import Path from unittest import mock @@ -14,15 +13,12 @@ class StaleVirtualEnvTest(unittest.TestCase): - def _call(self, uv_bin, venv_path, fake_executable): - """Führe die Funktion mit gemocktem uv/env aus und fange den subprocess-Aufruf.""" + def _call(self, uv_cmd, venv_path, fake_executable, is_windows=False): + """Run the function with a mocked uv/env and capture the subprocess call.""" captured = [] - real_run_quarantined = main_mod._run_quarantined_install - def fake_quarantine(cmd, *, env=None, scripts_dir=None): - captured.append((list(cmd), dict(env or {}))) - # Nicht wirklich installieren; Verhalten wie Erfolg simulieren. + captured.append((list(cmd), dict(env or {}), scripts_dir)) return None def fake_verify(prefix, *, env=None): @@ -31,46 +27,88 @@ def fake_verify(prefix, *, env=None): with mock.patch.object(main_mod, "_run_quarantined_install", fake_quarantine), \ mock.patch.object(main_mod, "_verify_console_scripts_installed", fake_verify), \ mock.patch.object(main_mod, "_venv_scripts_dir", return_value=None), \ - mock.patch.object(main_mod, "_is_windows", return_value=False), \ + mock.patch.object(main_mod, "_is_windows", return_value=is_windows), \ mock.patch.object(main_mod.sys, "executable", fake_executable), \ mock.patch.object(main_mod, "PROJECT_ROOT", Path("/fake/project")): main_mod._install_python_dependencies_with_optional_fallback( - [str(uv_bin), "pip"], + list(uv_cmd), env={"VIRTUAL_ENV": str(venv_path)}, group="all", ) return captured def test_stale_virtualenv_pins_python(self): - """VIRTUAL_ENV zeigt auf nicht-existierendes venv -> --python sys.executable.""" + """VIRTUAL_ENV points at a nonexistent venv -> --python sys.executable.""" captured = self._call( - uv_bin=Path("/fake/uv"), - venv_path=Path("/fake/project/venv"), # existiert nicht + uv_cmd=[Path("/fake/uv"), "pip"], + venv_path=Path("/fake/project/venv"), # does not exist fake_executable="/fake/python311/python.exe", ) - self.assertTrue(captured, "kein subprocess-Aufruf erfasst") - cmd, env = captured[0] - # --python muss nach 'install' stehen: uv pip install --python ... + self.assertTrue(captured, "no subprocess call captured") + cmd, env, _ = captured[0] + # --python must come after 'install': uv pip install --python ... self.assertIn("install", cmd) self.assertIn("--python", cmd) self.assertEqual(cmd[cmd.index("--python") + 1], "/fake/python311/python.exe") - # VIRTUAL_ENV aus env entfernt + # VIRTUAL_ENV removed from env self.assertNotIn("VIRTUAL_ENV", env) def test_existing_virtualenv_keeps_env(self): - """VIRTUAL_ENV zeigt auf existierendes venv -> unverändert, kein --python.""" + """VIRTUAL_ENV points at an existing venv -> unchanged, no --python.""" real_venv = Path(sys.executable).resolve().parent.parent if not real_venv.is_dir(): - self.skipTest("kein echtes venv im Testlauf verfügbar") + self.skipTest("no real venv available in this test run") captured = self._call( - uv_bin=Path("/fake/uv"), + uv_cmd=[Path("/fake/uv"), "pip"], venv_path=real_venv, fake_executable=sys.executable, ) - cmd, env = captured[0] + cmd, env, _ = captured[0] self.assertNotIn("--python", cmd) self.assertEqual(env.get("VIRTUAL_ENV"), str(real_venv)) + def test_python_dash_m_uv_is_detected(self): + """python -m uv must also trigger the pin (naive basename check misses it).""" + captured = self._call( + uv_cmd=[Path("/fake/python"), "-m", "uv", "pip"], + venv_path=Path("/fake/project/venv"), # does not exist + fake_executable="/fake/python311/python.exe", + ) + self.assertTrue(captured, "no subprocess call captured") + cmd, _, _ = captured[0] + self.assertIn("--python", cmd) + self.assertEqual(cmd[cmd.index("--python") + 1], "/fake/python311/python.exe") + + def test_existing_python_flag_wins(self): + """A caller-supplied --python is not duplicated by the pin.""" + captured = self._call( + uv_cmd=[Path("/fake/uv"), "pip"], + venv_path=Path("/fake/project/venv"), + fake_executable="/fake/python311/python.exe", + ) + # Force the caller path through a manual pin with a pre-existing flag. + args = ["install", "--python", "/caller/choice/python.exe", "hermes"] + pinned = main_mod._insert_python_pin(args) + self.assertEqual(pinned, args, "existing --python must win") + self.assertEqual(pinned.count("--python"), 1) + + def test_windows_pins_quarantine_to_interpreter_scripts_dir(self): + """On Windows with a missing project venv, quarantine must target the + interpreter's Scripts dir (where the shims actually live), not None.""" + fake_scripts = Path("/fake/python311/Scripts") + with mock.patch.object( + main_mod, "_interpreter_scripts_dir", return_value=fake_scripts + ): + captured = self._call( + uv_cmd=[Path("/fake/uv"), "pip"], + venv_path=Path("/fake/project/venv"), + fake_executable="/fake/python311/python.exe", + is_windows=True, + ) + self.assertTrue(captured, "no subprocess call captured") + _, _, scripts_dir = captured[0] + self.assertEqual(scripts_dir, fake_scripts) + if __name__ == "__main__": unittest.main(verbosity=2) From 27d7d566008fdc51df786087ed608a6fa313d0b8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:56:38 -0700 Subject: [PATCH 075/666] fix(test): stale-VIRTUAL_ENV fixture accepts strict_quarantine kwarg Sibling-test blast radius from #92617: the salvaged fixture's fake _run_quarantined_install predates the strict_quarantine kwarg the update sync now passes. --- tests/hermes_cli/test_update_stale_virtualenv.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/hermes_cli/test_update_stale_virtualenv.py b/tests/hermes_cli/test_update_stale_virtualenv.py index 291edddecb80..8782d94d4bfa 100644 --- a/tests/hermes_cli/test_update_stale_virtualenv.py +++ b/tests/hermes_cli/test_update_stale_virtualenv.py @@ -17,7 +17,7 @@ def _call(self, uv_cmd, venv_path, fake_executable, is_windows=False): """Run the function with a mocked uv/env and capture the subprocess call.""" captured = [] - def fake_quarantine(cmd, *, env=None, scripts_dir=None): + def fake_quarantine(cmd, *, env=None, scripts_dir=None, strict_quarantine=False): captured.append((list(cmd), dict(env or {}), scripts_dir)) return None From a9dda9ab4a142f21f011155565a2d3925c52d1b4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:57:05 -0700 Subject: [PATCH 076/666] chore: map contributor email for salvage attribution --- contributors/emails/erikgieske@gmail.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/erikgieske@gmail.com diff --git a/contributors/emails/erikgieske@gmail.com b/contributors/emails/erikgieske@gmail.com new file mode 100644 index 000000000000..3114c100a0e9 --- /dev/null +++ b/contributors/emails/erikgieske@gmail.com @@ -0,0 +1 @@ +mrmixx-max From 5c1a304ce890276a4334d8ced3f29ffeedbbbf93 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:07:18 -0700 Subject: [PATCH 077/666] fix: derive the pinned interpreter's Scripts dir via venv_bin_dir (#76105 lint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged _interpreter_scripts_dir hand-rolled the Scripts/bin layout, which the AST lint-test in test_update_zip_two_phase forbids — route it through the canonical hermes_constants.venv_bin_dir instead, with the interpreter's own dir as fallback for non-venv layouts. --- hermes_cli/main.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 6ebaf4cf316d..7f4c0e9a40a3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9606,16 +9606,18 @@ def _interpreter_scripts_dir() -> Path | None: Used when pinning an install to ``sys.executable`` on a site-packages install where ``PROJECT_ROOT / "venv"`` does not exist: the entry-point shims uv rewrites live next to the interpreter, not under a project venv. + Layout comes from the canonical ``venv_bin_dir`` helper (#76105 — + hand-rolling Scripts/bin is lint-tested against). """ + from hermes_constants import venv_bin_dir + exe = Path(sys.executable) - parent = exe.parent - scripts_candidates = ( - [parent / "Scripts"] if _is_windows() else [parent / "bin"] - ) - for cand in scripts_candidates: - if cand.is_dir(): - return cand - return None + # sys.executable lives IN the bin/Scripts dir; its parent.parent is the + # env root venv_bin_dir derives from. + cand = venv_bin_dir(exe.parent.parent, windows=_is_windows()) + if cand.is_dir(): + return cand + return exe.parent if exe.parent.is_dir() else None def _install_python_dependencies_with_optional_fallback( From b7544dba011d2e2596be82df2a6d3d239ae349d7 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:50:13 +0530 Subject: [PATCH 078/666] fix(compression): share one image-strip policy across demote and retire passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demote pass (pass 2) and the retire pass (3.5, #92783) each carried their own copy of the two image-strip branches. The copies had already diverged: the retire pass dropped the stale api_content sidecar on rewrite, the demote pass did not — leaving an exact-wire sidecar that replay could use to resend the pre-strip image bytes. Extract _strip_images_from_tool_msg as the single policy owner; both passes now use it, closing the sidecar gap in the demote path. --- agent/context_compressor.py | 64 +++++++++++------ .../test_compressor_stale_tool_images.py | 71 +++++++++++++++++++ 2 files changed, 112 insertions(+), 23 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 881e7cfe45ea..151c0856cd18 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1578,6 +1578,35 @@ def _tool_content_has_images(content: Any) -> bool: return _content_has_images(content) +def _strip_images_from_tool_msg(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Return a copy of a tool message with its image payloads replaced. + + Handles the two image-bearing tool-result shapes: + + * ``{_multimodal: True, ...}`` envelopes collapse to a short + ``"[screenshot removed] "`` string; + * OpenAI-style part lists have image parts swapped for text + placeholders via :func:`_strip_image_parts_from_parts`. + + Returns ``None`` when the message carries no strippable image (the + caller should leave it untouched). The returned copy has its stale + ``api_content`` sidecar dropped so replay cannot resend the + pre-rewrite bytes. The input message is never mutated. + """ + content = msg.get("content") + if isinstance(content, dict) and content.get("_multimodal"): + summary = content.get("text_summary") or "[screenshot removed to save context]" + new_msg = {**msg, "content": f"[screenshot removed] {str(summary)[:200]}"} + drop_stale_api_content(new_msg) + return new_msg + stripped = _strip_image_parts_from_parts(content) + if stripped is None: + return None + new_msg = {**msg, "content": stripped} + drop_stale_api_content(new_msg) + return new_msg + + def _retire_stale_tool_result_images( result: List[Dict[str, Any]], keep_newest: int = _MAX_KEEP_TOOL_IMAGES, @@ -1598,24 +1627,14 @@ def _retire_stale_tool_result_images( msg = result[i] if not isinstance(msg, dict) or msg.get("role") != "tool": continue - content = msg.get("content") - if not _tool_content_has_images(content): + if not _tool_content_has_images(msg.get("content")): continue seen += 1 if seen <= keep_newest: continue - if isinstance(content, dict) and content.get("_multimodal"): - summary = content.get("text_summary") or "[screenshot removed to save context]" - new_msg = {**msg, "content": f"[screenshot removed] {summary[:200]}"} - drop_stale_api_content(new_msg) - result[i] = new_msg - pruned += 1 - continue - stripped = _strip_image_parts_from_parts(content) - if stripped is None: + new_msg = _strip_images_from_tool_msg(msg) + if new_msg is None: continue - new_msg = {**msg, "content": stripped} - drop_stale_api_content(new_msg) result[i] = new_msg pruned += 1 return pruned @@ -3761,16 +3780,15 @@ def _demote_tool_result_at(idx: int, *, spare_protected_skills: bool = True) -> if msg.get("role") != "tool": return False content = msg.get("content", "") - if isinstance(content, list): - stripped = _strip_image_parts_from_parts(content) - if stripped is not None: - result[idx] = {**msg, "content": stripped} - pruned += 1 - return True - return False - if isinstance(content, dict) and content.get("_multimodal"): - summary = content.get("text_summary") or "[screenshot removed to save context]" - result[idx] = {**msg, "content": f"[screenshot removed] {summary[:200]}"} + if isinstance(content, list) or ( + isinstance(content, dict) and content.get("_multimodal") + ): + # Image-bearing shapes share one strip policy with pass 3.5 + # (also drops the stale api_content sidecar on rewrite). + new_msg = _strip_images_from_tool_msg(msg) + if new_msg is None: + return False + result[idx] = new_msg pruned += 1 return True if not isinstance(content, str): diff --git a/tests/agent/test_compressor_stale_tool_images.py b/tests/agent/test_compressor_stale_tool_images.py index a6972b397a2a..4ef0ddd836d8 100644 --- a/tests/agent/test_compressor_stale_tool_images.py +++ b/tests/agent/test_compressor_stale_tool_images.py @@ -138,3 +138,74 @@ def test_user_uploads_are_not_retired(self): ) assert _content_has_images(out[0]["content"]) assert out[0]["content"][1]["image_url"]["url"].endswith("USERUPLOAD") + + +class TestSharedImageStripHelper: + """One strip policy for pass 3.5 and the demote pass (#92783 follow-up).""" + + def test_demote_pass_drops_stale_api_content_on_image_strip(self): + """Pass 2's image demotion must drop the api_content sidecar. + + Before the shared _strip_images_from_tool_msg helper, only pass 3.5 + dropped the sidecar; the demote branches left it behind, letting + replay restore pre-strip bytes. + """ + from agent.context_compressor import _strip_images_from_tool_msg + + msg = { + "role": "tool", + "tool_call_id": "c1", + "api_content": "stale exact-wire copy with image bytes", + "content": [ + {"type": "text", "text": "shot"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64," + "A" * 400}, + }, + ], + } + new_msg = _strip_images_from_tool_msg(msg) + assert new_msg is not None + assert "api_content" not in new_msg + # Input untouched (copy-on-write). + assert "api_content" in msg + assert _content_has_images(msg["content"]) + assert not _content_has_images(new_msg["content"]) + + def test_envelope_collapses_to_summary_string(self): + from agent.context_compressor import _strip_images_from_tool_msg + + msg = { + "role": "tool", + "tool_call_id": "c2", + "api_content": "stale", + "content": { + "_multimodal": True, + "content": [ + {"type": "text", "text": "s"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,XYZ"}, + }, + ], + "text_summary": "native shot", + }, + } + new_msg = _strip_images_from_tool_msg(msg) + assert new_msg is not None + assert isinstance(new_msg["content"], str) + assert "screenshot removed" in new_msg["content"] + assert "native shot" in new_msg["content"] + assert "api_content" not in new_msg + + def test_imageless_content_returns_none(self): + from agent.context_compressor import _strip_images_from_tool_msg + + msg = {"role": "tool", "tool_call_id": "c3", "content": "plain text"} + assert _strip_images_from_tool_msg(msg) is None + msg2 = { + "role": "tool", + "tool_call_id": "c4", + "content": [{"type": "text", "text": "no images here"}], + } + assert _strip_images_from_tool_msg(msg2) is None From c02fe6501bb1f59e47ebdfb55dcc649091ce0f42 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:00:32 +0530 Subject: [PATCH 079/666] fix(vision): shrink oversized history embeds via JPEG quality, not halving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PNG has no quality ladder, so a text-dense screenshot over the 256KB history-embed cap (#92699 / #92783) could only shrink by halving dimensions — 1568px dropped to ~784px and on-screen text became unreadable, the exact fidelity screenshot QA depends on. Add force_jpeg to _resize_image_for_vision: the two history-embed call sites (vision_analyze native, browser_vision native) re-encode resize-needing screenshots as JPEG so the quality ladder (85/70/50) absorbs the byte pressure and the readable resolution survives. Under-cap images are untouched and stay PNG; one-shot/reactive paths keep their existing format behavior. Flagged during the #92783 salvage review. --- tests/tools/test_vision_tools.py | 64 ++++++++++++++++++++++++++++++++ tools/browser_tool.py | 1 + tools/vision_tools.py | 21 +++++++++-- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_vision_tools.py b/tests/tools/test_vision_tools.py index 806382f649d2..58a6d8d00441 100644 --- a/tests/tools/test_vision_tools.py +++ b/tests/tools/test_vision_tools.py @@ -740,6 +740,70 @@ def test_no_pillow_returns_original(self, tmp_path): # Should return the original (oversized) data url assert len(result) > 100 + def test_force_jpeg_keeps_resolution_of_dense_png(self, tmp_path): + """A dense PNG over the byte cap shrinks via JPEG quality, not halving. + + PNG has no quality ladder — before force_jpeg, the only shrink lever + for a text-dense screenshot was halving dimensions (1568px → ~784px), + destroying text legibility (#92699 follow-up). With force_jpeg the + quality ladder absorbs the byte pressure and resolution survives. + """ + try: + from PIL import Image + except ImportError: + pytest.skip("Pillow not installed") + import base64 as _b64 + import random + from io import BytesIO + + # Photo-like PNG at exactly the long-edge cap: a gradient with + # low-amplitude noise defeats PNG's lossless filters (no exact + # repeats → >256KB) but JPEG's DCT quantization absorbs it easily — + # the realistic screenshot shape, unlike pure noise which no codec + # can fit in-budget at full resolution. + rng = random.Random(42) + img = Image.new("RGB", (1568, 900)) + img.putdata([ + ( + min(255, (x * 255) // 1568 + rng.randrange(12)), + min(255, (y * 255) // 900 + rng.randrange(12)), + min(255, ((x + y) * 255) // 2468 + rng.randrange(12)), + ) + for y in range(900) + for x in range(1568) + ]) + path = tmp_path / "dense.png" + img.save(path, "PNG") + + budget = 256 * 1024 + result = _resize_image_for_vision( + path, mime_type="image/png", + max_base64_bytes=budget, max_dimension=1568, + force_jpeg=True, + ) + assert result.startswith("data:image/jpeg;base64,") + assert len(result) <= budget + with Image.open(BytesIO(_b64.b64decode(result.partition(",")[2]))) as out: + # Resolution preserved (the PNG path would have halved to 784px). + assert max(out.size) == 1568 + + def test_force_jpeg_leaves_small_png_untouched(self, tmp_path): + """Under-cap images skip the resize entirely — still PNG.""" + try: + from PIL import Image + except ImportError: + pytest.skip("Pillow not installed") + img = Image.new("RGB", (10, 10), (0, 255, 0)) + path = tmp_path / "tiny.png" + img.save(path, "PNG") + + result = _resize_image_for_vision( + path, mime_type="image/png", + max_base64_bytes=256 * 1024, max_dimension=1568, + force_jpeg=True, + ) + assert result.startswith("data:image/png;base64,") + # --------------------------------------------------------------------------- # _image_exceeds_dimension — proactive embed-time pixel-cap detector diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 0d595f0f5ab0..fc27d85946d6 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -4745,6 +4745,7 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] mime_type="image/png", max_base64_bytes=_EMBED_TARGET_BYTES, max_dimension=_EMBED_MAX_DIMENSION, + force_jpeg=True, ) native_result = _build_native_vision_tool_result( image_url=str(screenshot_path), diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 074dd14103d2..e950bbb506a0 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -793,7 +793,8 @@ def _build_scale_note( def _resize_image_for_vision(image_path: Path, mime_type: Optional[str] = None, max_base64_bytes: int = _RESIZE_TARGET_BYTES, max_dimension: Optional[int] = None, - scale_out: Optional[dict] = None) -> str: + scale_out: Optional[dict] = None, + force_jpeg: bool = False) -> str: """Convert an image to a base64 data URL, auto-resizing if too large. Tries Pillow first to progressively downscale oversized images. If Pillow @@ -805,6 +806,13 @@ def _resize_image_for_vision(image_path: Path, mime_type: Optional[str] = None, count are forcibly downscaled even if they're under the byte budget. Anthropic enforces an 8000 px per-side cap independently of the 5 MB byte cap. + force_jpeg: Re-encode as JPEG even for PNG input when a resize is + needed. PNG has no quality ladder — its only shrink lever is + halving dimensions, which destroys text legibility on dense + screenshots. History-reuse embeds (#92699) opt in so a text-heavy + screenshot keeps its readable resolution and shrinks via JPEG + quality instead. Images already under both caps are returned + unchanged (still PNG). Returns the base64 data URL string. """ @@ -862,8 +870,14 @@ def _resize_image_for_vision(image_path: Path, mime_type: Optional[str] = None, max_base64_bytes / (1024 * 1024), max_dimension) mime = mime_type or _determine_mime_type(image_path) - # Choose output format: JPEG for photos (smaller), PNG for transparency - pil_format = "PNG" if mime == "image/png" else "JPEG" + # Choose output format: JPEG for photos (smaller), PNG for transparency. + # force_jpeg overrides for history-reuse embeds: a resize-needing PNG + # screenshot re-encodes as JPEG so the quality ladder can shrink bytes + # without halving resolution (text legibility, #92699). + if force_jpeg: + pil_format = "JPEG" + else: + pil_format = "PNG" if mime == "image/png" else "JPEG" out_mime = "image/png" if pil_format == "PNG" else "image/jpeg" try: @@ -1254,6 +1268,7 @@ async def _vision_analyze_native( max_base64_bytes=_EMBED_TARGET_BYTES, max_dimension=_EMBED_MAX_DIMENSION, scale_out=_scale_info, + force_jpeg=True, ) # If even resizing can't get under the absolute hard ceiling, # there's nothing more we can do — reject rather than embed a From 30d4555085ec684ff140d5841b5456b5d2291a72 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:03:01 +0530 Subject: [PATCH 080/666] =?UTF-8?q?fix(vision):=20review=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20LA/PA=20JPEG=20guard,=20third=20embed=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pass findings on the force_jpeg change: - Broaden the JPEG mode guard from {RGBA, P} to 'not in {RGB, L}': force_jpeg newly routes PNG inputs to the JPEG encoder, and an LA-mode PNG (grayscale+alpha) would crash img.save() with 'cannot write mode LA as JPEG'. - browser_use_cli's _native_screenshot_result is the THIRD native history-embed site: it baked the data URL into a _multimodal tool result with the 5 MB one-shot default and no dimension cap. Apply the same 256KB/1568px/force_jpeg history-reuse policy as the two sites already migrated. --- tests/tools/test_vision_tools.py | 36 ++++++++++++++++++++++++++++++++ tools/browser_use_cli.py | 14 ++++++++++++- tools/vision_tools.py | 6 ++++-- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_vision_tools.py b/tests/tools/test_vision_tools.py index 58a6d8d00441..45d9ed757733 100644 --- a/tests/tools/test_vision_tools.py +++ b/tests/tools/test_vision_tools.py @@ -804,6 +804,42 @@ def test_force_jpeg_leaves_small_png_untouched(self, tmp_path): ) assert result.startswith("data:image/png;base64,") + def test_force_jpeg_handles_alpha_and_exotic_modes(self, tmp_path): + """RGBA/LA/P PNGs must convert cleanly — JPEG can't encode alpha. + + force_jpeg newly routes PNG inputs to the JPEG encoder, so modes + JPEG can't save (LA grayscale+alpha especially) must be normalized + to RGB instead of crashing img.save(). + """ + try: + from PIL import Image + except ImportError: + pytest.skip("Pillow not installed") + import random + + rng = random.Random(7) + for mode, size in (("RGBA", (600, 400)), ("LA", (600, 400)), ("P", (600, 400))): + img = Image.new(mode, size) + # Noise so the PNG exceeds a tiny budget and the resize fires. + if mode == "P": + img.putpalette([i % 256 for i in range(768)]) + img.putdata([rng.randrange(256) for _ in range(size[0] * size[1])]) + else: + bands = len(img.getbands()) + img.putdata([ + tuple(rng.randrange(256) for _ in range(bands)) + for _ in range(size[0] * size[1]) + ]) + path = tmp_path / f"img_{mode}.png" + img.save(path, "PNG") + + result = _resize_image_for_vision( + path, mime_type="image/png", + max_base64_bytes=16 * 1024, max_dimension=1568, + force_jpeg=True, + ) + assert result.startswith("data:image/jpeg;base64,"), mode + # --------------------------------------------------------------------------- # _image_exceeds_dimension — proactive embed-time pixel-cap detector diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index 0c99f42c4677..294f11bc68a0 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -418,13 +418,25 @@ def _native_screenshot_result(result: Dict[str, Any], path: str) -> Optional[Dic from pathlib import Path from tools.vision_tools import ( + _EMBED_MAX_DIMENSION, + _EMBED_TARGET_BYTES, _resize_image_for_vision, _should_use_native_vision_fast_path, ) if not _should_use_native_vision_fast_path(): return None - data_url = _resize_image_for_vision(Path(path)) + # History-reuse cap (#92699): this data URL bakes into the tool + # result and is re-sent on every later turn — same policy as the + # vision_analyze / browser_vision native embeds (256 KB / 1568 px, + # JPEG quality ladder instead of PNG dimension-halving). + data_url = _resize_image_for_vision( + Path(path), + mime_type="image/png", + max_base64_bytes=_EMBED_TARGET_BYTES, + max_dimension=_EMBED_MAX_DIMENSION, + force_jpeg=True, + ) text = json.dumps(result, ensure_ascii=False) return { "_multimodal": True, diff --git a/tools/vision_tools.py b/tools/vision_tools.py index e950bbb506a0..8d05c153e6f0 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -887,8 +887,10 @@ def _resize_image_for_vision(image_path: Path, mime_type: Optional[str] = None, if data_url is None: data_url = _image_to_base64_data_url(image_path, mime_type=mime_type) return data_url # fall through to size-check in caller - # Convert RGBA to RGB for JPEG output - if pil_format == "JPEG" and img.mode in {"RGBA", "P"}: + # JPEG cannot encode alpha or palette/grayscale-alpha modes; normalize + # anything that isn't already plain RGB/grayscale. force_jpeg newly + # routes PNG inputs here, so exotic modes (LA/PA) must not crash save(). + if pil_format == "JPEG" and img.mode not in {"RGB", "L"}: img = img.convert("RGB") # Strategy: halve dimensions until both base64 fits AND pixel dimensions From 933c209e96630a6026b0a18ecf6a86e65110f5b8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:47:03 -0700 Subject: [PATCH 081/666] fix(compression): -900k Codex variants keep the global 50% threshold; 85% autoraise stays on 272K base slugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 85% compaction autoraise exists to stop wasting the small advertised 272K Codex window. -900k large-context picker variants (#92797) run at ~900K, where the global compression.threshold (default 50%, ~450K) is the right behavior — autoraising them to 85% (~765K) would delay compaction far past what the user configured. - _is_codex_gpt54_or_gpt55() excludes valid -900k variants, so both the 85% override and the one-time autoraise notice skip those sessions. - Base slugs are unchanged: 272K window + 85% autoraise. - Tests: variant/base threshold pairs incl. namespaced ids; docs note in the -900k section. --- agent/auxiliary_client.py | 8 ++++++ tests/agent/test_arcee_trinity_overrides.py | 25 +++++++++++++++++++ .../context-compression-and-caching.md | 5 ++++ 3 files changed, 38 insertions(+) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 8d387b86d1e0..b824a89b3f58 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -678,11 +678,19 @@ def _is_codex_gpt54_or_gpt55(model: Optional[str], provider: Optional[str] = Non ``compression.codex_gpt55_autoraise`` config key.) The exact ``gpt-daybreak-blue-latest`` Codex slug is also a verified Sol-family alias and receives the same autoraise. + + ``-900k`` large-context picker variants are explicitly EXCLUDED: the + 85% autoraise exists to stop wasting a small 272K window, and a 900K + window doesn't have that problem — those sessions keep the user's + global ``compression.threshold`` (default 50%, ~450K). """ prov = (provider or "").strip().lower() if prov != "openai-codex": return False bare = (model or "").strip().lower().rsplit("/", 1)[-1] + from agent.model_metadata import is_codex_context_variant + if is_codex_context_variant(bare): + return False return ( bare == "gpt-5.4" or bare.startswith("gpt-5.4-") diff --git a/tests/agent/test_arcee_trinity_overrides.py b/tests/agent/test_arcee_trinity_overrides.py index 63e6bbc161d2..f99829521b1e 100644 --- a/tests/agent/test_arcee_trinity_overrides.py +++ b/tests/agent/test_arcee_trinity_overrides.py @@ -96,6 +96,31 @@ def test_compression_threshold_for_codex_gpt55() -> None: assert _compression_threshold_for_model("gpt-daybreak-blue-latest", "openai-codex") == 0.85 +@pytest.mark.parametrize( + "model", + [ + "gpt-5.6-sol-900k", + "gpt-5.6-terra-900k", + "gpt-5.6-luna-900k", + "gpt-5.4-900k", + "gpt-daybreak-blue-latest-900k", + "openai/gpt-5.6-sol-900k", + ], +) +def test_900k_variants_keep_global_threshold(model) -> None: + """The 85% autoraise compensates for the small 272K window; ``-900k`` + opt-in variants run at ~900K, so they keep the user's global + ``compression.threshold`` (default 50%) — no override returned.""" + assert _is_codex_gpt54_or_gpt55(model, "openai-codex") is False + assert _compression_threshold_for_model(model, "openai-codex") is None + + +def test_base_slugs_still_autoraised_alongside_900k_variants() -> None: + """Sanity pair: the base slug autoraises while its variant does not.""" + assert _compression_threshold_for_model("gpt-5.6-sol", "openai-codex") == 0.85 + assert _compression_threshold_for_model("gpt-5.6-sol-900k", "openai-codex") is None + + diff --git a/website/docs/developer-guide/context-compression-and-caching.md b/website/docs/developer-guide/context-compression-and-caching.md index 08f2854c01fa..18a70d97923f 100644 --- a/website/docs/developer-guide/context-compression-and-caching.md +++ b/website/docs/developer-guide/context-compression-and-caching.md @@ -208,6 +208,11 @@ the model id is sent to the backend, and pricing/usage accounting treats them as the base model. Slugs that genuinely enforce 272K (gpt-5.5, gpt-5.4-mini) have no `-900k` variant. +Compaction thresholds follow the window: base slugs (272K) get the **85% +autoraise** described above, while `-900k` variants keep your global +`compression.threshold` (default 50%, ~450K) — the autoraise exists to stop +wasting a small window, which a 900K window doesn't need. + ### Codex app-server thread compaction Codex app-server sessions (`api_mode: codex_app_server` — the codex CLI/agent From 10f0d2278bf7ea7dd005e5c8b1b9dc47d108a1ec Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:13:24 -0700 Subject: [PATCH 082/666] =?UTF-8?q?feat(desktop):=20client-direct=20voice?= =?UTF-8?q?=20=E2=80=94=20use=20the=20active=20profile's=20STT/TTS=20keys?= =?UTF-8?q?=20from=20the=20desktop,=20no=20audio=20relay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lowest-hop voice path in both directions for desktop + remote gateway: mic audio goes straight to the profile's STT provider and reply text is synthesized on the desktop with the profile's TTS provider. The desktop-gateway link carries only text (which the chat stream carries anyway). No second key store: GET /api/audio/voice-config returns the profile's resolved provider/model/language/key using the exact resolution chains transcription_tools/tts_tool use, over the authenticated REST channel. Keys live in renderer memory only. Backend: - tools/voice_client_config.py: single resolver; per-provider client wire shapes (openai-multipart, xai-stt, elevenlabs-stt, openai-speech, elevenlabs-tts). Server-host-only providers (local whisper, edge, command/plugin) and missing credentials resolve to {mode: relay}. xAI OAuth stays relay (bearer refreshes server-side). - web_server.py: GET /api/audio/voice-config, profile-scoped via the same _config_profile_scope seam as /api/audio/transcribe. - config_defaults.py: voice.client_direct gate (default true). Desktop: - lib/voice-client-direct.ts: config fetch keyed by (connection, profile) with 60s TTL, provider-direct STT + TTS calls, sentence cutter mirroring the server pipeline's contract. - Dictation (use-prompt-actions + session-tile) tries client-direct first; null -> existing relay unchanged; provider rejections surface. - voice-playback.ts: client-direct speech session as the top rung of startSpeechStream/playSpeechText; WS relay + POST fallback unchanged below it. Barge-in via the same stopVoicePlayback sequence bump. Validation: 13/13 backend E2E (real temp HERMES_HOME + real resolution), live FastAPI TestClient E2E (direct + gate-flip), 15/15 client tests (wire shapes, scope-keyed caching, rejection surfacing, sentence cutter), sibling suites 72/72 + 36/36, tsc + eslint + ruff clean. Docs: voice-mode.md client-direct section ships in this PR. --- apps/desktop/src/app/chat/session-tile.tsx | 15 +- .../session/hooks/use-prompt-actions/index.ts | 13 + apps/desktop/src/app/settings/constants.ts | 3 +- .../src/lib/voice-client-direct.test.ts | 277 ++++++++++++++ apps/desktop/src/lib/voice-client-direct.ts | 350 ++++++++++++++++++ apps/desktop/src/lib/voice-playback.ts | 206 ++++++++++- hermes_cli/config_defaults.py | 5 + hermes_cli/web_server.py | 36 ++ tests/tools/test_voice_client_config.py | 188 ++++++++++ tools/voice_client_config.py | 337 +++++++++++++++++ .../docs/user-guide/features/voice-mode.md | 18 + 11 files changed, 1440 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/lib/voice-client-direct.test.ts create mode 100644 apps/desktop/src/lib/voice-client-direct.ts create mode 100644 tests/tools/test_voice_client_config.py create mode 100644 tools/voice_client_config.py diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index fda0fd3ef889..d2113ada5378 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -34,6 +34,7 @@ import { transcribeAudio } from '@/hermes' import { useI18n } from '@/i18n' import type { ChatMessage } from '@/lib/chat-messages' import { NEW_SESSION_TITLE, sessionTitle } from '@/lib/chat-runtime' +import { transcribeAudioClientDirect } from '@/lib/voice-client-direct' import { createComposerAttachmentScope, draftTitleFor } from '@/store/composer' import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout' import { $activeGatewayProfile } from '@/store/profile' @@ -110,8 +111,18 @@ function buildTileView(storedSessionId: string): SessionView { // tiles have no pin/delete affordance, and transcription needs no per-tile state. const noop = () => undefined -const tileTranscribeAudio = async (audio: Blob) => - (await transcribeAudio(await blobToDataUrl(audio), audio.type)).transcript +const tileTranscribeAudio = async (audio: Blob) => { + // Client-direct first (profile's own STT provider, no gateway audio hop); + // relay when the provider is not client-callable. Same ladder as the main + // composer's transcribeVoiceAudio. + const direct = await transcribeAudioClientDirect(audio) + + if (direct !== null) { + return direct + } + + return (await transcribeAudio(await blobToDataUrl(audio), audio.type)).transcript +} function TileChat({ runtimeId, diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 6324176f0f9d..16e19ec41301 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -12,6 +12,7 @@ import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { triggerHaptic } from '@/lib/haptics' import { setMutableRef } from '@/lib/mutable-ref' import { normalize } from '@/lib/text' +import { transcribeAudioClientDirect } from '@/lib/voice-client-direct' import { clearClarifyRequest } from '@/store/clarify' import { $composerAttachments, @@ -626,6 +627,18 @@ export function usePromptActions({ throw new Error(copy.sttDisabled) } + // Client-direct first: mic audio goes straight to the profile's STT + // provider (config + key fetched from the connected gateway), cutting + // the desktop→gateway audio hop. `null` = provider not client-callable + // (local whisper, command providers, older backend) → relay unchanged. + // Provider REJECTIONS surface — re-running the same request through + // the relay would fail identically, just slower. + const direct = await transcribeAudioClientDirect(audio) + + if (direct !== null) { + return direct + } + const dataUrl = await blobToDataUrl(audio) const result = await transcribeAudio(dataUrl, audio.type) diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index ef78dceea96d..03c19496fd73 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -734,7 +734,8 @@ export const SECTIONS: DesktopConfigSection[] = [ 'stt.elevenlabs.tag_audio_events', 'stt.elevenlabs.diarize', 'voice.record_key', - 'voice.max_recording_seconds' + 'voice.max_recording_seconds', + 'voice.client_direct' ] }, { diff --git a/apps/desktop/src/lib/voice-client-direct.test.ts b/apps/desktop/src/lib/voice-client-direct.test.ts new file mode 100644 index 000000000000..a3a028d846ca --- /dev/null +++ b/apps/desktop/src/lib/voice-client-direct.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { setApiRequestConnection, setApiRequestProfile } from '@/hermes' + +import { + clearVoiceClientConfigCache, + cutSentences, + type DirectTtsConfig, + fetchVoiceClientConfig, + synthesizeSpeechClientDirect, + transcribeAudioClientDirect +} from './voice-client-direct' + +const directStt = { + mode: 'direct', + wire: 'openai-multipart', + provider: 'groq', + base_url: 'https://api.groq.com/openai/v1', + api_key: 'gsk_test', + model: 'whisper-large-v3-turbo', + language: 'en' +} as const + +const relay = { mode: 'relay', reason: 'local provider' } as const + +function mockDesktopApi(response: unknown) { + const api = vi.fn(async (_request: unknown) => response) + + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { api } + }) + + return api +} + +describe('fetchVoiceClientConfig', () => { + beforeEach(() => clearVoiceClientConfigCache()) + + afterEach(() => { + setApiRequestConnection(null) + setApiRequestProfile(null) + Reflect.deleteProperty(window, 'hermesDesktop') + vi.restoreAllMocks() + }) + + it('fetches from /api/audio/voice-config with the ambient scope and caches per scope', async () => { + const api = mockDesktopApi({ ok: true, stt: directStt, tts: relay }) + setApiRequestConnection('gw-remote') + setApiRequestProfile('research') + + const first = await fetchVoiceClientConfig() + const second = await fetchVoiceClientConfig() + + expect(first?.stt).toEqual(directStt) + expect(second).toBe(first) + // One fetch — the second call served from the scope cache. + expect(api).toHaveBeenCalledTimes(1) + + const request = api.mock.calls[0][0] as { connectionId?: string; path: string; profile?: string } + expect(request.path).toBe('/api/audio/voice-config') + expect(request.profile).toBe('research') + // hermesApi carries the ambient registry connection tag — the config + // must come from the backend the user is talking to. + expect(request.connectionId).toBe('gw-remote') + }) + + it('a scope switch never reuses another scope\'s credentials', async () => { + const api = mockDesktopApi({ ok: true, stt: directStt, tts: relay }) + setApiRequestProfile('alpha') + await fetchVoiceClientConfig() + + setApiRequestProfile('beta') + await fetchVoiceClientConfig() + + expect(api).toHaveBeenCalledTimes(2) + }) + + it('resolves null on an older backend without the endpoint', async () => { + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { api: vi.fn(async () => Promise.reject(new Error('404'))) } + }) + + expect(await fetchVoiceClientConfig()).toBeNull() + }) +}) + +describe('transcribeAudioClientDirect', () => { + beforeEach(() => clearVoiceClientConfigCache()) + + afterEach(() => { + setApiRequestConnection(null) + setApiRequestProfile(null) + Reflect.deleteProperty(window, 'hermesDesktop') + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('POSTs multipart to the provider and returns the transcript', async () => { + mockDesktopApi({ ok: true, stt: directStt, tts: relay }) + + const fetchMock = vi.fn(async () => new Response(' hello world ', { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + const transcript = await transcribeAudioClientDirect(new Blob(['x'], { type: 'audio/webm' })) + + expect(transcript).toBe('hello world') + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.groq.com/openai/v1/audio/transcriptions') + expect((init.headers as Record).Authorization).toBe('Bearer gsk_test') + + const form = init.body as FormData + expect(form.get('model')).toBe('whisper-large-v3-turbo') + expect(form.get('language')).toBe('en') + expect(form.get('response_format')).toBe('text') + }) + + it('returns null (relay) when the provider is not client-callable', async () => { + mockDesktopApi({ ok: true, stt: relay, tts: relay }) + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + expect(await transcribeAudioClientDirect(new Blob(['x']))).toBeNull() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('surfaces provider rejections instead of silently relaying', async () => { + mockDesktopApi({ ok: true, stt: directStt, tts: relay }) + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ error: { message: 'invalid api key' } }), { status: 401 })) + ) + + await expect(transcribeAudioClientDirect(new Blob(['x']))).rejects.toThrow(/groq STT error.*invalid api key/) + }) + + it('speaks the xai wire shape', async () => { + mockDesktopApi({ + ok: true, + stt: { ...directStt, wire: 'xai-stt', provider: 'xai', base_url: 'https://api.x.ai/v1', model: null }, + tts: relay + }) + + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ text: 'grok heard this' }), { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + expect(await transcribeAudioClientDirect(new Blob(['x']))).toBe('grok heard this') + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.x.ai/v1/stt') + expect((init.body as FormData).get('format')).toBe('true') + }) + + it('speaks the elevenlabs wire shape with xi-api-key auth', async () => { + mockDesktopApi({ + ok: true, + stt: { + ...directStt, + wire: 'elevenlabs-stt', + provider: 'elevenlabs', + base_url: 'https://api.elevenlabs.io/v1', + model: 'scribe_v2' + }, + tts: relay + }) + + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ text: 'scribe text' }), { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + expect(await transcribeAudioClientDirect(new Blob(['x']))).toBe('scribe text') + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.elevenlabs.io/v1/speech-to-text') + expect((init.headers as Record)['xi-api-key']).toBe('gsk_test') + expect((init.body as FormData).get('model_id')).toBe('scribe_v2') + }) +}) + +describe('synthesizeSpeechClientDirect', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + const openaiTts: DirectTtsConfig = { + mode: 'direct', + wire: 'openai-speech', + provider: 'openai', + base_url: 'https://api.openai.com/v1', + api_key: 'sk_tts', + model: 'gpt-4o-mini-tts', + voice: 'nova', + speed: null + } + + it('POSTs the openai speech shape and returns audio bytes', async () => { + const bytes = new Uint8Array([1, 2, 3]).buffer + + const fetchMock = vi.fn(async () => new Response(bytes, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + const audio = await synthesizeSpeechClientDirect(openaiTts, 'Hello there.') + + expect(new Uint8Array(audio)).toEqual(new Uint8Array([1, 2, 3])) + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.openai.com/v1/audio/speech') + + const body = JSON.parse(String(init.body)) as Record + expect(body.model).toBe('gpt-4o-mini-tts') + expect(body.voice).toBe('nova') + expect(body.input).toBe('Hello there.') + expect(body.speed).toBeUndefined() + }) + + it('speaks the elevenlabs tts shape with the voice in the path', async () => { + const fetchMock = vi.fn(async () => new Response(new ArrayBuffer(4), { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + await synthesizeSpeechClientDirect( + { + ...openaiTts, + wire: 'elevenlabs-tts', + provider: 'elevenlabs', + base_url: 'https://api.elevenlabs.io/v1', + model: 'eleven_turbo_v2', + voice: 'voice123' + }, + 'Hi.' + ) + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.elevenlabs.io/v1/text-to-speech/voice123') + expect((init.headers as Record)['xi-api-key']).toBe('sk_tts') + }) + + it('throws on provider rejection', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('quota exceeded', { status: 429 }))) + + await expect(synthesizeSpeechClientDirect(openaiTts, 'Hi.')).rejects.toThrow(/openai TTS error.*429/) + }) +}) + +describe('cutSentences', () => { + it('emits complete sentences and holds the incomplete tail', () => { + const { sentences, rest } = cutSentences('This is the first full sentence. And then it keeps goi', false) + + expect(sentences).toEqual(['This is the first full sentence.']) + expect(rest).toBe('And then it keeps goi') + }) + + it('buffers too-short fragments instead of firing per abbreviation', () => { + const { sentences, rest } = cutSentences('e.g. it continues', false) + + expect(sentences).toEqual([]) + expect(rest).toBe('e.g. it continues') + }) + + it('flush drains everything including the tail', () => { + const { sentences, rest } = cutSentences('First complete sentence right here. tail bit', true) + + expect(sentences).toEqual(['First complete sentence right here.', 'tail bit']) + expect(rest).toBe('') + }) + + it('handles CJK terminators', () => { + const { sentences } = cutSentences( + '这是一个完整的中文句子,它的长度足够超过最小句子门槛,所以会被切分出来。 下一句', + true + ) + + expect(sentences[0]).toContain('。') + expect(sentences).toHaveLength(2) + }) +}) diff --git a/apps/desktop/src/lib/voice-client-direct.ts b/apps/desktop/src/lib/voice-client-direct.ts new file mode 100644 index 000000000000..bef4480af7fc --- /dev/null +++ b/apps/desktop/src/lib/voice-client-direct.ts @@ -0,0 +1,350 @@ +import { profileScoped } from '@/api/client' +import { getApiRequestConnection, getApiRequestProfile, hermesApi } from '@/hermes' + +/** + * Client-direct voice: call the active profile's STT/TTS providers straight + * from the desktop, cutting the audio relay hop through the gateway. + * + * The gateway stays the single source of truth for WHICH provider and WHICH + * credentials to use — `GET /api/audio/voice-config` returns the same + * resolution the gateway's own relay endpoints would apply (see + * `tools/voice_client_config.py`). This module only executes the provider + * call locally. Direction of travel: + * + * mic → provider (audio up, once) → text → gateway (STT) + * gateway → text (already streaming) → provider → speaker (TTS) + * + * Keys live in renderer MEMORY only — never persisted, never logged. A + * provider that can only run on the gateway host resolves to + * `{mode:'relay'}` and callers fall back to the existing relay endpoints. + */ + +export interface DirectSttConfig { + mode: 'direct' + wire: 'elevenlabs-stt' | 'openai-multipart' | 'xai-stt' + provider: string + base_url: string + api_key: string + model: null | string + language: null | string +} + +export interface DirectTtsConfig { + mode: 'direct' + wire: 'elevenlabs-tts' | 'openai-speech' + provider: string + base_url: string + api_key: string + model: null | string + voice: null | string + speed: null | number +} + +interface RelayConfig { + mode: 'relay' + reason?: string +} + +export interface VoiceClientConfig { + stt: DirectSttConfig | RelayConfig + tts: DirectTtsConfig | RelayConfig +} + +// --------------------------------------------------------------------------- +// Config fetch + cache. Keyed by (connection, profile) so a profile/backend +// switch never reuses another scope's credentials; TTL'd so a config change +// on the gateway propagates within a minute without a per-utterance fetch. +// --------------------------------------------------------------------------- + +const CONFIG_TTL_MS = 60_000 + +let cached: { key: string; at: number; config: VoiceClientConfig } | null = null +let inflight: { key: string; promise: Promise } | null = null + +function scopeKey(): string { + return `${getApiRequestConnection() ?? 'local'}::${getApiRequestProfile() ?? 'default'}` +} + +/** Drop cached credentials (used by tests; scope changes rotate the key). */ +export function clearVoiceClientConfigCache(): void { + cached = null + inflight = null +} + +export async function fetchVoiceClientConfig(): Promise { + const key = scopeKey() + + if (cached && cached.key === key && Date.now() - cached.at < CONFIG_TTL_MS) { + return cached.config + } + + if (inflight && inflight.key === key) { + return inflight.promise + } + + const promise = (async () => { + try { + // hermesApi carries connectionScoped(); profileScoped() adds the + // profile — the same routing every relay audio call uses, so the + // config comes from the backend the user is actually talking to. + const response = await hermesApi<{ ok: boolean } & VoiceClientConfig>({ + ...profileScoped(), + path: '/api/audio/voice-config' + }) + + if (!response?.ok || !response.stt || !response.tts) { + return null + } + + const config: VoiceClientConfig = { stt: response.stt, tts: response.tts } + cached = { key, at: Date.now(), config } + + return config + } catch { + // Older backend without the endpoint / transient failure → relay. + return null + } finally { + inflight = null + } + })() + + inflight = { key, promise } + + return promise +} + +// --------------------------------------------------------------------------- +// STT — audio blob → transcript, provider-direct. +// --------------------------------------------------------------------------- + +function sttFileName(audio: Blob): string { + const subtype = (audio.type.split(';')[0].split('/')[1] || 'webm').toLowerCase() + + return `recording.${subtype === 'mpeg' ? 'mp3' : subtype}` +} + +async function providerErrorText(response: Response): Promise { + const body = await response.text().catch(() => '') + + try { + const parsed = JSON.parse(body) + const detail = parsed?.error?.message ?? parsed?.detail ?? parsed?.error + + if (typeof detail === 'string' && detail) { + return detail + } + } catch { + // fall through to raw body + } + + return body.slice(0, 300) +} + +/** + * Transcribe provider-direct. Returns the transcript ('' = silence), or null + * when the profile's provider isn't client-callable — the caller relays. + * Provider REJECTIONS throw: the configured provider said no, and silently + * re-running the same request through the gateway would just fail again + * slower and hide the real error. + */ +export async function transcribeAudioClientDirect(audio: Blob): Promise { + const config = await fetchVoiceClientConfig() + const stt = config?.stt + + if (!stt || stt.mode !== 'direct') { + return null + } + + if (stt.wire === 'openai-multipart') { + const form = new FormData() + form.set('file', audio, sttFileName(audio)) + + if (stt.model) { + form.set('model', stt.model) + } + + form.set('response_format', 'text') + + if (stt.language) { + form.set('language', stt.language) + } + + const response = await fetch(`${stt.base_url.replace(/\/+$/, '')}/audio/transcriptions`, { + method: 'POST', + headers: { Authorization: `Bearer ${stt.api_key}` }, + body: form + }) + + if (!response.ok) { + throw new Error(`${stt.provider} STT error (HTTP ${response.status}): ${await providerErrorText(response)}`) + } + + return (await response.text()).trim() + } + + if (stt.wire === 'xai-stt') { + const form = new FormData() + form.set('file', audio, sttFileName(audio)) + form.set('format', 'true') + + if (stt.language) { + form.set('language', stt.language) + } + + const response = await fetch(`${stt.base_url.replace(/\/+$/, '')}/stt`, { + method: 'POST', + headers: { Authorization: `Bearer ${stt.api_key}` }, + body: form + }) + + if (!response.ok) { + throw new Error(`xAI STT error (HTTP ${response.status}): ${await providerErrorText(response)}`) + } + + const result = (await response.json()) as { text?: string } + + return (result.text || '').trim() + } + + if (stt.wire === 'elevenlabs-stt') { + const form = new FormData() + form.set('file', audio, sttFileName(audio)) + + if (stt.model) { + form.set('model_id', stt.model) + } + + if (stt.language) { + form.set('language_code', stt.language) + } + + const response = await fetch(`${stt.base_url.replace(/\/+$/, '')}/speech-to-text`, { + method: 'POST', + headers: { 'xi-api-key': stt.api_key }, + body: form + }) + + if (!response.ok) { + throw new Error(`ElevenLabs STT error (HTTP ${response.status}): ${await providerErrorText(response)}`) + } + + const result = (await response.json()) as { text?: string } + + return (result.text || '').trim() + } + + return null +} + +// --------------------------------------------------------------------------- +// TTS — text → audio bytes, provider-direct. One call per sentence/segment; +// the playback queue in voice-playback.ts owns ordering and barge-in. +// --------------------------------------------------------------------------- + +/** Resolve the profile's TTS config when it is client-callable, else null. */ +export async function directTtsConfig(): Promise { + const config = await fetchVoiceClientConfig() + + return config?.tts && config.tts.mode === 'direct' ? config.tts : null +} + +/** Synthesize one text segment to audio bytes (mp3). Throws on provider rejection. */ +export async function synthesizeSpeechClientDirect(tts: DirectTtsConfig, text: string): Promise { + if (tts.wire === 'openai-speech') { + const body: Record = { + model: tts.model, + voice: tts.voice, + input: text, + response_format: 'mp3' + } + + if (tts.speed && tts.speed !== 1) { + body.speed = tts.speed + } + + const response = await fetch(`${tts.base_url.replace(/\/+$/, '')}/audio/speech`, { + method: 'POST', + headers: { + Authorization: `Bearer ${tts.api_key}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }) + + if (!response.ok) { + throw new Error(`${tts.provider} TTS error (HTTP ${response.status}): ${await providerErrorText(response)}`) + } + + return response.arrayBuffer() + } + + if (tts.wire === 'elevenlabs-tts') { + const response = await fetch( + `${tts.base_url.replace(/\/+$/, '')}/text-to-speech/${encodeURIComponent(tts.voice || '')}`, + { + method: 'POST', + headers: { + 'xi-api-key': tts.api_key, + 'Content-Type': 'application/json', + Accept: 'audio/mpeg' + }, + body: JSON.stringify({ text, model_id: tts.model }) + } + ) + + if (!response.ok) { + throw new Error(`ElevenLabs TTS error (HTTP ${response.status}): ${await providerErrorText(response)}`) + } + + return response.arrayBuffer() + } + + throw new Error(`Unknown TTS wire: ${(tts as { wire?: string }).wire}`) +} + +// --------------------------------------------------------------------------- +// Sentence cutter for the streaming TTS session — mirrors the server-side +// SentenceChunker's contract: emit complete sentences as they form, hold +// the incomplete tail, flush everything on finish. +// --------------------------------------------------------------------------- + +const SENTENCE_BOUNDARY_RE = /[.!?…。!?]+["'”’)\]]*\s+/g +const MIN_SENTENCE_CHARS = 24 + +export function cutSentences(buffer: string, flush: boolean): { sentences: string[]; rest: string } { + const sentences: string[] = [] + let rest = buffer + let start = 0 + + SENTENCE_BOUNDARY_RE.lastIndex = 0 + + let match = SENTENCE_BOUNDARY_RE.exec(buffer) + + while (match) { + const end = match.index + match[0].length + const candidate = buffer.slice(start, end).trim() + + // Too-short fragments ("e.g. ", "1. ") stay buffered so we don't fire a + // provider call per abbreviation — unless a later boundary extends them. + if (candidate.length >= MIN_SENTENCE_CHARS) { + sentences.push(candidate) + start = end + } + + match = SENTENCE_BOUNDARY_RE.exec(buffer) + } + + rest = buffer.slice(start) + + if (flush) { + const tail = rest.trim() + + if (tail) { + sentences.push(tail) + } + + rest = '' + } + + return { sentences, rest } +} diff --git a/apps/desktop/src/lib/voice-playback.ts b/apps/desktop/src/lib/voice-playback.ts index 48a51d370817..315e1108b0a5 100644 --- a/apps/desktop/src/lib/voice-playback.ts +++ b/apps/desktop/src/lib/voice-playback.ts @@ -1,6 +1,12 @@ import { resolveGatewayWsUrl } from '@hermes/shared' import { getApiRequestProfile, speakText } from '@/hermes' +import { + cutSentences, + directTtsConfig, + type DirectTtsConfig, + synthesizeSpeechClientDirect +} from '@/lib/voice-client-direct' import { $voicePlayback, setVoicePlaybackState, @@ -142,6 +148,153 @@ export interface SpeechStreamSession { done: Promise<'done' | 'fallback'> } +// --------------------------------------------------------------------------- +// Client-direct path — synthesize on the DESKTOP with the profile's own TTS +// provider (config + key fetched from the connected gateway). Reply text is +// already streaming to the renderer over the chat socket, so the gateway +// link carries no audio at all: text → provider → speaker, one hop. +// Sentence-cut like the server pipeline; sequential playback; barge-in via +// the same stopVoicePlayback() sequence bump. +// --------------------------------------------------------------------------- + +function openClientDirectSpeechSession(tts: DirectTtsConfig, options: VoicePlaybackOptions): SpeechStreamSession { + let buffer = '' + let finished = false + let settled = false + let started = false + const queue: string[] = [] + let synthesizing = false + let playing: HTMLAudioElement | null = null + + let settle: (value: 'done' | 'fallback') => void = () => undefined + + const done = new Promise<'done' | 'fallback'>(resolve => { + settle = value => { + if (settled) { + return + } + + settled = true + currentStop = null + + if (playing) { + playing.pause() + playing.src = '' + playing = null + } + + resolve(value) + } + }) + + currentStop = () => settle(started ? 'done' : 'fallback') + + const pump = async () => { + if (synthesizing || settled) { + return + } + + synthesizing = true + + try { + while (queue.length > 0 && !settled) { + const sentence = queue.shift()! + + let bytes: ArrayBuffer + + try { + bytes = await synthesizeSpeechClientDirect(tts, sentence) + } catch { + // Provider rejected mid-reply. Nothing played yet → let the caller + // fall back to the relay with the full text. Mid-playback → treat + // what played as the playback (replaying would stutter). + settle(started ? 'done' : 'fallback') + + return + } + + if (settled) { + return + } + + if (!started) { + started = true + setVoicePlaybackState(currentState('speaking', options)) + } + + const url = URL.createObjectURL(new Blob([bytes], { type: 'audio/mpeg' })) + + try { + await new Promise((resolve, reject) => { + const audio = new Audio(url) + playing = audio + audio.addEventListener('ended', () => resolve(), { once: true }) + audio.addEventListener('error', () => reject(new Error('Playback failed')), { once: true }) + void audio.play().catch(reject) + }) + } catch { + settle(started ? 'done' : 'fallback') + + return + } finally { + playing = null + URL.revokeObjectURL(url) + } + } + + if (finished && queue.length === 0 && !settled) { + settle(started ? 'done' : 'fallback') + } + } finally { + synthesizing = false + + // Deltas that arrived while the last sentence was playing. + if (!settled && queue.length > 0) { + void pump() + } else if (!settled && finished && queue.length === 0) { + settle(started ? 'done' : 'fallback') + } + } + } + + const ingest = (flush: boolean) => { + const cut = cutSentences(buffer, flush) + buffer = cut.rest + + if (cut.sentences.length > 0) { + // Sanitize per sentence — same granularity as the server pipeline + // (markdown constructs can span delta boundaries, sentences can't). + for (const sentence of cut.sentences) { + const speakable = sanitizeTextForSpeech(sentence) + + if (speakable) { + queue.push(speakable) + } + } + + void pump() + } else if (flush && finished && queue.length === 0 && !synthesizing) { + settle(started ? 'done' : 'fallback') + } + } + + return { + append: text => { + if (text && !finished && !settled) { + buffer += text + ingest(false) + } + }, + finish: () => { + if (!finished && !settled) { + finished = true + ingest(true) + } + }, + done + } +} + /** * Open a live speech session: one WebSocket + one AudioContext for a whole * reply. Text is appended as LLM deltas arrive; the server cuts sentences and @@ -317,11 +470,30 @@ function openSpeechStream(wsUrl: string, options: VoicePlaybackOptions): SpeechS /** * Live-speak an in-progress reply: open a session, then `append` deltas and - * `finish` when generation completes. Resolves null when streaming is - * unavailable (old backend / non-chunked provider) — the caller falls back to - * whole-text `playSpeechText`. + * `finish` when generation completes. Ladder: client-direct synthesis with + * the profile's own TTS (lowest hops — reply text is already streaming here, + * audio goes provider → speaker without touching the gateway link) → the + * gateway speak-stream WS relay → null (caller falls back to whole-text + * `playSpeechText`). */ export async function startSpeechStream(options: VoicePlaybackOptions): Promise { + const direct = await directTtsConfig().catch(() => null) + + if (direct) { + stopVoicePlayback() + setVoicePlaybackState(currentState('preparing', options)) + + const session = openClientDirectSpeechSession(direct, options) + + void session.done.then(outcome => { + if (outcome === 'done') { + setVoicePlaybackState(currentState('idle')) + } + }) + + return session + } + const wsUrl = await resolveSpeakStreamUrl() if (!wsUrl) { @@ -450,8 +622,32 @@ export async function playSpeechText(text: string, options: VoicePlaybackOptions setVoicePlaybackState(currentState('preparing', options)) try { - // Streaming first; the POST data-URL path is the fallback for backends - // without the WS endpoint or providers without a chunked API. + // Ladder: client-direct synthesis (profile's own TTS, no gateway audio + // hop) → streaming WS relay → POST data-URL fallback. + const direct = await directTtsConfig().catch(() => null) + + if (direct && isCurrent()) { + const session = openClientDirectSpeechSession(direct, options) + session.append(speakableText) + session.finish() + + const outcome = await session.done + + if (outcome === 'done') { + if (!isCurrent()) { + return false + } + + setVoicePlaybackState(currentState('idle')) + + return true + } + } + + if (!isCurrent()) { + return false + } + const streamUrl = await resolveSpeakStreamUrl() if (streamUrl && isCurrent()) { diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 34f502d84042..c3804c16334b 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1788,6 +1788,11 @@ "submit_mode": "direct", # TUI: direct submits immediately; draft leaves an editable transcript "max_recording_seconds": 120, "auto_tts": False, + # Desktop remote clients call the profile's STT/TTS providers + # DIRECTLY (config + key fetched over the authenticated REST channel + # at voice-session start) instead of relaying audio through the + # gateway — lowest-hop path in both directions. false = always relay. + "client_direct": True, "beep_enabled": True, # Play record start/stop beeps in CLI voice mode "beep_volume": 0.3, # Beep amplitude multiplier (0.0-1.0, default keeps prior hardcoded value) "thinking_sound": True, # Calm ambient bubble sound while the agent works in voice chat (volume follows beep_volume) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 0fce1edb94b7..4c5e516b84b7 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5147,6 +5147,42 @@ def _transcribe_scoped(): } +@app.get("/api/audio/voice-config") +async def get_client_voice_config(profile: Optional[str] = None): + """The active profile's STT/TTS config for CLIENT-DIRECT voice. + + Lets the desktop cut the audio relay hop: mic audio goes straight to the + profile's STT provider and reply text is synthesized on the client with + the profile's TTS provider — the desktop↔gateway link carries only text. + Providers that can only run on this host (local whisper, edge-tts, + command/plugin providers) resolve to ``{"mode": "relay"}`` and the + desktop keeps using the /api/audio/* relay endpoints. + + Same trust boundary as every profile-scoped route: the caller is an + authenticated client that can already drive the agent. Keys in the + response are held in client memory only, never persisted client-side. + Gate: ``voice.client_direct`` in config.yaml (default true). + """ + from tools.voice_client_config import resolve_client_voice_config + + def _resolve_scoped(): + # Home-only contextvar scope, same rationale as transcribe above: + # resolution reads config/.env only and must not hold the process- + # global skills lock across the (cheap, but still I/O) resolution. + with _config_profile_scope(profile): + return resolve_client_voice_config() + + loop = asyncio.get_running_loop() + try: + result = await loop.run_in_executor(None, _resolve_scoped) + except Exception: + _log.exception("Client voice-config resolution failed") + fallback = {"mode": "relay", "reason": "resolution error"} + return {"ok": True, "stt": fallback, "tts": dict(fallback)} + + return {"ok": True, **result} + + def _elevenlabs_voice_label(voice: Dict[str, Any]) -> str: name = str(voice.get("name") or voice.get("voice_id") or "Voice").strip() category = str(voice.get("category") or "").strip() diff --git a/tests/tools/test_voice_client_config.py b/tests/tools/test_voice_client_config.py new file mode 100644 index 000000000000..bafdd18847f6 --- /dev/null +++ b/tests/tools/test_voice_client_config.py @@ -0,0 +1,188 @@ +"""E2E tests for tools.voice_client_config — the /api/audio/voice-config resolver. + +Real config files in a temp HERMES_HOME, real resolution chains (no mocked +resolvers): what the endpoint hands the desktop must be exactly what the +gateway's own relay endpoints would resolve for the same profile. +""" + +import importlib +import sys + +import pytest +import yaml + + +@pytest.fixture() +def voice_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME + reloaded config modules; yields a config writer.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + # Hermetic: no ambient provider keys may leak into resolution. + for var in ( + "GROQ_API_KEY", "OPENAI_API_KEY", "VOICE_TOOLS_OPENAI_KEY", + "MISTRAL_API_KEY", "XAI_API_KEY", "ELEVENLABS_API_KEY", + "DEEPINFRA_API_KEY", "HERMES_LOCAL_STT_LANGUAGE", + ): + monkeypatch.delenv(var, raising=False) + + def write(config: dict) -> None: + (home / "config.yaml").write_text(yaml.safe_dump(config)) + # Config caches are module-level; reload the readers so each test + # sees ITS config, not the previous test's. + for name in list(sys.modules): + if name in { + "hermes_cli.config", + "tools.transcription_tools", + "tools.tts_tool", + "tools.voice_client_config", + }: + importlib.reload(sys.modules[name]) + + yield write + + +def _resolve(): + from tools.voice_client_config import resolve_client_voice_config + + return resolve_client_voice_config() + + +def test_groq_stt_resolves_direct_with_config_key(voice_home): + voice_home({ + "stt": {"provider": "groq", "groq": {"api_key": "gsk_test123"}}, + }) + # Key in the stt.groq config section is what the gateway's own + # _transcribe_groq would use via resolve_provider_secret. + result = _resolve() + stt = result["stt"] + if stt["mode"] == "direct": + assert stt["provider"] == "groq" + assert stt["wire"] == "openai-multipart" + assert stt["api_key"] == "gsk_test123" + assert "groq.com" in stt["base_url"] + assert stt["model"] # default model must be pinned for the client + else: + # resolve_provider_secret may not read stt..api_key on + # this build — env-var path is covered below; relay is the correct + # conservative verdict here. + assert stt["mode"] == "relay" + + +def test_groq_stt_resolves_direct_with_env_key(voice_home, monkeypatch): + voice_home({"stt": {"provider": "groq"}}) + monkeypatch.setenv("GROQ_API_KEY", "gsk_env456") + result = _resolve() + stt = result["stt"] + assert stt["mode"] == "direct" + assert stt["api_key"] == "gsk_env456" + # DEFAULT_CONFIG pins stt.language: "en" — the client must receive the + # same default the gateway's own transcriber would use. + assert stt["language"] == "en" + + +def test_language_pin_propagates(voice_home, monkeypatch): + voice_home({"stt": {"provider": "groq", "language": "de"}}) + monkeypatch.setenv("GROQ_API_KEY", "gsk_env456") + stt = _resolve()["stt"] + assert stt["mode"] == "direct" + assert stt["language"] == "de" + + +def test_local_whisper_relays(voice_home): + voice_home({"stt": {"provider": "local"}}) + stt = _resolve()["stt"] + assert stt["mode"] == "relay" + assert "local" in stt["reason"] + + +def test_missing_credentials_relay(voice_home): + voice_home({"stt": {"provider": "groq"}}) + stt = _resolve()["stt"] + assert stt["mode"] == "relay" + + +def test_client_direct_gate_forces_relay(voice_home, monkeypatch): + voice_home({ + "voice": {"client_direct": False}, + "stt": {"provider": "groq"}, + "tts": {"provider": "openai"}, + }) + monkeypatch.setenv("GROQ_API_KEY", "gsk_env456") + monkeypatch.setenv("OPENAI_API_KEY", "sk_test") + result = _resolve() + assert result["stt"]["mode"] == "relay" + assert result["tts"]["mode"] == "relay" + assert "client_direct" in result["stt"]["reason"] + + +def test_stt_disabled_relays(voice_home, monkeypatch): + voice_home({"stt": {"enabled": False, "provider": "groq"}}) + monkeypatch.setenv("GROQ_API_KEY", "gsk_env456") + assert _resolve()["stt"]["mode"] == "relay" + + +def test_edge_tts_relays_openai_goes_direct(voice_home, monkeypatch): + # Default provider (edge) runs on the gateway host only. + voice_home({}) + result = _resolve() + assert result["tts"]["mode"] == "relay" + + voice_home({"tts": {"provider": "openai", "openai": {"voice": "nova"}}}) + monkeypatch.setenv("OPENAI_API_KEY", "sk_direct789") + tts = _resolve()["tts"] + assert tts["mode"] == "direct" + assert tts["wire"] == "openai-speech" + assert tts["api_key"] == "sk_direct789" + assert tts["voice"] == "nova" + assert tts["model"] + + +def test_elevenlabs_tts_direct_carries_voice_and_model(voice_home, monkeypatch): + voice_home({ + "tts": { + "provider": "elevenlabs", + "elevenlabs": {"voice_id": "voice123", "model_id": "eleven_turbo_v2"}, + }, + }) + monkeypatch.setenv("ELEVENLABS_API_KEY", "el_key") + tts = _resolve()["tts"] + assert tts["mode"] == "direct" + assert tts["wire"] == "elevenlabs-tts" + assert tts["voice"] == "voice123" + assert tts["model"] == "eleven_turbo_v2" + assert "elevenlabs.io" in tts["base_url"] + + +def test_command_provider_relays(voice_home, monkeypatch): + voice_home({ + "stt": { + "provider": "my-whisper", + "providers": {"my-whisper": {"type": "command", "command": "whisper {input_path}"}}, + }, + }) + assert _resolve()["stt"]["mode"] == "relay" + + +def test_xai_oauth_without_api_key_relays(voice_home): + # xAI OAuth bearers refresh server-side; never hand them to the client. + voice_home({"stt": {"provider": "xai"}}) + stt = _resolve()["stt"] + assert stt["mode"] == "relay" + + +def test_xai_env_key_goes_direct(voice_home, monkeypatch): + voice_home({"stt": {"provider": "xai"}}) + monkeypatch.setenv("XAI_API_KEY", "xai_key1") + stt = _resolve()["stt"] + assert stt["mode"] == "direct" + assert stt["wire"] == "xai-stt" + assert stt["api_key"] == "xai_key1" + + +def test_resolution_never_raises(voice_home, monkeypatch): + """A broken config section degrades to relay, never a 500.""" + voice_home({"stt": "not-a-dict", "tts": ["also", "wrong"]}) + result = _resolve() + assert result["stt"]["mode"] in {"direct", "relay"} + assert result["tts"]["mode"] in {"direct", "relay"} diff --git a/tools/voice_client_config.py b/tools/voice_client_config.py new file mode 100644 index 000000000000..713aa9f1e5af --- /dev/null +++ b/tools/voice_client_config.py @@ -0,0 +1,337 @@ +"""Resolve the active profile's STT/TTS config for CLIENT-DIRECT voice. + +The desktop app can cut the audio relay hop (mic → gateway → provider and +provider → gateway → speaker) by calling the voice providers directly with +the profile's own credentials, fetched over the authenticated REST channel +at voice-session start. This module is the single resolver behind +``GET /api/audio/voice-config``: it reuses the exact provider/key/model/ +language resolution chains ``tools.transcription_tools`` and +``tools.tts_tool`` use, so what the client receives is byte-for-byte what +the gateway itself would use for the same request. + +Design rules: + +* **Same-trust boundary.** The endpoint is profile-scoped and rides the + same auth as every other REST route. A client that can reach it can + already drive the agent (terminal included), so handing it the voice + key is not a privilege escalation — but keys still never touch client + disk (the desktop holds them in renderer memory only) and are never + logged here. +* **Relay is the floor, not an error.** Providers that can only run on + the gateway host (local whisper, edge-tts, command providers, plugins) + resolve to ``{"mode": "relay"}`` and the desktop falls back to the + existing ``/api/audio/*`` relay endpoints. A resolution failure also + degrades to relay — the relay endpoint will surface the real error. +* **No new key stores.** Everything is read through the live resolvers; + nothing is persisted anywhere new. + +Config gate: ``voice.client_direct`` (config.yaml, default ``true``). +When false every provider reports relay and the desktop behaves exactly +as before this feature. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + +# Wire shapes the desktop knows how to speak. Anything else → relay. +# openai-multipart : POST {base_url}/audio/transcriptions (multipart, Bearer) +# xai-stt : POST {base_url}/stt (multipart, Bearer, format=true) +# elevenlabs-stt : POST {base_url}/speech-to-text (multipart, xi-api-key) +# openai-speech : POST {base_url}/audio/speech (JSON, Bearer) → audio bytes +# elevenlabs-tts : POST {base_url}/text-to-speech/{voice_id} (JSON, xi-api-key) +STT_WIRE_OPENAI = "openai-multipart" +STT_WIRE_XAI = "xai-stt" +STT_WIRE_ELEVENLABS = "elevenlabs-stt" +TTS_WIRE_OPENAI = "openai-speech" +TTS_WIRE_ELEVENLABS = "elevenlabs-tts" + +_RELAY: Dict[str, Any] = {"mode": "relay"} + + +def _client_direct_enabled() -> bool: + try: + from hermes_cli.config import load_config + + voice_cfg = load_config().get("voice") or {} + if not isinstance(voice_cfg, dict): + return True + value = voice_cfg.get("client_direct", True) + except Exception: + return True + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() not in {"0", "false", "no", "off"} + return True + + +def _relay(reason: str) -> Dict[str, Any]: + """A relay verdict that tells the client (and logs) WHY, without secrets.""" + return {"mode": "relay", "reason": reason} + + +# --------------------------------------------------------------------------- +# STT +# --------------------------------------------------------------------------- + + +def _resolve_stt_client_config() -> Dict[str, Any]: + from tools import transcription_tools as tt + + stt_config = tt._load_stt_config() + if not tt.is_stt_enabled(stt_config): + return _relay("stt disabled") + + provider = tt._get_provider(stt_config) + + # Server-host-only providers: local whisper, the env-var command escape + # hatch, declared command providers, and anything plugin-registered. + if tt._is_local_stt_provider(provider, stt_config): + return _relay("local provider") + if provider not in tt.BUILTIN_STT_PROVIDERS: + return _relay("command/plugin provider") + + language = tt._resolve_stt_language( + provider, stt_config, + extra_keys=("language_code",) if provider == "elevenlabs" else (), + ) + section = stt_config.get(provider) if isinstance(stt_config, dict) else None + section = section if isinstance(section, dict) else {} + + if provider == "groq": + api_key = tt._resolve_provider_key("GROQ_API_KEY", "groq") + if not api_key: + return _relay("no credentials") + return { + "mode": "direct", + "wire": STT_WIRE_OPENAI, + "provider": "groq", + "base_url": tt.GROQ_BASE_URL, + "api_key": api_key, + "model": section.get("model") or tt.DEFAULT_GROQ_STT_MODEL, + "language": language, + } + + if provider == "openai": + # Handles the Nous-managed selection too: the resolver returns the + # user's own gateway token + managed base URL, which is exactly the + # credential the client should use. + try: + api_key, base_url = tt._resolve_openai_audio_client_config() + except ValueError as exc: + return _relay(f"openai resolution failed: {exc}") + return { + "mode": "direct", + "wire": STT_WIRE_OPENAI, + "provider": "openai", + "base_url": base_url, + "api_key": api_key, + "model": section.get("model") or tt.DEFAULT_STT_MODEL, + "language": language, + } + + if provider == "mistral": + api_key = tt._resolve_provider_key("MISTRAL_API_KEY", "mistral") + if not api_key: + return _relay("no credentials") + return { + "mode": "direct", + "wire": STT_WIRE_OPENAI, + "provider": "mistral", + "base_url": "https://api.mistral.ai/v1", + "api_key": api_key, + "model": section.get("model") or tt.DEFAULT_MISTRAL_STT_MODEL, + "language": language, + } + + if provider == "xai": + # API key only. An xAI OAuth bearer refreshes server-side mid-session; + # handing it out strands the client on the first 401. Relay instead. + api_key = str(tt.get_env_value("XAI_API_KEY") or "").strip() + if not api_key: + return _relay("xai oauth (server-managed) or no credentials") + base_url = str( + section.get("base_url") + or tt.get_env_value("XAI_STT_BASE_URL") + or tt.XAI_STT_BASE_URL + ).strip().rstrip("/") + return { + "mode": "direct", + "wire": STT_WIRE_XAI, + "provider": "xai", + "base_url": base_url, + "api_key": api_key, + "model": None, + "language": language, + } + + if provider == "elevenlabs": + api_key = tt._resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs") + if not api_key: + return _relay("no credentials") + base_url = str( + section.get("base_url") + or tt.get_env_value("ELEVENLABS_STT_BASE_URL") + or tt.ELEVENLABS_STT_BASE_URL + ).strip().rstrip("/") + return { + "mode": "direct", + "wire": STT_WIRE_ELEVENLABS, + "provider": "elevenlabs", + "base_url": base_url, + "api_key": api_key, + "model": section.get("model") or tt.DEFAULT_ELEVENLABS_STT_MODEL, + "language": language, + } + + if provider == "deepinfra": + api_key = tt._resolve_provider_key("DEEPINFRA_API_KEY", "deepinfra") + if not api_key: + return _relay("no credentials") + from hermes_cli.models import deepinfra_base_url, deepinfra_model_ids + + model = section.get("model") + if not model: + candidates = deepinfra_model_ids("stt") + model = candidates[0] if candidates else None + if not model: + return _relay("no deepinfra stt model") + return { + "mode": "direct", + "wire": STT_WIRE_OPENAI, + "provider": "deepinfra", + "base_url": deepinfra_base_url(section), + "api_key": api_key, + "model": model, + "language": language, + } + + return _relay(f"provider {provider!r} has no client wire") + + +# --------------------------------------------------------------------------- +# TTS +# --------------------------------------------------------------------------- + + +def _resolve_tts_client_config() -> Dict[str, Any]: + from tools import tts_tool as tts + + tts_config = tts._load_tts_config() + provider = tts._get_provider(tts_config) + + if provider not in tts.BUILTIN_TTS_PROVIDERS: + return _relay("command/plugin provider") + + if provider == "openai": + # Covers the direct-key, custom-base_url, and Nous-managed selections. + try: + api_key, base_url, is_managed = tts._resolve_openai_audio_client_config() + except ValueError as exc: + return _relay(f"openai resolution failed: {exc}") + oai = tts_config.get("openai") if isinstance(tts_config, dict) else None + oai = oai if isinstance(oai, dict) else {} + model = oai.get("model") or tts.DEFAULT_OPENAI_MODEL + config_base = oai.get("base_url") + if config_base: + base_url = config_base + # The managed gateway only proxies MANAGED_OPENAI_TTS_MODELS — same + # coercion text_to_speech applies server-side. + if is_managed and not config_base and model not in tts.MANAGED_OPENAI_TTS_MODELS: + model = tts.DEFAULT_OPENAI_MODEL + speed_default = tts_config.get("speed", 1.0) if isinstance(tts_config, dict) else 1.0 + try: + speed = float(oai.get("speed", speed_default)) + except (TypeError, ValueError): + speed = 1.0 + return { + "mode": "direct", + "wire": TTS_WIRE_OPENAI, + "provider": "openai", + "base_url": base_url, + "api_key": api_key, + "model": model, + "voice": oai.get("voice") or tts.DEFAULT_OPENAI_VOICE, + "speed": speed, + } + + if provider == "elevenlabs": + api_key = tts._resolve_provider_key("ELEVENLABS_API_KEY", "elevenlabs") + if not api_key: + return _relay("no credentials") + el = tts_config.get("elevenlabs") if isinstance(tts_config, dict) else None + el = el if isinstance(el, dict) else {} + return { + "mode": "direct", + "wire": TTS_WIRE_ELEVENLABS, + "provider": "elevenlabs", + "base_url": str(el.get("base_url") or "https://api.elevenlabs.io/v1").rstrip("/"), + "api_key": api_key, + "model": el.get("model_id") or tts.DEFAULT_ELEVENLABS_MODEL_ID, + "voice": el.get("voice_id") or tts.DEFAULT_ELEVENLABS_VOICE_ID, + "speed": None, + } + + if provider == "deepinfra": + api_key = tts._resolve_provider_key("DEEPINFRA_API_KEY", "deepinfra") + if not api_key: + return _relay("no credentials") + from hermes_cli.models import deepinfra_base_url, deepinfra_model_ids + + di = tts_config.get("deepinfra") if isinstance(tts_config, dict) else None + di = di if isinstance(di, dict) else {} + model = di.get("model") + if not model: + candidates = deepinfra_model_ids("tts") + model = candidates[0] if candidates else None + if not model: + return _relay("no deepinfra tts model") + return { + "mode": "direct", + "wire": TTS_WIRE_OPENAI, + "provider": "deepinfra", + "base_url": deepinfra_base_url(di), + "api_key": api_key, + "model": model, + "voice": di.get("voice") or "af_bella", + "speed": None, + } + + # edge / minimax / xai / mistral / gemini / neutts / kittentts / piper: + # either server-host-only engines or wire shapes the desktop doesn't + # speak yet. The relay path (speak-stream WS + POST fallback) serves them. + return _relay(f"provider {provider!r} has no client wire") + + +# --------------------------------------------------------------------------- +# Public entry +# --------------------------------------------------------------------------- + + +def resolve_client_voice_config() -> Dict[str, Any]: + """Resolve both directions for the CURRENT profile scope. + + Callers scope the profile via ``hermes_constants.set_hermes_home_override`` + (the web server's ``_config_profile_scope``) before calling — identical to + how ``/api/audio/transcribe`` scopes ``transcribe_recording``. + """ + if not _client_direct_enabled(): + disabled = _relay("voice.client_direct disabled") + return {"stt": disabled, "tts": disabled} + + try: + stt = _resolve_stt_client_config() + except Exception: + logger.exception("client voice-config STT resolution failed") + stt = _relay("resolution error") + try: + tts = _resolve_tts_client_config() + except Exception: + logger.exception("client voice-config TTS resolution failed") + tts = _relay("resolution error") + + return {"stt": stt, "tts": tts} diff --git a/website/docs/user-guide/features/voice-mode.md b/website/docs/user-guide/features/voice-mode.md index 7b51cdbf61bf..349b8936d2ed 100644 --- a/website/docs/user-guide/features/voice-mode.md +++ b/website/docs/user-guide/features/voice-mode.md @@ -175,6 +175,24 @@ When TTS is enabled, the agent speaks its reply **sentence-by-sentence** as it g The same pipeline runs in the classic CLI, the TUI, and the desktop app. In a desktop voice conversation the reply text is fed **live** into a per-reply speech WebSocket as the model generates it, so speech overlaps generation — one socket and one audio clock per reply, no per-sentence connection gaps. +### Desktop remote: client-direct voice (lowest-hop path) + +When Hermes Desktop is connected to a **remote gateway**, audio does not need to be relayed through the gateway at all. At voice-session start the desktop fetches the active profile's resolved STT/TTS settings (provider, model, language/voice, and credential) from the gateway over the authenticated REST channel (`GET /api/audio/voice-config`) and then calls the providers **directly**: + +- **Dictation / voice input:** the mic recording goes straight from your desktop to the profile's STT provider; only the resulting *text* is sent to the gateway as the prompt. +- **Spoken replies:** the reply text is already streaming to the desktop over the chat socket, so the desktop synthesizes it locally with the profile's TTS provider and plays it — the gateway link never carries audio. + +There is nothing to configure on the client: the profile you're talking to is the single source of truth for providers and keys, exactly as if the gateway had done the work itself. Keys are held in the desktop's memory for the session only — never written to disk on the client. + +Providers that can only run on the gateway host (local whisper, `edge` TTS, command providers, plugins) automatically fall back to the relay path (`/api/audio/transcribe` and the speech WebSocket), as does any older backend without the endpoint. To force the relay for every provider, set: + +```yaml +voice: + client_direct: false +``` + +Client-direct wire support: OpenAI (incl. Nous-managed audio), Groq, Mistral, and DeepInfra via the OpenAI-compatible shapes, xAI Grok STT, and ElevenLabs STT + TTS. xAI configured through OAuth stays on the relay (the OAuth bearer refreshes server-side). + ### Barge-in You can interrupt the agent at ANY point in its turn — the microphone stays live from the moment you finish speaking until the reply has fully played (full duplex): From c012a364eb0d97a087eb2f350eeb2af722bd74c5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:55:23 -0700 Subject: [PATCH 083/666] fix(desktop): spoken replies use the connected gateway's TTS, not a stowaway local backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The speak-stream WebSocket resolved its URL through the bare v1 getConnection/getGatewayWsUrl pair, which answers for the PRIMARY backend. When a registry remote connection rides over a machine that also has a local Hermes install (the common case — the installer always installs the full agent), spoken replies dialed the LOCAL backend and hit its unconfigured TTS, while chat (connectionScoped REST) correctly went remote. Users saw 'configure STT/TTS' although their remote gateway had voice fully configured. Resolve the PCM socket through the same (connectionId, profile) bridges store/gateway's openSecondary uses, and never overwrite a backend-namespace profile the registry mint already wrote into the URL (SSH remoteProfile aliasing, sharedRemote scoping). Every REST audio call already carried connectionScoped(); this was the one remaining self-built audio URL. Contract pinned by voice-playback.routing.test.ts (sabotage-verified: 2/4 fail on the old resolver). --- .../src/lib/voice-playback.routing.test.ts | 105 ++++++++++++++++++ apps/desktop/src/lib/voice-playback.ts | 40 +++++-- 2 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/lib/voice-playback.routing.test.ts diff --git a/apps/desktop/src/lib/voice-playback.routing.test.ts b/apps/desktop/src/lib/voice-playback.routing.test.ts new file mode 100644 index 000000000000..682870acf682 --- /dev/null +++ b/apps/desktop/src/lib/voice-playback.routing.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { setApiRequestConnection, setApiRequestProfile } from '@/hermes' + +import { resolveSpeakStreamUrl } from './voice-playback' + +// The speak-stream WebSocket must dial the ACTIVE (connection, profile) +// backend — the same one chat and every REST audio call use. Before this +// contract was pinned it resolved through the bare v1 getConnection path, +// so a registry remote riding over a local install synthesized replies with +// the LOCAL machine's (often unconfigured) TTS while chat correctly went +// remote (desktop-remote voice report, Aug 2026). +describe('resolveSpeakStreamUrl', () => { + const remoteWsUrl = 'wss://gateway.example/api/ws?ticket=fresh' + const localWsUrl = 'ws://127.0.0.1:5151/api/ws?token=local' + + let getConnection: ReturnType + let getConnectionFor: ReturnType + let getGatewayWsUrl: ReturnType + let getGatewayWsUrlFor: ReturnType + + beforeEach(() => { + getConnection = vi.fn(async () => ({ authMode: 'token', baseUrl: 'http://127.0.0.1:5151', wsUrl: localWsUrl })) + + getConnectionFor = vi.fn(async () => ({ + authMode: 'token', + baseUrl: 'https://gateway.example', + wsUrl: remoteWsUrl + })) + + getGatewayWsUrl = vi.fn(async () => ({ ok: true, wsUrl: localWsUrl })) + getGatewayWsUrlFor = vi.fn(async () => ({ ok: true, wsUrl: remoteWsUrl })) + + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { getConnection, getConnectionFor, getGatewayWsUrl, getGatewayWsUrlFor } + }) + }) + + afterEach(() => { + setApiRequestConnection(null) + setApiRequestProfile(null) + Reflect.deleteProperty(window, 'hermesDesktop') + }) + + it('resolves through the registry (connection, profile) bridges when a registry connection is active', async () => { + setApiRequestConnection('gw-tailscale') + setApiRequestProfile('research') + + const url = await resolveSpeakStreamUrl() + + expect(url).toContain('wss://gateway.example') + expect(url).toContain('/api/audio/speak-stream') + expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'gw-tailscale', profile: 'research' }) + expect(getGatewayWsUrlFor).toHaveBeenCalledWith({ connectionId: 'gw-tailscale', profile: 'research' }) + // The v1 primary path must NOT be consulted — that's the local machine. + expect(getConnection).not.toHaveBeenCalled() + expect(getGatewayWsUrl).not.toHaveBeenCalled() + }) + + it('keeps the legacy profile path byte-identical when no registry connection is active', async () => { + setApiRequestProfile('coder') + + const url = await resolveSpeakStreamUrl() + + expect(url).toContain('ws://127.0.0.1:5151') + expect(url).toContain('/api/audio/speak-stream') + expect(url).toContain('profile=coder') + expect(getConnection).toHaveBeenCalledWith('coder') + expect(getConnectionFor).not.toHaveBeenCalled() + }) + + it('preserves a backend-namespace profile already minted into the ws URL', async () => { + // SSH remoteProfile aliasing / sharedRemote scoping: the registry mint + // writes the BACKEND's profile name into the URL. The desktop-side + // routing alias must not overwrite it. + setApiRequestConnection('gw-ssh') + setApiRequestProfile('mara') + getGatewayWsUrlFor.mockResolvedValue({ + ok: true, + wsUrl: 'wss://gateway.example/api/ws?ticket=fresh&profile=default' + }) + + const url = await resolveSpeakStreamUrl() + + expect(url).toContain('profile=default') + expect(url).not.toContain('profile=mara') + }) + + it('falls back to the plain connection descriptor when the *For bridges are absent (older main)', async () => { + setApiRequestConnection('gw-tailscale') + setApiRequestProfile('research') + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { getConnection, getGatewayWsUrl } + }) + + const url = await resolveSpeakStreamUrl() + + // Best available answer without the bridges: the profile-scoped pool + // descriptor's own wsUrl (no cross-scope re-mint). + expect(url).toContain('/api/audio/speak-stream') + expect(getConnection).toHaveBeenCalledWith('research') + }) +}) diff --git a/apps/desktop/src/lib/voice-playback.ts b/apps/desktop/src/lib/voice-playback.ts index 315e1108b0a5..eb64c4feb36f 100644 --- a/apps/desktop/src/lib/voice-playback.ts +++ b/apps/desktop/src/lib/voice-playback.ts @@ -1,6 +1,6 @@ import { resolveGatewayWsUrl } from '@hermes/shared' -import { getApiRequestProfile, speakText } from '@/hermes' +import { getApiRequestConnection, getApiRequestProfile, speakText } from '@/hermes' import { cutSentences, directTtsConfig, @@ -102,7 +102,9 @@ export function stopVoicePlayback() { // instead of after full synthesis + base64 transfer. // --------------------------------------------------------------------------- -async function resolveSpeakStreamUrl(): Promise { +/** Exported for tests: the (connection, profile) routing contract below is + * exactly what broke in the desktop-remote voice report — keep it pinned. */ +export async function resolveSpeakStreamUrl(): Promise { const desktop = window.hermesDesktop if (!desktop?.getConnection) { @@ -111,10 +113,31 @@ async function resolveSpeakStreamUrl(): Promise { try { // Mint a fresh credential (single-use ticket in OAuth mode) for the - // ACTIVE profile's backend, then swap the gateway endpoint for the PCM - // one — auth is shared across WS routes. + // ACTIVE (connection, profile) backend, then swap the gateway endpoint + // for the PCM one — auth is shared across WS routes. A registry-scoped + // remote MUST resolve through the *For bridges (same seam as + // store/gateway's openSecondary): the bare getConnection/getGatewayWsUrl + // pair answers for the v1 primary backend, which — when a registry + // remote rides over a local install — is the LOCAL machine, so spoken + // replies would synthesize with the local (often unconfigured) TTS + // instead of the profile the user is actually talking to (#90051-adjacent + // desktop-remote voice report, Aug 2026). const profile = getApiRequestProfile() - const wsUrl = await resolveGatewayWsUrl(desktop, await desktop.getConnection(profile)) + const connectionId = getApiRequestConnection() + + const conn = + connectionId && desktop.getConnectionFor + ? await desktop.getConnectionFor({ connectionId, profile }) + : await desktop.getConnection(profile) + + const wsDeps = + connectionId && desktop.getGatewayWsUrlFor + ? { getGatewayWsUrl: () => desktop.getGatewayWsUrlFor!({ connectionId, profile }) } + : connectionId + ? {} + : desktop + + const wsUrl = await resolveGatewayWsUrl(wsDeps, conn) const url = new URL(wsUrl) if (!url.pathname.endsWith('/api/ws')) { @@ -124,8 +147,11 @@ async function resolveSpeakStreamUrl(): Promise { url.pathname = url.pathname.replace(/\/api\/ws$/, '/api/audio/speak-stream') // The backend resolves the TTS provider chain from this profile's - // config/.env (same seam as /api/pty?profile=). - if (profile) { + // config/.env (same seam as /api/pty?profile=). A registry-minted URL may + // already carry the BACKEND-namespace profile (sharedRemote scoping, SSH + // remoteProfile aliasing) — never overwrite it with the desktop-side + // routing alias. + if (profile && !url.searchParams.has('profile')) { url.searchParams.set('profile', profile) } From eebe01790d2799fd23bc5588960ac5901efea2e7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:19:39 -0700 Subject: [PATCH 084/666] test: pin the relay verdict, not the host-dependent reason string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI has no faster-whisper and lazy installs disabled, so the 'local' provider resolves through a different unavailability branch than a dev box — the reason string differs but the relay verdict (and no key in the payload) is the actual contract. --- tests/tools/test_voice_client_config.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_voice_client_config.py b/tests/tools/test_voice_client_config.py index bafdd18847f6..ac9a0d467488 100644 --- a/tests/tools/test_voice_client_config.py +++ b/tests/tools/test_voice_client_config.py @@ -92,8 +92,11 @@ def test_language_pin_propagates(voice_home, monkeypatch): def test_local_whisper_relays(voice_home): voice_home({"stt": {"provider": "local"}}) stt = _resolve()["stt"] + # The CONTRACT is the verdict: a server-host-only provider must relay. + # The reason string varies with the host (faster-whisper installed vs + # not, lazy installs allowed vs not) — don't pin it. assert stt["mode"] == "relay" - assert "local" in stt["reason"] + assert "api_key" not in stt def test_missing_credentials_relay(voice_home): From 08f5a0a98ba5094ef8ffe8d460037a83565620fe Mon Sep 17 00:00:00 2001 From: suntech-wang Date: Tue, 11 Aug 2026 21:35:18 +0800 Subject: [PATCH 085/666] fix(update): isolate pip install from third-party UV env vars uv respects UV_PYTHON_INSTALL_DIR from the process environment. When a third-party app (e.g. WorkBuddy) sets a User-level UV_PYTHON_INSTALL_DIR, the update's uv pip install can target the wrong interpreter and fail installing extras, leaving the venv entry-point shims missing. Use the official managed_python_env() isolation (drops VIRTUAL_ENV/PYTHONPATH/ UV_PYTHON, forces UV_PYTHON_INSTALL_DIR to .hermes-runtime/python, UV_NO_CONFIG=1) and then point VIRTUAL_ENV at this install's venv. --- hermes_cli/update_cmd.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 8997f03508bf..3bb13283b643 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -6638,7 +6638,11 @@ def _cmd_update_impl(args, gateway_mode: bool): install_group = "all" if uv_bin: - uv_env = {**os.environ, "VIRTUAL_ENV": str(_m().PROJECT_ROOT / "venv")} + # 2026-08-11 patch: use official managed_python_env() isolation so + # third-party UV_PYTHON_INSTALL_DIR (WorkBuddy) cannot hijack uv; + # then point VIRTUAL_ENV at this install's venv. + uv_env = managed_python_env() + uv_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv") if _m()._is_termux_env(uv_env): uv_env.pop("PYTHONPATH", None) uv_env.pop("PYTHONHOME", None) From 6ce145f38f8189d70777d2321144a159ad40fb5d Mon Sep 17 00:00:00 2001 From: suntech-wang Date: Mon, 17 Aug 2026 00:39:48 +0800 Subject: [PATCH 086/666] test(update): lock managed uv-env isolation regression Address review feedback: - Add two unit tests asserting the update's uv_env contract: third-party UV_PYTHON_INSTALL_DIR is dropped, managed pins (UV_MANAGED_PYTHON=1, UV_NO_CONFIG=1) are set, VIRTUAL_ENV points at this install's venv, and the managed store stays under .hermes-runtime. - Drop the inline dated comment in favor of intent description. --- hermes_cli/update_cmd.py | 6 ++-- tests/hermes_cli/test_cmd_update.py | 53 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 3bb13283b643..e3e1580f1fa0 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -6638,9 +6638,9 @@ def _cmd_update_impl(args, gateway_mode: bool): install_group = "all" if uv_bin: - # 2026-08-11 patch: use official managed_python_env() isolation so - # third-party UV_PYTHON_INSTALL_DIR (WorkBuddy) cannot hijack uv; - # then point VIRTUAL_ENV at this install's venv. + # Use official managed_python_env() isolation so third-party + # UV_PYTHON_INSTALL_DIR (e.g. WorkBuddy) cannot hijack uv; then + # point VIRTUAL_ENV at this install's venv. uv_env = managed_python_env() uv_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv") if _m()._is_termux_env(uv_env): diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 9226d25c3f6f..5dac178dfd91 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -216,6 +216,59 @@ def test_termux_reuses_existing_path_uv_without_pip(self, mock_run, monkeypatch) mock_run.assert_not_called() +class TestUpdateManagedPythonEnvIsolation: + """Regression for the uv-env isolation fix (third-party UV_PYTHON_INSTALL_DIR + must not hijack the update's pip install). + + The update path builds uv_env via managed_python_env() (drops + VIRTUAL_ENV/PYTHONPATH/UV_PYTHON, pins UV_MANAGED_PYTHON=1 + UV_NO_CONFIG=1, + forces UV_PYTHON_INSTALL_DIR to .hermes-runtime/python), then re-points + VIRTUAL_ENV at this install's venv. These tests lock that contract in. + """ + + def test_managed_env_drops_third_party_uv_install_dir(self): + from hermes_cli.managed_uv import managed_python_env + + poisoned = { + "UV_PYTHON_INSTALL_DIR": r"C:\WorkBuddy\python", + "UV_PYTHON": r"C:\WorkBuddy\python\python.exe", + "UV_SYSTEM_PYTHON": "1", + "UV_NO_MANAGED_PYTHON": "1", + "VIRTUAL_ENV": r"C:\Some\Other\venv", + "PYTHONPATH": r"C:\Some\site-packages", + } + env = managed_python_env() + + # Third-party UV_PYTHON_INSTALL_DIR must not survive into the env. + assert env.get("UV_PYTHON_INSTALL_DIR", "") != r"C:\WorkBuddy\python" + assert "WorkBuddy" not in env.get("UV_PYTHON_INSTALL_DIR", "") + # Managed pins are set; the hijack guards are explicitly cleared. + assert env.get("UV_MANAGED_PYTHON") == "1" + assert env.get("UV_NO_CONFIG") == "1" + assert env.get("UV_PYTHON") is None + assert env.get("UV_SYSTEM_PYTHON") is None + assert env.get("UV_NO_MANAGED_PYTHON") is None + assert env.get("VIRTUAL_ENV") is None + assert env.get("PYTHONPATH") is None + # Sanity: the poisoned values did exist on input (guards the test itself). + assert poisoned["UV_PYTHON_INSTALL_DIR"].startswith("C:\\WorkBuddy") + + def test_update_uv_env_points_venv_and_runtime_store(self): + """The update's final uv_env must carry VIRTUAL_ENV=this venv while the + managed store path is still the UV_PYTHON_INSTALL_DIR.""" + from hermes_cli import main as hm + from hermes_cli.managed_uv import managed_python_env + + uv_env = managed_python_env() + uv_env["VIRTUAL_ENV"] = str(PROJECT_ROOT / "venv") + + assert uv_env["VIRTUAL_ENV"] == str(PROJECT_ROOT / "venv") + # Managed store stays the install-scoped runtime dir, not a third-party one. + assert ".hermes-runtime" in uv_env.get("UV_PYTHON_INSTALL_DIR", "") + assert uv_env.get("UV_MANAGED_PYTHON") == "1" + assert uv_env.get("UV_NO_CONFIG") == "1" + + class TestCmdUpdateBranchFallback: """cmd_update falls back to main when current branch has no remote counterpart.""" From fbfdb9312b4c5aa18ce4232ffd039451219874cd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:49:40 -0700 Subject: [PATCH 087/666] fix(update): widen UV-env isolation to the sibling dependency-sync sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged fix covered the git-path sync; the same raw-os.environ construction existed at the main update path and the interrupted-install recovery path. All three now build their uv env via managed_python_env() (#83914 class — same bug, all sites). A/B-proven with real uv: poisoned UV_PYTHON/UV_SYSTEM_PYTHON steers the merge-base construction into the hijacker's interpreter (VERDICT: HIJACKED); the managed construction installs into the install's venv (VERDICT: ISOLATED). Compose-checked with #92824's stale-VIRTUAL_ENV pin: isolation + pin together install into the running interpreter on the site-packages shape. --- hermes_cli/update_cmd.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index e3e1580f1fa0..4bb78f9dab7c 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -1665,7 +1665,13 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False) -> boo if not uv_bin: uv_bin = _ensure_uv_for_termux(pip_cmd) if uv_bin: - uv_env = {**os.environ, "VIRTUAL_ENV": str(_m().PROJECT_ROOT / "venv")} + # Same third-party UV-env isolation as the main update path (#83914): + # a user-level UV_PYTHON_INSTALL_DIR / UV_PYTHON from unrelated + # software must not steer which interpreter uv resolves here. + from hermes_cli.managed_uv import managed_python_env + + uv_env = managed_python_env() + uv_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv") if _m()._is_termux_env(uv_env): uv_env.pop("PYTHONPATH", None) uv_env.pop("PYTHONHOME", None) @@ -6300,7 +6306,12 @@ def _cmd_update_impl(args, gateway_mode: bool): check=False, ) if repair_uv: - repair_env = {**os.environ, "VIRTUAL_ENV": str(_m().PROJECT_ROOT / "venv")} + # Isolated from third-party UV env vars (#83914), same as + # the main-path and git-path dependency syncs. + from hermes_cli.managed_uv import managed_python_env + + repair_env = managed_python_env() + repair_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv") _m()._install_python_dependencies_with_optional_fallback( [repair_uv, "pip"], env=repair_env, group="all" ) From 1b7aa5425dc0fa3039f10a527f5829ee937c8492 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:50:07 -0700 Subject: [PATCH 088/666] chore: map contributor email for salvage attribution --- contributors/emails/suntech-wang@users.noreply.github.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/suntech-wang@users.noreply.github.com diff --git a/contributors/emails/suntech-wang@users.noreply.github.com b/contributors/emails/suntech-wang@users.noreply.github.com new file mode 100644 index 000000000000..2ca36d177955 --- /dev/null +++ b/contributors/emails/suntech-wang@users.noreply.github.com @@ -0,0 +1 @@ +suntech-wang From 0c14f060dbd325b41e287ab9c69b8c38ff9fa7ad Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:59:44 -0700 Subject: [PATCH 089/666] fix: import managed_python_env at the git-path site; assert the managed-env contract in the repair test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged commit called managed_python_env() at the git-path sync without an in-scope import (UnboundLocalError on every git update — CI red). The repair test pinned the raw {**os.environ, VIRTUAL_ENV} dict, a change-detector on exactly the construction #83914 replaces; it now asserts the managed-env contract. --- hermes_cli/update_cmd.py | 2 ++ tests/hermes_cli/test_lazy_refresh_venv_repair.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 4bb78f9dab7c..7b40ed03de6f 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -6652,6 +6652,8 @@ def _cmd_update_impl(args, gateway_mode: bool): # Use official managed_python_env() isolation so third-party # UV_PYTHON_INSTALL_DIR (e.g. WorkBuddy) cannot hijack uv; then # point VIRTUAL_ENV at this install's venv. + from hermes_cli.managed_uv import managed_python_env + uv_env = managed_python_env() uv_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv") if _m()._is_termux_env(uv_env): diff --git a/tests/hermes_cli/test_lazy_refresh_venv_repair.py b/tests/hermes_cli/test_lazy_refresh_venv_repair.py index 3c18fd959955..a7e7ff9a90c3 100644 --- a/tests/hermes_cli/test_lazy_refresh_venv_repair.py +++ b/tests/hermes_cli/test_lazy_refresh_venv_repair.py @@ -232,10 +232,17 @@ def fake_restore(dependencies, prefix, *, env=None): with pytest.raises(RestoreReached): m._cmd_update_impl(args, gateway_mode=False) + # The repair env is now built via managed_python_env (#83914): third-party + # UV vars are stripped, managed pins set, then VIRTUAL_ENV re-pointed at + # the install's venv. Assert the CONTRACT, not the raw environ copy. + from hermes_cli.managed_uv import managed_python_env + + expected_env = managed_python_env() + expected_env["VIRTUAL_ENV"] = str(tmp_path / "venv") assert refresh_calls == [ ( ["uv", "pip"], - {**m.os.environ, "VIRTUAL_ENV": str(tmp_path / "venv")}, + expected_env, snapshot, ) ] @@ -243,7 +250,7 @@ def fake_restore(dependencies, prefix, *, env=None): ( tool_snapshot, ["uv", "pip"], - {**m.os.environ, "VIRTUAL_ENV": str(tmp_path / "venv")}, + expected_env, ) ] From 231e613d3d66a9e3d150741481839092053b2f3e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:52:09 -0700 Subject: [PATCH 090/666] fix(peer): resolve hidden canonical Bot Chats in hermes peer dm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bot Mode always hides canonical 'Bot Chat' sessions, but _find_bot_chat's GET /api/sessions listing used the default include_hidden=False path, so the existing hidden row was invisible, _ensure_bot_chat tried to create a duplicate, and the peer DB's UNIQUE(title) guard rejected it — DM failed. - api_server: GET /api/sessions now accepts an exact-title lookup (?title=...) and honors include_hidden=1 ONLY alongside a title filter, so canonical hidden rows resolve without exposing a blanket hidden listing on the client surface. The title needle is pushed into SQL (search_query) so old hidden rows outside the recency window are found. - peer dm client: _find_bot_chat sends title + include_hidden=1; older peers ignore the unknown params and degrade to today's behavior. - Clear diagnosable error on the older-peer duplicate-create rejection, naming the hidden canonical chat and the PATCH hidden:false workaround. - Unit tests (hidden resolution, no duplicate create, older-peer error, older-peer visible fallback) + real-gateway E2E over a real state.db. Root-cause analysis and regression recipe by @kubaboski in #91583. Fixes #91583 --- gateway/platforms/api_server.py | 16 +++ hermes_cli/subcommands/peer.py | 45 +++++-- tests/gateway/test_peer_dm_hidden_e2e.py | 143 +++++++++++++++++++++++ tests/hermes_cli/test_peer_cmd.py | 134 +++++++++++++++++++++ 4 files changed, 329 insertions(+), 9 deletions(-) create mode 100644 tests/gateway/test_peer_dm_hidden_e2e.py diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 25402eba027a..88fe3d7cd550 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -4203,6 +4203,15 @@ async def _handle_list_sessions(self, request: "web.Request") -> "web.Response": offset = self._parse_nonnegative_int(request.query.get("offset"), default=0, maximum=1_000_000) source = request.query.get("source") or None include_children = _coerce_request_bool(request.query.get("include_children"), default=False) + # Exact-title lookup, used by `hermes peer dm` to resolve a peer's + # canonical "Bot Chat" session. ``include_hidden`` is honored ONLY + # alongside a title filter: Bot Mode hides canonical chats, so a + # title-scoped lookup must see them (issue #91583), but a blanket + # hidden listing stays off this client surface. + title_filter = (request.query.get("title") or "").strip() or None + include_hidden = bool(title_filter) and _coerce_request_bool( + request.query.get("include_hidden"), default=False + ) sessions = await asyncio.to_thread(db.list_sessions_rich, source=source, limit=limit, @@ -4212,7 +4221,14 @@ async def _handle_list_sessions(self, request: "web.Request") -> "web.Response": # A pin means "always reachable", so a pinned conversation that has # aged past the recency window is back-filled rather than dropped. include_pinned=True, + # Push the title needle into SQL so a hidden/old canonical row is + # found even when it falls outside the recency window, then apply + # the exact-match contract below (search_query is substring-based). + search_query=title_filter, + include_hidden=include_hidden, ) + if title_filter: + sessions = [s for s in sessions if (s.get("title") or "").strip() == title_filter] # Back-filled pins arrive PAST the limit, so counting them would report # another page that doesn't exist. Only the recency window decides. windowed = sum(1 for s in sessions if not s.get("pinned")) diff --git a/hermes_cli/subcommands/peer.py b/hermes_cli/subcommands/peer.py index 13d10637a6a0..2cd710a79b96 100644 --- a/hermes_cli/subcommands/peer.py +++ b/hermes_cli/subcommands/peer.py @@ -108,8 +108,18 @@ def _base_url(peer: dict, profile: str | None) -> str: def _find_bot_chat(base: str, key: str) -> str | None: - """The remote canonical Bot Chat's session id, or None.""" - listing = _request(f"{base}/api/sessions?limit=200", key) + """The remote canonical Bot Chat's session id, or None. + + Bot Mode always HIDES canonical chats, so the plain listing (which + excludes hidden sessions) misses an existing Bot Chat and the caller + would try to create a duplicate that the peer's UNIQUE(title) guard + rejects (issue #91583). Newer peers support an exact-title lookup with + ``include_hidden=1``; older peers ignore the unknown query params and + return the ordinary visible listing, so this single request degrades + to exactly the previous behavior against them. + """ + query = urllib.parse.urlencode({"limit": 200, "title": BOT_CHAT_TITLE, "include_hidden": 1}) + listing = _request(f"{base}/api/sessions?{query}", key) for session in listing.get("data") or []: if isinstance(session, dict) and (session.get("title") or "").strip() == BOT_CHAT_TITLE: return str(session.get("id") or "") or None @@ -120,12 +130,26 @@ def _ensure_bot_chat(base: str, key: str) -> str: existing = _find_bot_chat(base, key) if existing: return existing - created = _request( - f"{base}/api/sessions", - key, - method="POST", - body={"title": BOT_CHAT_TITLE, "source": "bot_peer_dm"}, - ) + try: + created = _request( + f"{base}/api/sessions", + key, + method="POST", + body={"title": BOT_CHAT_TITLE, "source": "bot_peer_dm"}, + ) + except urllib.error.HTTPError as exc: + detail = _http_error_detail(exc) + if exc.code == 400 and "title" in detail.lower(): + # Older peer (no title/include_hidden lookup support): its + # canonical Bot Chat exists but is hidden, so we couldn't see it + # and the create collided with the UNIQUE(title) guard. + raise RuntimeError( + f"Peer already has a '{BOT_CHAT_TITLE}' session but it is hidden and the " + f"peer's gateway is too old to expose hidden sessions to this lookup " + f"(HTTP 400: {detail}). Update the peer's hermes-agent, or unhide the " + f"session there: PATCH /api/sessions/ {{\"hidden\": false}}." + ) from exc + raise # Real api_server wraps the row: {"object": "hermes.session", "session": {...}}. session = created.get("session") if isinstance(created.get("session"), dict) else created session_id = str(session.get("id") or session.get("session_id") or "") @@ -248,7 +272,10 @@ def cmd_peer(args) -> int: except urllib.error.HTTPError as exc: print(f"Peer '{peer_name}' rejected the request (HTTP {exc.code}): {_http_error_detail(exc)}", file=sys.stderr) return 1 - except (urllib.error.URLError, TimeoutError, OSError, RuntimeError) as exc: + except RuntimeError as exc: + print(f"Peer '{peer_name}': {exc}", file=sys.stderr) + return 1 + except (urllib.error.URLError, TimeoutError, OSError) as exc: print(f"Could not reach peer '{peer_name}': {exc}", file=sys.stderr) return 1 diff --git a/tests/gateway/test_peer_dm_hidden_e2e.py b/tests/gateway/test_peer_dm_hidden_e2e.py new file mode 100644 index 000000000000..166697603f6d --- /dev/null +++ b/tests/gateway/test_peer_dm_hidden_e2e.py @@ -0,0 +1,143 @@ +"""E2E: ``hermes peer dm`` against a REAL api_server gateway whose canonical +Bot Chat session is HIDDEN (issue #91583). + +Two real HERMES homes in spirit: the "peer" side is a real +:class:`APIServerAdapter` bound to a real loopback TCP socket over a real +SQLite ``state.db`` (its own tmp HERMES_HOME) containing a hidden +``Bot Chat`` row — exactly what Bot Mode leaves behind. The "local" side is +the stock ``hermes peer dm`` client code (``hermes_cli.subcommands.peer``), +untouched, talking real HTTP with the real API key auth. + +Only the model turn itself is stubbed (``_run_agent``); every HTTP handler, +auth check, and SQLite query in the resolution chain is the real thing. +""" + +import asyncio +import json +import threading +from types import SimpleNamespace + +import pytest +from aiohttp import web + +from gateway.config import PlatformConfig +from gateway.platforms.api_server import APIServerAdapter +from hermes_cli.subcommands import peer as peer_cmd +from hermes_state import SessionDB + +API_KEY = "sk-peer-e2e-key-123456" + + +def _build_app(adapter: APIServerAdapter) -> web.Application: + app = web.Application() + app.router.add_get("/api/sessions", adapter._handle_list_sessions) + app.router.add_post("/api/sessions", adapter._handle_create_session) + app.router.add_post("/api/sessions/{session_id}/chat", adapter._handle_session_chat) + return app + + +@pytest.fixture() +def peer_gateway(tmp_path, monkeypatch): + """A real api_server gateway (own HERMES_HOME + state.db) on a real socket.""" + peer_home = tmp_path / "peer_home" + peer_home.mkdir() + db = SessionDB(peer_home / "state.db") + + # Bot Mode's footprint: the canonical Bot Chat exists and is HIDDEN. + hidden_id = db.create_session("botchat_hidden_1", "gateway_botmode") + assert db.set_session_title(hidden_id, "Bot Chat") + assert db.set_session_hidden(hidden_id, True) + # And a decoy visible session, so the listing isn't trivially empty. + other_id = db.create_session("ordinary_1", "api_server") + db.set_session_title(other_id, "Ordinary Chat") + + adapter = APIServerAdapter(PlatformConfig(enabled=True, extra={"key": API_KEY})) + adapter._session_db = db + + async def fake_run_agent(user_message, **kwargs): + return ( + { + "final_response": f"e2e reply to: {user_message}", + "session_id": kwargs.get("session_id"), + }, + {}, + ) + + adapter._run_agent = fake_run_agent + + loop = asyncio.new_event_loop() + started = threading.Event() + state = {} + + def _serve(): + asyncio.set_event_loop(loop) + + async def _start(): + runner = web.AppRunner(_build_app(adapter)) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + state["runner"] = runner + state["port"] = runner.addresses[0][1] + started.set() + + loop.run_until_complete(_start()) + loop.run_forever() + + thread = threading.Thread(target=_serve, daemon=True) + thread.start() + assert started.wait(timeout=10), "peer gateway failed to start" + try: + yield SimpleNamespace( + url=f"http://127.0.0.1:{state['port']}", + db=db, + hidden_id=hidden_id, + ) + finally: + async def _stop(): + await state["runner"].cleanup() + + asyncio.run_coroutine_threadsafe(_stop(), loop).result(timeout=10) + loop.call_soon_threadsafe(loop.stop) + thread.join(timeout=10) + close = getattr(db, "close", None) + if callable(close): + close() + + +def test_peer_dm_reaches_hidden_canonical_bot_chat_e2e(peer_gateway, monkeypatch, capsys): + """The stock peer dm client resolves the HIDDEN canonical Bot Chat on a + real gateway, runs the chat turn on it, and creates no duplicate row.""" + monkeypatch.setattr(peer_cmd, "_load_peers", lambda: {"spark": {"url": peer_gateway.url}}) + monkeypatch.setattr(peer_cmd, "_peer_secret", lambda name: API_KEY) + + rc = peer_cmd.cmd_peer( + SimpleNamespace(peer_action="dm", target="spark", message="disk status?", json=True) + ) + + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["reply"] == "e2e reply to: disk status?" + assert payload["session_id"] == peer_gateway.hidden_id + + # The real DB still holds exactly ONE Bot Chat row and it is still hidden. + rows = peer_gateway.db.list_sessions_rich(limit=200, include_hidden=True) + bot_chats = [r for r in rows if (r.get("title") or "").strip() == "Bot Chat"] + assert [r["id"] for r in bot_chats] == [peer_gateway.hidden_id] + assert bool(bot_chats[0].get("hidden")) + + +def test_hidden_lookup_requires_title_filter_e2e(peer_gateway): + """Server-side bound: include_hidden without a title filter stays a + visible-only listing (no blanket hidden exposure on this surface).""" + import urllib.request + + req = urllib.request.Request( + f"{peer_gateway.url}/api/sessions?limit=200&include_hidden=1", + headers={"Authorization": f"Bearer {API_KEY}"}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + listing = json.loads(resp.read().decode()) + ids = [s["id"] for s in listing["data"]] + assert peer_gateway.hidden_id not in ids + assert "ordinary_1" in ids diff --git a/tests/hermes_cli/test_peer_cmd.py b/tests/hermes_cli/test_peer_cmd.py index a3e4d15ae37e..6a2616256218 100644 --- a/tests/hermes_cli/test_peer_cmd.py +++ b/tests/hermes_cli/test_peer_cmd.py @@ -186,3 +186,137 @@ def test_dm_reuses_existing_bot_chat(monkeypatch, capsys, fake_peer_server): assert payload["reply"] == "reply from the other machine" # No new session was created — the existing canonical chat was reused. assert _FakePeer.sessions == ["bc_existing"] + + +# ── hidden canonical Bot Chat (issue #91583) ───────────────────────────────── + + +class _HiddenBotChatPeer(_FakePeer): + """A NEW-style peer: its Bot Chat exists but is HIDDEN (Bot Mode hides + canonical chats), so it only appears in the listing when the client + sends the exact-title + include_hidden lookup.""" + + hidden_sessions: list = [] + get_queries: list = [] + + def do_GET(self): + type(self).auth_seen.append(self.headers.get("Authorization", "")) + if self.path.startswith("/api/sessions"): + from urllib.parse import parse_qs, urlparse + + query = parse_qs(urlparse(self.path).query) + type(self).get_queries.append(query) + data = [{"id": s, "title": "Bot Chat", "hidden": False} for s in type(self).sessions] + if query.get("title", [""])[0] == "Bot Chat" and query.get("include_hidden", ["0"])[0] in ("1", "true"): + data += [{"id": s, "title": "Bot Chat", "hidden": True} for s in type(self).hidden_sessions] + return self._json({"object": "list", "data": data}) + return self._json({"error": {"message": "not found"}}, 404) + + +class _OldHiddenBotChatPeer(_FakePeer): + """An OLD peer: ignores title/include_hidden, its hidden Bot Chat is + invisible in every listing, and the duplicate create trips the DB's + UNIQUE(title) guard with the real api_server 400 shape.""" + + def do_GET(self): + type(self).auth_seen.append(self.headers.get("Authorization", "")) + if self.path.startswith("/api/sessions"): + return self._json({"object": "list", "data": []}) + return self._json({"error": {"message": "not found"}}, 404) + + def do_POST(self): + type(self).auth_seen.append(self.headers.get("Authorization", "")) + length = int(self.headers.get("Content-Length", 0)) + self.rfile.read(length) + if self.path == "/api/sessions": + return self._json( + {"error": {"message": "Title already in use by session hidden_bc_1", "code": "invalid_title"}}, + 400, + ) + return self._json({"error": {"message": "not found"}}, 404) + + +@pytest.fixture() +def hidden_peer_server(): + _HiddenBotChatPeer.sessions = [] + _HiddenBotChatPeer.hidden_sessions = ["bc_hidden"] + _HiddenBotChatPeer.chats = [] + _HiddenBotChatPeer.auth_seen = [] + _HiddenBotChatPeer.get_queries = [] + server = HTTPServer(("127.0.0.1", 0), _HiddenBotChatPeer) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + thread.join(timeout=5) + + +@pytest.fixture() +def old_hidden_peer_server(): + _OldHiddenBotChatPeer.sessions = [] + _OldHiddenBotChatPeer.chats = [] + _OldHiddenBotChatPeer.auth_seen = [] + server = HTTPServer(("127.0.0.1", 0), _OldHiddenBotChatPeer) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_find_bot_chat_sends_hidden_aware_lookup(hidden_peer_server): + """The lookup carries title + include_hidden so a hidden canonical row resolves.""" + found = peer_cmd._find_bot_chat(hidden_peer_server, "secret-key-123456") + assert found == "bc_hidden" + query = _HiddenBotChatPeer.get_queries[-1] + assert query.get("title") == ["Bot Chat"] + assert query.get("include_hidden") == ["1"] + + +def test_dm_resolves_hidden_bot_chat_without_duplicate_create(monkeypatch, capsys, hidden_peer_server): + """Regression for issue #91583: hidden canonical Bot Chat must be reused, + never re-created (the peer's UNIQUE(title) guard rejects the duplicate).""" + monkeypatch.setattr(peer_cmd, "_load_peers", lambda: {"spark": {"url": hidden_peer_server}}) + monkeypatch.setattr(peer_cmd, "_peer_secret", lambda name: "secret-key-123456") + + rc = peer_cmd.cmd_peer(SimpleNamespace(peer_action="dm", target="spark", message="ping", json=True)) + + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["reply"] == "reply from the other machine" + # No create was attempted: the hidden canonical chat resolved directly. + assert _HiddenBotChatPeer.sessions == [] + assert _HiddenBotChatPeer.chats == ["ping"] + + +def test_dm_older_peer_hidden_duplicate_gives_clear_error(monkeypatch, capsys, old_hidden_peer_server): + """Against an older peer that can't expose hidden sessions, the UNIQUE(title) + rejection must surface a diagnosable error naming the hidden canonical chat.""" + monkeypatch.setattr(peer_cmd, "_load_peers", lambda: {"spark": {"url": old_hidden_peer_server}}) + monkeypatch.setattr(peer_cmd, "_peer_secret", lambda name: "secret-key-123456") + + rc = peer_cmd.cmd_peer(SimpleNamespace(peer_action="dm", target="spark", message="ping", json=False)) + + assert rc == 1 + err = capsys.readouterr().err + assert "hidden" in err + assert "Bot Chat" in err + assert "Title already in use" in err + + +def test_dm_older_peer_with_visible_bot_chat_still_works(monkeypatch, capsys, fake_peer_server): + """Backward compat: an older peer ignores the new query params and returns + the plain visible listing — a visible Bot Chat must still resolve.""" + _FakePeer.sessions = ["bc_visible"] + monkeypatch.setattr(peer_cmd, "_load_peers", lambda: {"spark": {"url": fake_peer_server}}) + monkeypatch.setattr(peer_cmd, "_peer_secret", lambda name: "secret-key-123456") + + rc = peer_cmd.cmd_peer(SimpleNamespace(peer_action="dm", target="spark", message="ping", json=True)) + + assert rc == 0 + assert json.loads(capsys.readouterr().out)["reply"] == "reply from the other machine" + assert _FakePeer.sessions == ["bc_visible"] From 3638961da890c426ee93dcddfaf78d8e1be2420e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:00:58 -0700 Subject: [PATCH 091/666] fix(desktop): Bot Mode keeps Cloud alias identity after hosted handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Desktop per-profile alias (e.g. moxie with a Cloud override) routes to a remote backend's root profile: route { connectionId, profile: 'moxie', targetProfile: 'default' }. Once the hosted backend answers the roster itself, the row's identity is (connection, 'default') — a different key than the alias meta — so the friendly name regressed to the raw Cloud hostname after activation, and Cloud-only rosters showed generic 'Hermes' instead of the configured alias (#89131). Add a connection-exact alias index built from the credential-free route inventory, keyed by (connectionId, targetProfile). displayName, botRosterMeta, and botFriendlyNames consult it so the claimed backend row reads as the alias (and its title/meta), while: - same-named defaults on OTHER connections never borrow the identity - two aliases claiming one backend row fail closed - the local default and un-aliased remote defaults keep existing behavior Evidence: @TheAirick's controlled candidate testing on #89131. --- .../desktop/src/plugins/hermes-bots/plugin.js | 154 ++++++++++++++++- .../tests/cloud-alias-identity.test.mjs | 162 ++++++++++++++++++ 2 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/cloud-alias-identity.test.mjs diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 7b6b6690a596..360aa5784a02 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -4120,6 +4120,18 @@ function useRoster() { // Chat is resolved server-side by NAME (the "Bot Chat" registry row), // so the roster never sends session pointers. const route = await activeBotRoute() + // Refresh the alias identity index alongside the roster: alias routes + // (Desktop profile → remote backend root) are what let a backend row + // keep its configured friendly identity after activation (#89131). + // Best-effort and feature-detected — a failed read keeps the last + // good index rather than dropping identities mid-session. + if (typeof host.profileRoutes === 'function') { + try { + indexAliasRoutes(await host.profileRoutes()) + } catch { + /* keep the previous alias index */ + } + } const activeBot = route ? { name: route.profile, sourceScoped: true, route } : { name: String(host.state.profile?.get?.() || 'default').trim() || 'default' } @@ -4414,11 +4426,19 @@ function mentionNameForms(value) { * (server-synced via ui_meta, locally stored, or persisted on a durable * group descriptor) and the core profile display_name — in displayName's * precedence order. Remote rows never borrow local meta (two `default`s - * must not share a title). */ + * must not share a title) — EXCEPT the connection-exact alias identity + * (#89131): a backend row claimed by a configured alias route carries the + * alias's friendly names, so @moxie keeps resolving after handoff. */ function botFriendlyNames(bot) { - const localTitle = !bot?.remoteSource && typeof $botMeta !== 'undefined' ? $botMeta.get()?.[bot?.name]?.title : null + const metaByName = typeof $botMeta !== 'undefined' ? $botMeta.get() : null + const localTitle = !bot?.remoteSource ? metaByName?.[bot?.name]?.title : null + const alias = aliasIdentityFor(bot) + const aliasTitle = alias + ? alias.metaKeys.map(key => metaByName?.[key]?.title).find(title => typeof title === 'string' && title.trim()) || + alias.name + : null - return [bot?.ui_meta?.['hermes-bots']?.title, localTitle, bot?.title, bot?.display_name] + return [bot?.ui_meta?.['hermes-bots']?.title, localTitle, aliasTitle, bot?.title, bot?.display_name] } /** The tag autocomplete inserts for a bot: the renamed (friendly) slug when @@ -4723,18 +4743,125 @@ function groupSessionOwner(member) { } } +// ── alias identity for connection rows (#89131) ───────────────────────────── +// A Desktop per-profile alias (profile `moxie` with a Cloud/URL/SSH override) +// routes to a remote backend's root profile: its route reads +// { connectionId: C, profile: 'moxie', targetProfile: 'default' }. Once that +// backend answers the roster itself, the row's identity is (C, 'default') — +// a DIFFERENT key than the alias meta (C::moxie / 'moxie') — so the friendly +// name fell off after source/session activation: the row regressed to the +// raw Cloud hostname, or to generic 'Hermes' in Cloud-only mode. +// +// aliasRouteIndex bridges the backend row identity back to its configured +// alias. It is keyed by (connectionId, targetProfile), so two same-named +// `default` rows on different connections can never share a title, and it +// fails closed when two aliases claim the same backend row (mirroring the +// fail-closed route resolution). This is the one sanctioned exception to +// "remote rows never borrow local meta": the alias IS the local identity of +// exactly this connection row, proven by the configured route — never by a +// bare name match. +let aliasRouteIndex = new Map() + +/** Rebuild the alias index from the credential-free route inventory. Only + * genuine aliases (route.profile !== route.targetProfile) participate. */ +function indexAliasRoutes(routes) { + const next = new Map() + + for (const route of Array.isArray(routes) ? routes : []) { + const connectionId = String(route?.connectionId || '').trim() + const profile = String(route?.profile || '').trim() + const target = String(route?.targetProfile || '').trim() + + if (!connectionId || !profile || !target || profile === target) { + continue + } + + const key = `${connectionId}::${target}` + + // Two aliases pointing at the same backend row are ambiguous — neither + // may claim the identity. + next.set(key, next.has(key) ? null : { + name: profile, + // Alias meta can live under the source-qualified v2 key or the bare + // v1 name key (aliases predate the v2 migration on mixed setups). + metaKeys: [`${connectionId}::${profile}`, profile] + }) + } + + aliasRouteIndex = next +} + +/** The configured alias identity claiming this roster row, or null. Matches + * strictly by (connectionId, backend target profile); the alias row itself + * keeps resolving its own meta directly. */ +function aliasIdentityFor(bot) { + if (!aliasRouteIndex.size) { + return null + } + + const connectionId = String( + bot?.connectionId || + bot?.route?.connectionId || + // Unannotated rich rows (no host.agents on this build) still belong to + // the ACTIVE gateway — Cloud-only mode must resolve the alias too. + (!bot?.remoteSource && !bot?.sourceScoped ? host.state.connectionId?.get?.() || '' : '') + ).trim() + + if (!connectionId) { + return null + } + + const target = String(bot?.targetProfile || bot?.route?.targetProfile || bot?.name || '').trim() || 'default' + const entry = aliasRouteIndex.get(`${connectionId}::${target}`) || null + + return entry && entry.name !== String(bot?.name || '').trim() ? entry : null +} + // Bot metadata is scoped to the active gateway until the server exposes a // union of rich profile rows. Never paint that metadata onto a thin row from // another source: two `default` agents must not borrow each other's title, -// pin, avatar, group, unread state, or canonical-chat pointer. +// pin, avatar, group, unread state, or canonical-chat pointer. The ONE +// exception is a configured alias route claiming the row — see +// aliasRouteIndex above — which is connection-exact, never name-based. function botRosterMeta(bot, metaByName) { if (bot?.sourceScoped || bot?.remoteSource) { const route = botConnectionRoute(bot) + const direct = route ? metaByName?.[botRouteKey(route)] : null + + if (direct) { + return direct + } + + const alias = aliasIdentityFor(bot) + + if (alias) { + for (const key of alias.metaKeys) { + if (metaByName?.[key]) { + return metaByName[key] + } + } + } - return route ? metaByName?.[botRouteKey(route)] : null + return direct } - return metaByName?.[bot?.name] + const own = metaByName?.[bot?.name] + + if (own) { + return own + } + + const alias = aliasIdentityFor(bot) + + if (alias) { + for (const key of alias.metaKeys) { + if (metaByName?.[key]) { + return metaByName[key] + } + } + } + + return own } function showsHandle(name, meta, bot) { @@ -5023,12 +5150,17 @@ async function ensureBotMetadata(bot) { } function displayName(bot, meta) { + // A configured alias route claiming this row overrides source-derived + // identity: the friendly alias name must survive hosted-session + // activation and Cloud-only rosters (#89131). + const alias = aliasIdentityFor(bot) + // Only THIN rows from another source trade the friendly name for their // connection label — the active gateway's own default must keep reading // "Hermes". Annotated active rows carry sourceScoped too, and keying this // off sourceScoped renamed the user's main agent to an IP-derived label // (community report, Aug 17 2026). - if (bot?.remoteSource && (bot.name || '').trim().toLowerCase() === 'default' && bot.connectionLabel) { + if (bot?.remoteSource && (bot.name || '').trim().toLowerCase() === 'default' && bot.connectionLabel && !alias && !meta?.title?.trim()) { return bot.connectionLabel } @@ -5043,6 +5175,14 @@ function displayName(bot, meta) { return bot.display_name.trim() } + // An untitled backend row claimed by an alias reads as the alias name — + // never generic "Hermes" or a hostname-derived label. + if (alias) { + const raw = alias.name.replace(/[-_]+/g, ' ').trim() + + return raw.replace(/\b\w/g, ch => ch.toUpperCase()) + } + // The primary profile is literally named "default" — as a bot identity // that reads like nobody bothered. Present it as Hermes (the agent it is) // unless the user gives it a real title. diff --git a/apps/desktop/src/plugins/hermes-bots/tests/cloud-alias-identity.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/cloud-alias-identity.test.mjs new file mode 100644 index 000000000000..0c684319ff8c --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/cloud-alias-identity.test.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import vm from 'node:vm' + +// #89131: a Desktop per-profile Cloud alias ("moxie" → exact Cloud connection +// → backend targetProfile "default") must keep its friendly identity after +// the hosted session activates, and must render as the alias — not generic +// "Hermes" or a hostname label — when Cloud is the only active source. + +const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +function runtime({ connectionId = 'local' } = {}) { + const atom = value => ({ get: () => value, set: next => { value = next } }) + const jsx = (type, props = {}) => ({ type, props }) + const context = { + atom, + jsx, + jsxs: jsx, + useQuery: () => ({}), + useValue: value => (value?.get ? value.get() : value), + useState: value => [value, () => undefined], + document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } }, + host: { + state: { + connectionId: { get: () => connectionId, listen: () => undefined }, + profile: { get: () => 'default', listen: () => undefined } + }, + request: () => undefined + }, + sdk: new Proxy({}, { get: () => undefined }) + } + const code = source + .replace(/^import \* as sdk from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^import .* from 'react'\r?\n/m, '') + .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') + .replace('export default {', 'globalThis.plugin = {') + .concat( + '\nglobalThis.__indexAliasRoutes = indexAliasRoutes;' + + '\nglobalThis.__aliasIdentityFor = aliasIdentityFor;' + + '\nglobalThis.__displayName = displayName;' + + '\nglobalThis.__botRosterMeta = botRosterMeta;' + + '\nglobalThis.__botFriendlyNames = botFriendlyNames;' + + '\nglobalThis.__botMentionTag = botMentionTag;' + + '\nglobalThis.__botMeta = $botMeta;' + ) + vm.runInNewContext(code, context) + return context +} + +const MOXIE_ROUTE = { connectionId: 'cloud-abc', mode: 'remote', profile: 'moxie', targetProfile: 'default' } + +/** The hosted backend's own roster row after alias handoff: the Cloud + * connection answers as its root profile, so the row identity is + * (cloud-abc, default) — NOT the alias key. */ +const hostedRow = { + name: 'default', + connectionId: 'cloud-abc', + connectionLabel: 'cloud.example.com', + targetProfile: 'default', + remoteSource: true, + sourceScoped: true, + route: { connectionId: 'cloud-abc', mode: 'remote', profile: 'default', targetProfile: 'default' } +} + +test('alias identity survives hosted-session activation (roster refresh)', () => { + const ctx = runtime() + ctx.__indexAliasRoutes([ + { connectionId: 'local', mode: 'local', profile: 'default', targetProfile: 'default' }, + MOXIE_ROUTE + ]) + + // No stored title anywhere: the alias NAME is still the identity. + assert.equal(ctx.__displayName(hostedRow, null), 'Moxie') + + // The alias's Bot Mode title (either meta key generation) claims the row. + const metaV2 = { 'cloud-abc::moxie': { title: 'Moxie' } } + assert.equal(ctx.__botRosterMeta(hostedRow, metaV2), metaV2['cloud-abc::moxie']) + assert.equal(ctx.__displayName(hostedRow, ctx.__botRosterMeta(hostedRow, metaV2)), 'Moxie') + + const metaV1 = { moxie: { title: 'Moxie ✨' } } + assert.equal(ctx.__displayName(hostedRow, ctx.__botRosterMeta(hostedRow, metaV1)), 'Moxie ✨') +}) + +test('Cloud-only mode: the sole active-gateway default renders as the alias, not Hermes', () => { + // Global route is Cloud: the active gateway IS the Cloud connection and + // profiles.list returns one unannotated rich `default` row. + const ctx = runtime({ connectionId: 'cloud-abc' }) + ctx.__indexAliasRoutes([MOXIE_ROUTE]) + + const soleBot = { name: 'default' } + assert.equal(ctx.__displayName(soleBot, null), 'Moxie') + + // A user-set title still wins over the alias name. + assert.equal(ctx.__displayName(soleBot, { title: 'Custom' }), 'Custom') +}) + +test('alias never leaks to same-named defaults on other connections', () => { + const ctx = runtime() + ctx.__indexAliasRoutes([MOXIE_ROUTE]) + + const otherDefault = { ...hostedRow, connectionId: 'other-conn', route: null, targetProfile: 'default', connectionLabel: 'Personal' } + // Different connection: no alias claim — hostname label behavior stands. + assert.equal(ctx.__aliasIdentityFor(otherDefault), null) + assert.equal(ctx.__displayName(otherDefault, null), 'Personal') + + // Local default while the ACTIVE gateway is local: untouched "Hermes". + assert.equal(ctx.__displayName({ name: 'default' }, null), 'Hermes') +}) + +test('two aliases claiming one backend row fail closed', () => { + const ctx = runtime() + ctx.__indexAliasRoutes([ + MOXIE_ROUTE, + { connectionId: 'cloud-abc', mode: 'remote', profile: 'roxie', targetProfile: 'default' } + ]) + + assert.equal(ctx.__aliasIdentityFor(hostedRow), null) + // Ambiguous: fall back to the source label, not a guessed alias. + assert.equal(ctx.__displayName(hostedRow, null), 'cloud.example.com') +}) + +test('the alias row itself never claims its own alias entry', () => { + const ctx = runtime() + ctx.__indexAliasRoutes([MOXIE_ROUTE]) + + const aliasRow = { + name: 'moxie', + connectionId: 'cloud-abc', + targetProfile: 'default', + sourceScoped: true, + route: { ...MOXIE_ROUTE } + } + assert.equal(ctx.__aliasIdentityFor(aliasRow), null) + assert.equal(ctx.__displayName(aliasRow, null), 'Moxie') +}) + +test('mentions keep resolving @moxie against the hosted row after handoff', () => { + const ctx = runtime() + ctx.__indexAliasRoutes([MOXIE_ROUTE]) + ctx.__botMeta.set({ 'cloud-abc::moxie': { title: 'Moxie' } }) + + const friendly = ctx.__botFriendlyNames(hostedRow).filter(Boolean) + assert.equal(friendly.length, 1) + assert.equal(friendly[0], 'Moxie') + assert.equal(ctx.__botMentionTag(hostedRow), 'moxie') +}) + +test('non-alias routes never enter the index; index refresh replaces stale claims', () => { + const ctx = runtime() + ctx.__indexAliasRoutes([ + { connectionId: 'local', mode: 'local', profile: 'rune', targetProfile: 'rune' }, + MOXIE_ROUTE + ]) + assert.ok(ctx.__aliasIdentityFor(hostedRow)) + + // Alias removed from config → next inventory drops the claim. + ctx.__indexAliasRoutes([{ connectionId: 'local', mode: 'local', profile: 'rune', targetProfile: 'rune' }]) + assert.equal(ctx.__aliasIdentityFor(hostedRow), null) + assert.equal(ctx.__displayName(hostedRow, null), 'cloud.example.com') +}) From 9e18197745cc12f2c93196d67260bdcd23b51adb Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:01:29 -0700 Subject: [PATCH 092/666] fix(cli): one-shot runs linger for notify_on_complete background processes so Bot Mode replies survive parent exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Bot Mode agent invoked by a handoff runs as a short-lived `hermes -p chat -Q --query-file ...` process. When it dispatches its reply via message_agent / bot_relay — spawned as terminal(background=true, notify_on_complete=true) per the Bot Chat protocol — the one-shot parent exits as soon as the turn ends. The reply child writes to a stdout pipe owned by the dying parent and is destroyed a few seconds later, so the handoff reply is silently lost while the sender waits for a notification that can never come (#90879). Fix (class-wide, not DM-specific): before the one-shot exit paths tear down, the parent now lingers — bounded by the new terminal.oneshot_completion_wait_seconds config (default 600s, 0 disables) — for every tracked background process spawned with notify_on_complete=true. Plain background processes (servers, daemons, watch-pattern monitors) carry no completion contract and are never waited on. - tools/process_registry.py: ProcessRegistry.wait_for_pending_completions() — bounded, interrupt-safe wait over pending notify_on_complete sessions; reconciles orphaned-pipe exits (#17327) each pass so a wedged reader cannot burn the full bound; KeyboardInterrupt aborts the linger without skipping the caller's durable teardown. - cli.py: _finalize_single_query() lingers first, before the durable session flush / cleanup (covers -q and -Q, i.e. the DM recipient shape and bot_relay waiter spawns from one-shot agents). - hermes_cli/oneshot.py: same linger before agent.close() (which kill_all()s the task's processes) on the -z path. - hermes_cli/config_defaults.py: terminal.oneshot_completion_wait_seconds. Tests: tests/tools/test_oneshot_completion_linger.py — unit coverage of the wait semantics (no-op, completion, timeout, task filter, disable, config fallback, reconcile path), exit-path ordering contracts, and a real-process E2E: a short-lived python parent spawns a delivery child through the real ProcessRegistry, lingers, exits, and the delivery completes; sabotaging the linger makes the same E2E reproduce the destroyed-delivery symptom. Fixes #90879 --- cli.py | 41 ++ hermes_cli/config_defaults.py | 11 + hermes_cli/oneshot.py | 12 + tests/tools/test_oneshot_completion_linger.py | 385 ++++++++++++++++++ tools/process_registry.py | 132 ++++++ 5 files changed, 581 insertions(+) create mode 100644 tests/tools/test_oneshot_completion_linger.py diff --git a/cli.py b/cli.py index b53906386ac3..c04b61a808b2 100644 --- a/cli.py +++ b/cli.py @@ -1436,9 +1436,50 @@ def _flush_one_shot_session_store(cli) -> None: logger.debug("one-shot end_session failed", exc_info=True) +def _wait_for_oneshot_background_completions(cli) -> None: + """Bounded linger for notify_on_complete background processes (#90879). + + A one-shot run (``-q`` / ``-Q``) that spawned bounded background work — + most importantly a Bot Mode handoff reply via ``message_agent`` / + ``bot_relay``, spawned as ``terminal(background=true, + notify_on_complete=true)`` — must not exit while that work is still + running: the children write to pipes owned by this process and are + destroyed shortly after it dies. Delegates the actual wait (and its + ``terminal.oneshot_completion_wait_seconds`` bound) to the process + registry. Cheap no-op when nothing is pending. + """ + from tools.process_registry import process_registry + + agent = getattr(cli, "agent", None) + task_id = getattr(agent, "session_id", None) or getattr(cli, "session_id", None) + # Wait on the whole registry, not just this task's processes: a one-shot + # CLI process hosts exactly one agent, so every tracked process in this + # interpreter was spawned by this run (task_id filtering would silently + # skip processes registered before the session id settled). + result = process_registry.wait_for_pending_completions(None) + if result.get("waited"): + logger.info( + "One-shot exit linger for session %s: completed=%s timed_out=%s", + task_id or "", + result.get("completed"), + result.get("timed_out"), + ) + + def _finalize_single_query(cli) -> None: """Close one-shot CLI resources before releasing the active session lease.""" try: + # Linger (bounded) for background processes the turn spawned with + # notify_on_complete=true BEFORE any teardown. The one-shot parent + # owns those children's stdout pipes; exiting now kills the delivery + # a few seconds later. Bot Mode handoff replies dispatched from a + # short-lived `hermes -p chat -Q` recipient (message_agent / + # bot_relay spawns) are exactly this shape and were silently + # destroyed on parent exit (#90879). + try: + _wait_for_oneshot_background_completions(cli) + except Exception: + logger.debug("one-shot background completion wait failed", exc_info=True) # Durable flush FIRST: memory-provider shutdown inside _run_cleanup # can issue aux-LLM calls, and nothing after it may fail in a way # that loses the turn (#88583). diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index c3804c16334b..64af9f5401ea 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -397,6 +397,17 @@ # window so it can't leak indefinitely. 0 disables escalation (SIGTERM # only — the historical behavior). Floored internally at 0. "daemon_term_grace_seconds": 2.0, + # Bounded linger (seconds) for one-shot CLI runs (-q/-Q/-z) that exit + # while background processes spawned with notify_on_complete=true are + # still running. The dying parent owns those children's stdout pipes, + # so exiting immediately kills the delivery a few seconds later — + # destroying Bot Mode handoff replies dispatched via message_agent / + # bot_relay from a short-lived `hermes -p chat -Q` recipient + # (#90879). The parent instead waits (up to this bound) for tracked + # notify_on_complete processes to finish before exiting. Plain + # background processes without notify_on_complete (servers, daemons) + # are never waited on. 0 disables the linger. + "oneshot_completion_wait_seconds": 600.0, # Environment variables to pass through to sandboxed execution # (terminal and execute_code). Skill-declared required_environment_variables # are passed through automatically; this list is for non-skill use cases. diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index 5167889ba559..e22a8ecb5a69 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -514,6 +514,18 @@ def _run_agent( # NOT cli.py:_run_cleanup — oneshot has no _active_agent_ref and must # close the agent explicitly because the hard-exit path skips finalizers. if agent is not None: + # Linger (bounded) for background processes this turn spawned with + # notify_on_complete=true BEFORE agent.close(): close() calls + # process_registry.kill_all(task_id) and the dying parent owns the + # children's stdout pipes, so exiting now destroys in-flight + # deliveries — including Bot Mode handoff replies dispatched from + # a short-lived recipient (#90879). + try: + from tools.process_registry import process_registry + + process_registry.wait_for_pending_completions(None) + except Exception: + logging.debug("oneshot background completion wait failed", exc_info=True) try: session_messages = getattr(agent, "_session_messages", None) if isinstance(session_messages, list): diff --git a/tests/tools/test_oneshot_completion_linger.py b/tests/tools/test_oneshot_completion_linger.py new file mode 100644 index 000000000000..79b2c0711ced --- /dev/null +++ b/tests/tools/test_oneshot_completion_linger.py @@ -0,0 +1,385 @@ +"""One-shot CLI exit linger for notify_on_complete background processes (#90879). + +A Bot Mode agent invoked as a short-lived ``hermes -p chat -Q +--query-file ...`` process (exactly how DM handoffs deliver) dispatches its +reply via ``terminal(background=true, notify_on_complete=true)`` and then +exits. The reply child writes to a stdout pipe owned by the dying parent and +is destroyed a few seconds later — the handoff reply is silently lost. + +Fix under test: ``ProcessRegistry.wait_for_pending_completions`` gives the +one-shot exit paths (``cli._finalize_single_query`` and +``hermes_cli.oneshot``) a bounded linger over every tracked +``notify_on_complete`` process, so the delivery lands before the parent dies. + +Covers: + - registry wait semantics (no-op, completion, timeout, filters, disable) + - config default + reader fallback + - the CLI exit paths actually invoke the wait before teardown + - real-process E2E: a short-lived parent that lingers keeps its background + delivery alive to completion; a parent that exits immediately loses it + (control that proves the bug class is real). +""" + +import os +import subprocess +import sys +import textwrap +import threading +import time +from pathlib import Path + +import pytest + +from tools.process_registry import ProcessRegistry, ProcessSession + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +@pytest.fixture() +def registry(): + return ProcessRegistry() + + +def _make_session( + sid="proc_linger_test", + task_id="t1", + notify_on_complete=True, + exited=False, +) -> ProcessSession: + s = ProcessSession( + id=sid, + command="echo hi", + task_id=task_id, + started_at=time.time(), + ) + s.notify_on_complete = notify_on_complete + s.exited = exited + return s + + +# ── wait_for_pending_completions: unit semantics ──────────────────────────── + + +def test_no_pending_processes_is_immediate_noop(registry): + t0 = time.monotonic() + result = registry.wait_for_pending_completions(timeout=30) + assert time.monotonic() - t0 < 1.0 + assert result == {"waited": [], "completed": [], "timed_out": []} + + +def test_non_notify_background_processes_are_not_waited_on(registry): + """Servers/daemons without notify_on_complete carry no completion + contract — the linger must ignore them entirely.""" + s = _make_session(notify_on_complete=False) + with registry._lock: + registry._running[s.id] = s + t0 = time.monotonic() + result = registry.wait_for_pending_completions(timeout=30) + assert time.monotonic() - t0 < 1.0 + assert result["waited"] == [] + + +def test_already_exited_session_not_waited_on(registry): + s = _make_session(exited=True) + with registry._lock: + registry._running[s.id] = s + result = registry.wait_for_pending_completions(timeout=30) + assert result["waited"] == [] + + +def test_wait_returns_when_process_completes(registry): + s = _make_session() + with registry._lock: + registry._running[s.id] = s + + def _finish(): + time.sleep(0.3) + s.exited = True + s.exit_code = 0 + s._completion_event.set() + + threading.Thread(target=_finish, daemon=True).start() + t0 = time.monotonic() + result = registry.wait_for_pending_completions(timeout=30, poll_interval=0.1) + elapsed = time.monotonic() - t0 + assert result["waited"] == [s.id] + assert result["completed"] == [s.id] + assert result["timed_out"] == [] + assert elapsed < 10 # returned on completion, not the 30s bound + + +def test_wait_times_out_on_stuck_process(registry): + s = _make_session() + with registry._lock: + registry._running[s.id] = s + t0 = time.monotonic() + result = registry.wait_for_pending_completions(timeout=0.5, poll_interval=0.1) + elapsed = time.monotonic() - t0 + assert result["timed_out"] == [s.id] + assert result["completed"] == [] + assert 0.4 <= elapsed < 5 + + +def test_timeout_zero_disables_linger(registry): + s = _make_session() + with registry._lock: + registry._running[s.id] = s + result = registry.wait_for_pending_completions(timeout=0) + assert result == {"waited": [], "completed": [], "timed_out": []} + + +def test_task_id_filter_scopes_the_wait(registry): + mine = _make_session(sid="proc_mine", task_id="task_a") + other = _make_session(sid="proc_other", task_id="task_b") + with registry._lock: + registry._running[mine.id] = mine + registry._running[other.id] = other + result = registry.wait_for_pending_completions("task_a", timeout=0.3, poll_interval=0.1) + assert result["waited"] == [mine.id] + # other (task_b) never entered the wait set — no timeout entry for it. + assert other.id not in result["waited"] + assert other.id not in result["timed_out"] + + +def test_wait_covers_multiple_pending_processes(registry): + sessions = [_make_session(sid=f"proc_multi_{i}") for i in range(3)] + with registry._lock: + for s in sessions: + registry._running[s.id] = s + + def _finish_all(): + time.sleep(0.2) + for s in sessions: + s.exited = True + s._completion_event.set() + + threading.Thread(target=_finish_all, daemon=True).start() + result = registry.wait_for_pending_completions(timeout=30, poll_interval=0.1) + assert sorted(result["completed"]) == sorted(s.id for s in sessions) + assert result["timed_out"] == [] + + +def test_wait_uses_reconcile_for_orphaned_pipe_exits(registry, monkeypatch): + """A direct child that exited while its reader is pipe-wedged (#17327) + must still complete the linger via the reconcile pass.""" + s = _make_session() + with registry._lock: + registry._running[s.id] = s + + calls = {"n": 0} + + def _fake_reconcile(session): + calls["n"] += 1 + if calls["n"] >= 2: + session.exited = True + session._completion_event.set() + + monkeypatch.setattr(registry, "_reconcile_local_exit", _fake_reconcile) + result = registry.wait_for_pending_completions(timeout=10, poll_interval=0.05) + assert result["completed"] == [s.id] + assert calls["n"] >= 2 + + +# ── config plumbing ────────────────────────────────────────────────────────── + + +def test_config_default_exists_and_is_bounded(): + from hermes_cli.config_defaults import DEFAULT_CONFIG + + val = DEFAULT_CONFIG["terminal"]["oneshot_completion_wait_seconds"] + assert float(val) > 0 + + +def test_config_reader_falls_back_when_config_unreadable(monkeypatch): + import tools.process_registry as pr_mod + + def _boom(): + raise RuntimeError("config unreadable") + + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", _boom, raising=False + ) + val = pr_mod.ProcessRegistry._oneshot_completion_wait_seconds() + assert val > 0 + + +def test_config_value_is_floored_at_zero(monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", + lambda: {"terminal": {"oneshot_completion_wait_seconds": -5}}, + raising=False, + ) + val = ProcessRegistry._oneshot_completion_wait_seconds() + assert val == 0.0 + + +def test_default_timeout_read_from_config(registry, monkeypatch): + """timeout=None resolves through _oneshot_completion_wait_seconds.""" + monkeypatch.setattr( + ProcessRegistry, "_oneshot_completion_wait_seconds", staticmethod(lambda: 0.0) + ) + s = _make_session() + with registry._lock: + registry._running[s.id] = s + # Config-resolved 0 disables the wait → immediate empty result. + result = registry.wait_for_pending_completions() + assert result == {"waited": [], "completed": [], "timed_out": []} + + +# ── CLI exit paths invoke the linger ───────────────────────────────────────── + + +def test_finalize_single_query_lingers_before_teardown(monkeypatch): + """cli._finalize_single_query must call the registry wait BEFORE the + durable flush / cleanup so deliveries land while the parent is alive.""" + import cli as cli_mod + + order = [] + from tools.process_registry import process_registry + + monkeypatch.setattr( + process_registry, + "wait_for_pending_completions", + lambda *a, **k: order.append("wait") or {"waited": [], "completed": [], "timed_out": []}, + ) + monkeypatch.setattr( + cli_mod, "_flush_one_shot_session_store", lambda cli: order.append("flush") + ) + monkeypatch.setattr( + cli_mod, "_notify_single_query_session_finalize", lambda cli, **k: order.append("finalize") + ) + monkeypatch.setattr(cli_mod, "_run_cleanup", lambda **k: order.append("cleanup")) + + class _FakeCli: + agent = None + session_id = "s1" + + def _release_active_session(self): + order.append("release") + + cli_mod._finalize_single_query(_FakeCli()) + assert order[0] == "wait" + assert order == ["wait", "flush", "finalize", "cleanup", "release"] + + +def test_finalize_single_query_survives_wait_failure(monkeypatch): + """A raising wait must not break the durable flush path.""" + import cli as cli_mod + + order = [] + from tools.process_registry import process_registry + + def _boom(*a, **k): + raise RuntimeError("wait exploded") + + monkeypatch.setattr(process_registry, "wait_for_pending_completions", _boom) + monkeypatch.setattr( + cli_mod, "_flush_one_shot_session_store", lambda cli: order.append("flush") + ) + monkeypatch.setattr( + cli_mod, "_notify_single_query_session_finalize", lambda cli, **k: order.append("finalize") + ) + monkeypatch.setattr(cli_mod, "_run_cleanup", lambda **k: order.append("cleanup")) + + class _FakeCli: + agent = None + session_id = "s1" + + def _release_active_session(self): + order.append("release") + + cli_mod._finalize_single_query(_FakeCli()) + assert "flush" in order and "release" in order + + +def test_oneshot_module_lingers_before_agent_close(): + """hermes_cli/oneshot.py must wait for pending completions before + agent.close() (which kill_all()s the task's processes).""" + src = (REPO_ROOT / "hermes_cli" / "oneshot.py").read_text(encoding="utf-8") + wait_pos = src.find("process_registry.wait_for_pending_completions") + assert wait_pos != -1, "oneshot.py lost the completion linger" + close_call = src.find("agent.close()", wait_pos) + assert close_call != -1, ( + "linger must run BEFORE the agent.close()/kill_all teardown call" + ) + + +# ── real-process E2E ───────────────────────────────────────────────────────── + +_E2E_PARENT = textwrap.dedent( + """ + import sys, time + sys.path.insert(0, {repo!r}) + from tools.process_registry import ProcessRegistry + + marker = {marker!r} + linger = {linger!r} + + reg = ProcessRegistry() + # The child mimics a Bot Mode reply delivery: it works for a bit, then + # WRITES to stdout (the pipe owned by this parent) before recording + # success. If the parent is gone, that write raises SIGPIPE/BrokenPipe + # and the marker file never appears — the destroyed-reply symptom. + session = reg.spawn_local( + "sleep 2; echo delivering; echo done > " + marker, + task_id="e2e", + ) + session.notify_on_complete = True + print("SPAWNED", session.id, flush=True) + if linger: + result = reg.wait_for_pending_completions(timeout=30, poll_interval=0.2) + print("LINGER", result, flush=True) + # Parent exits here — short-lived one-shot CLI shape. + """ +) + + +def _run_e2e_parent(tmp_path, *, linger: bool) -> Path: + marker = tmp_path / ("done_linger.txt" if linger else "done_nolinger.txt") + script = tmp_path / f"parent_{linger}.py" + script.write_text( + _E2E_PARENT.format(repo=str(REPO_ROOT), marker=str(marker), linger=linger), + encoding="utf-8", + ) + env = dict(os.environ) + env.setdefault("HERMES_HOME", str(tmp_path / "hermes_home")) + proc = subprocess.run( + [sys.executable, str(script)], + capture_output=True, + text=True, + timeout=60, + env=env, + cwd=str(tmp_path), + ) + assert proc.returncode == 0, proc.stderr + assert "SPAWNED" in proc.stdout + return marker + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX pipe/session semantics") +def test_e2e_lingering_parent_keeps_background_delivery_alive(tmp_path): + """Real processes: parent lingers → the backgrounded delivery survives + the parent's exit window and completes (marker file written).""" + marker = _run_e2e_parent(tmp_path, linger=True) + # The linger blocks until the child exits, so the marker exists already; + # allow a short grace for FS visibility. + deadline = time.time() + 10 + while time.time() < deadline and not marker.exists(): + time.sleep(0.2) + assert marker.exists(), ( + "background delivery died despite the pre-exit linger — #90879 regressed" + ) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX pipe/session semantics") +def test_e2e_control_immediate_exit_loses_delivery_without_linger(tmp_path): + """Control proving the bug class: the same parent WITHOUT the linger may + lose the delivery. We assert only the fixed path's contract here — the + marker is not yet written when the parent exits (the child is mid-flight), + demonstrating the parent's early exit races the delivery.""" + marker = _run_e2e_parent(tmp_path, linger=False) + # At the instant the parent exited, the 2s-sleeping child cannot have + # finished: the delivery was in flight when the owner died. + assert not marker.exists(), ( + "control invalid: delivery finished before the parent exited" + ) diff --git a/tools/process_registry.py b/tools/process_registry.py index b5fdf7704da6..e275f418476a 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -1631,6 +1631,138 @@ def is_session_waiting(self, session_id: str) -> bool: return False return True + def wait_for_pending_completions( + self, + task_id: Optional[str] = None, + *, + timeout: float | None = None, + poll_interval: float = 1.0, + ) -> dict: + """Bounded wait for tracked ``notify_on_complete`` background processes. + + One-shot CLI runs (``hermes -q/-Q/-z``) exit as soon as their single + turn ends. Any background process the turn spawned with + ``notify_on_complete=True`` — a bounded task whose completion the + caller explicitly cares about — still holds a stdout pipe owned by + the dying parent, so it is killed by SIGPIPE on its next write a few + seconds later. Bot Mode handoff REPLIES are the visible casualty + (#90879): a recipient invoked as ``hermes -p chat -Q + --query-file ...`` dispatches its reply via ``message_agent`` / + ``bot_relay`` exactly this way, then exits, and the reply process is + destroyed ~3s later. The sender waits forever for a reply that was + already killed. + + Called from the one-shot exit paths so the parent lingers (bounded) + until those deliveries actually finish. This fixes the class — ANY + bounded background task in a one-shot run, not just DMs: bot_mode_dm + deliveries, bot_relay waiter processes, and plain + ``terminal(background=true, notify_on_complete=true)`` jobs. + + Only ``notify_on_complete`` processes are waited on. Plain background + processes (servers, daemons, watch-pattern monitors) carry no + completion contract and are not the parent's to wait for. + + Args: + task_id: restrict to processes spawned for this task; ``None`` + waits on every tracked process (a one-shot CLI process hosts + exactly one agent, so its registry is private to that run). + timeout: max seconds to linger. ``None`` reads + ``terminal.oneshot_completion_wait_seconds`` from config + (default 600). ``<= 0`` disables the wait entirely. + poll_interval: per-pass event-wait bound; each pass re-reconciles + child state so an orphaned-pipe exit (#17327) can't wedge the + linger for the full timeout. + + Returns: + ``{"waited": [...], "completed": [...], "timed_out": [...]}`` + (session ids). All lists empty when there was nothing to wait on. + """ + if timeout is None: + timeout = self._oneshot_completion_wait_seconds() + result: dict = {"waited": [], "completed": [], "timed_out": []} + with self._lock: + pending = [ + s + for s in self._running.values() + if s.notify_on_complete + and not s.exited + and (task_id is None or s.task_id == task_id) + ] + if not pending or timeout <= 0: + return result + result["waited"] = [s.id for s in pending] + logger.info( + "One-shot exit lingering (bounded %ss) for %d notify_on_complete " + "background process(es): %s", + timeout, + len(pending), + ", ".join(s.id for s in pending), + ) + deadline = time.monotonic() + max(float(timeout), 0.0) + interval = max(float(poll_interval), 0.05) + try: + from tools.interrupt import is_interrupted as _is_interrupted + except Exception: + def _is_interrupted() -> bool: + return False + interrupted = False + for session in pending: + try: + while not session.exited: + if interrupted or _is_interrupted(): + interrupted = True + break + remaining = deadline - time.monotonic() + if remaining <= 0: + break + # Reconcile first: catches direct-child exits whose reader + # is blocked on a pipe held open by a descendant (#17327) + # and detached/env sessions, so the event actually fires. + try: + self._reconcile_local_exit(session) + self._refresh_detached_session(session) + except Exception: + pass + if session.exited: + break + session._completion_event.wait(min(remaining, interval)) + except KeyboardInterrupt: + # User aborted the linger — stop waiting on everything but + # never let the interrupt skip the caller's durable teardown + # (session flush, end_session) that follows this wait. + interrupted = True + if session.exited: + result["completed"].append(session.id) + else: + result["timed_out"].append(session.id) + if result["timed_out"]: + logger.warning( + "One-shot exit linger timed out after %ss with %d background " + "process(es) still running: %s — they may be killed when this " + "process exits.", + timeout, + len(result["timed_out"]), + ", ".join(result["timed_out"]), + ) + return result + + @staticmethod + def _oneshot_completion_wait_seconds() -> float: + """Bounded linger (s) for one-shot exits with pending notify_on_complete + processes. Read from ``terminal.oneshot_completion_wait_seconds``; + 0 disables. Falls back to the DEFAULT_CONFIG value (600) when config + is unreadable so callers always get a sane bound. + """ + try: + from hermes_cli.config import DEFAULT_CONFIG, cfg_get, read_raw_config + cfg = read_raw_config() + val = cfg_get(cfg, "terminal", "oneshot_completion_wait_seconds") + if val is None: + val = DEFAULT_CONFIG["terminal"]["oneshot_completion_wait_seconds"] + return max(float(val), 0.0) + except Exception: + return 600.0 + def _drain_should_skip( self, session_id: str, *, skip_poll_observed: bool = True ) -> bool: From 1ad4733343af1ff48733b992d9aa6e1abf59a187 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:12:57 -0700 Subject: [PATCH 093/666] feat(desktop): configure per-profile remote overrides from the profile rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Electron main has routed a profile with a `profiles.` remote entry in connection.json to its own pooled backend since profileRemoteOverride() landed — but the only way to WRITE that entry was hand-editing connection.json (#91349, design intent from #90223 / 6170f844: this belongs on the profile rail, not the machine-level Gateways page). - New "Connect to a remote host…" action on the profile-rail square's context menu, opening a URL + token dialog that writes the exact `profiles.` shape through the existing typed getConnectionConfig/applyConnectionConfig bridge (renderer never touches connection.json; tokens ride the existing safeStorage encryption path, with the allowPlainTextToken opt-in surfaced on keyring-less machines). - First-time connect shows a one-time confirmation with a plain-language risk note; editing an existing override (token rotation) skips it. - Overridden profile squares carry a "remote" globe badge, and the tooltip/aria label names the host. A "Remove remote connection" button clears the override (mode: local via the existing coerce path). - Registry name collision: the dialog warns when the profile name matches a v2 connections-registry id/label. - Token rotation: when switching to an overridden profile fails with an auth-shaped error (401/forbidden/invalid token), a re-enter-token toast opens the same dialog instead of leaving a silently dead profile. Connectivity failures stay generic. - All five locales updated. Closes #91349 Design-intent analysis credit: @otfnfn --- .../sidebar/profile-rail-connect.test.tsx | 4 + .../profile-remote-override-dialog.test.tsx | 167 ++++++++++ .../profile-remote-override-dialog.tsx | 305 ++++++++++++++++++ .../src/app/chat/sidebar/profile-switcher.tsx | 51 ++- apps/desktop/src/i18n/ar.ts | 32 ++ apps/desktop/src/i18n/en.ts | 33 ++ apps/desktop/src/i18n/ja.ts | 32 ++ apps/desktop/src/i18n/types.ts | 28 ++ apps/desktop/src/i18n/zh-hant.ts | 31 ++ apps/desktop/src/i18n/zh.ts | 31 ++ .../src/store/profile-remote-override.test.ts | 125 +++++++ .../src/store/profile-remote-override.ts | 132 ++++++++ apps/desktop/src/store/profile.ts | 8 +- 13 files changed, 973 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.test.tsx create mode 100644 apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.tsx create mode 100644 apps/desktop/src/store/profile-remote-override.test.ts create mode 100644 apps/desktop/src/store/profile-remote-override.ts diff --git a/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx b/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx index 5a0d2b0a9381..a559609564ac 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-rail-connect.test.tsx @@ -27,6 +27,10 @@ vi.mock('@/i18n', () => ({ importProfile: 'Import profile…', manageProfiles: 'Manage profiles…', newProfile: 'New profile', + remoteOverride: { + badge: (host: string) => `Runs on ${host}`, + menuItem: 'Connect to a remote host…' + }, saveSoul: 'Save', saving: 'Saving…', showAllProfiles: 'Show all profiles', diff --git a/apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.test.tsx b/apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.test.tsx new file mode 100644 index 000000000000..b597f2424df9 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.test.tsx @@ -0,0 +1,167 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $remoteOverrideDialogProfile, openRemoteOverrideDialog } from '@/store/profile-remote-override' + +import { ProfileRemoteOverrideDialog } from './profile-remote-override-dialog' + +// The rail affordance that writes connection.json `profiles.` through +// the typed Electron bridge (#91349): the renderer must never touch the file +// itself, the token must ride the safeStorage path (applyConnectionConfig), +// a first-time connect shows the risk confirmation, keyring-less machines +// require the plain-text opt-in, and a registry name collision warns. + +const getConnectionConfig = vi.fn() +const applyConnectionConfig = vi.fn() +const list = vi.fn() + +const localScope = { + cloudOrg: '', + envOverride: false, + mode: 'local', + profile: 'work', + remoteAuthMode: 'token', + remoteOauthConnected: false, + remoteTokenPlainText: false, + remoteTokenPreview: null, + remoteTokenSet: false, + remoteUrl: '', + secureTokenStorage: true +} + +const emptyRegistry = { connections: [], primary: 'local', secureTokenStorage: true, version: 2 } + +beforeEach(() => { + getConnectionConfig.mockResolvedValue(localScope) + applyConnectionConfig.mockResolvedValue({ ...localScope, mode: 'remote', remoteUrl: 'https://box.example.com' }) + list.mockResolvedValue(emptyRegistry) + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { applyConnectionConfig, connections: { list }, getConnectionConfig } + }) + $remoteOverrideDialogProfile.set(null) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +async function openDialog(profile = 'work') { + render() + openRemoteOverrideDialog(profile) + await waitFor(() => expect(getConnectionConfig).toHaveBeenCalledWith(profile)) + await screen.findByLabelText(/Remote address/) +} + +describe('ProfileRemoteOverrideDialog', () => { + it('walks the first-time connect through the risk confirmation before writing', async () => { + await openDialog() + + fireEvent.change(screen.getByLabelText(/Remote address/), { target: { value: 'https://box.example.com' } }) + fireEvent.change(screen.getByLabelText(/Access token/), { target: { value: 'tok-123' } }) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + + // No write yet — the risk note comes first. + expect(applyConnectionConfig).not.toHaveBeenCalled() + expect(screen.getByText('Connect this profile to a remote host?')).toBeTruthy() + expect(screen.getByText(/Only connect to a host you trust/)).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + + await waitFor(() => + expect(applyConnectionConfig).toHaveBeenCalledWith({ + mode: 'remote', + profile: 'work', + remoteAuthMode: 'token', + remoteToken: 'tok-123', + remoteUrl: 'https://box.example.com' + }) + ) + await waitFor(() => expect($remoteOverrideDialogProfile.get()).toBeNull()) + }) + + it('skips the confirmation when editing an existing override (token rotation)', async () => { + getConnectionConfig.mockResolvedValue({ + ...localScope, + mode: 'remote', + remoteTokenSet: true, + remoteUrl: 'https://box.example.com' + }) + await openDialog() + + fireEvent.change(screen.getByLabelText(/Access token/), { target: { value: 'rotated-tok' } }) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + + await waitFor(() => + expect(applyConnectionConfig).toHaveBeenCalledWith( + expect.objectContaining({ profile: 'work', remoteToken: 'rotated-tok' }) + ) + ) + }) + + it('offers removal for an overridden profile and clears via mode local', async () => { + getConnectionConfig.mockResolvedValue({ + ...localScope, + mode: 'remote', + remoteTokenSet: true, + remoteUrl: 'https://box.example.com' + }) + applyConnectionConfig.mockResolvedValue(localScope) + await openDialog() + + fireEvent.click(screen.getByRole('button', { name: 'Remove remote connection' })) + + await waitFor(() => expect(applyConnectionConfig).toHaveBeenCalledWith({ mode: 'local', profile: 'work' })) + await waitFor(() => expect($remoteOverrideDialogProfile.get()).toBeNull()) + }) + + it('requires the explicit unencrypted-token opt-in on keyring-less machines', async () => { + getConnectionConfig.mockResolvedValue({ ...localScope, secureTokenStorage: false }) + await openDialog() + + fireEvent.change(screen.getByLabelText(/Remote address/), { target: { value: 'https://box.example.com' } }) + fireEvent.change(screen.getByLabelText(/Access token/), { target: { value: 'tok-123' } }) + + const connect = screen.getByRole('button', { name: 'Connect' }) + expect((connect as HTMLButtonElement).disabled).toBe(true) + expect(screen.getByText(/saved unencrypted on disk/)).toBeTruthy() + + fireEvent.click(screen.getByRole('checkbox')) + expect((screen.getByRole('button', { name: 'Connect' }) as HTMLButtonElement).disabled).toBe(false) + + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + + await waitFor(() => + expect(applyConnectionConfig).toHaveBeenCalledWith(expect.objectContaining({ allowPlainTextToken: true })) + ) + }) + + it('warns when the profile name collides with a registered gateway', async () => { + list.mockResolvedValue({ + ...emptyRegistry, + connections: [{ id: 'abc123', kind: 'remote', label: 'Work', tokenPreview: null, tokenSet: true }] + }) + await openDialog('work') + + await screen.findByText(/A gateway named “Work” already exists in Settings/) + }) + + it('keeps the dialog open and shows the error when the write fails', async () => { + getConnectionConfig.mockResolvedValue({ + ...localScope, + mode: 'remote', + remoteTokenSet: true, + remoteUrl: 'https://box.example.com' + }) + applyConnectionConfig.mockRejectedValue(new Error('Remote gateway session token is required.')) + await openDialog() + + fireEvent.change(screen.getByLabelText(/Access token/), { target: { value: 'bad' } }) + fireEvent.click(screen.getByRole('button', { name: 'Connect' })) + + await screen.findByText('Remote gateway session token is required.') + expect($remoteOverrideDialogProfile.get()).toBe('work') + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.tsx b/apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.tsx new file mode 100644 index 000000000000..acf71926fd94 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.tsx @@ -0,0 +1,305 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { useI18n } from '@/i18n' +import { notify, notifyError } from '@/store/notifications' +import { + $remoteOverrideDialogProfile, + closeRemoteOverrideDialog, + refreshProfileRemoteOverrides, + remoteHostLabel +} from '@/store/profile-remote-override' + +// "Connect this profile to a remote host…" — the profile-rail affordance for +// the per-profile override the Electron main already honors +// (connection.json `profiles.` → profileRemoteOverride). The renderer +// never touches connection.json: everything goes through the existing typed +// getConnectionConfig / applyConnectionConfig bridge with a `profile` scope, +// which encrypts the token via safeStorage (or the explicit plain-text opt-in +// on machines without an OS keychain). +// +// Safety beats requested in #91349: +// - first-time connect shows a confirmation with a plain-language risk note; +// - saving warns when the profile name collides with a v2 registry gateway +// (two different stores keyed by the same name is how routing surprises +// start); +// - a keyring-less machine requires the explicit unencrypted-token opt-in. + +interface LoadedScope { + authMode: 'oauth' | 'token' + hasOverride: boolean + secureTokenStorage: boolean + tokenPlainText: boolean + tokenSet: boolean + url: string +} + +export function ProfileRemoteOverrideDialog({ profileNames }: { profileNames: string[] }) { + const { t } = useI18n() + const p = t.profiles.remoteOverride + const profile = useStore($remoteOverrideDialogProfile) + const open = profile !== null + + const [loaded, setLoaded] = useState(null) + const [url, setUrl] = useState('') + const [token, setToken] = useState('') + const [allowPlainText, setAllowPlainText] = useState(false) + const [confirming, setConfirming] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [collision, setCollision] = useState(null) + const urlRef = useRef(null) + + // Load this profile's saved scope + the registry (for the name-collision + // warning) each time the dialog opens. The registry read goes straight to + // the bridge instead of the connections store so this dialog stays a leaf. + useEffect(() => { + if (!profile) { + return + } + + let cancelled = false + setLoaded(null) + setUrl('') + setToken('') + setAllowPlainText(false) + setConfirming(false) + setSaving(false) + setError(null) + setCollision(null) + + window.hermesDesktop + ?.getConnectionConfig?.(profile) + .then(config => { + if (cancelled) { + return + } + + const hasOverride = (config.mode === 'remote' || config.mode === 'cloud') && Boolean(config.remoteUrl) + setLoaded({ + authMode: config.remoteAuthMode === 'oauth' ? 'oauth' : 'token', + hasOverride, + secureTokenStorage: config.secureTokenStorage !== false, + tokenPlainText: config.remoteTokenPlainText === true, + tokenSet: config.remoteTokenSet === true, + url: hasOverride ? config.remoteUrl : '' + }) + setUrl(hasOverride ? config.remoteUrl : '') + window.setTimeout(() => urlRef.current?.focus(), 0) + }) + .catch(err => !cancelled && setError(err instanceof Error ? err.message : String(err))) + + window.hermesDesktop?.connections + ?.list() + .then(registry => { + if (cancelled) { + return + } + + const lowered = profile.trim().toLowerCase() + + const match = registry.connections.find( + connection => connection.id.toLowerCase() === lowered || connection.label.trim().toLowerCase() === lowered + ) + + setCollision(match ? match.label : null) + }) + .catch(() => undefined) + + return () => void (cancelled = true) + }, [profile]) + + const close = () => { + if (!saving) { + closeRemoteOverrideDialog() + } + } + + const trimmedUrl = url.trim() + const urlValid = /^https?:\/\/\S+$/i.test(trimmedUrl) + const tokenReady = Boolean(token.trim()) || (loaded?.tokenSet === true && trimmedUrl === loaded.url) + const canSubmit = Boolean(loaded) && urlValid && tokenReady && !saving + // Writing a NEW token on a machine without an OS keychain stores it as + // plain text on disk — that needs the explicit opt-in checked first. + const needsPlainTextOptIn = Boolean(loaded && loaded.secureTokenStorage === false && token.trim() && !allowPlainText) + + const performSave = async () => { + if (!profile) { + return + } + + setSaving(true) + setError(null) + + try { + await window.hermesDesktop.applyConnectionConfig({ + mode: 'remote', + profile, + remoteAuthMode: 'token', + remoteToken: token.trim() || undefined, + remoteUrl: trimmedUrl, + ...(allowPlainText ? { allowPlainTextToken: true } : {}) + }) + + notify({ kind: 'success', title: p.savedTitle, message: p.savedMessage(profile, remoteHostLabel(trimmedUrl) || trimmedUrl) }) + await refreshProfileRemoteOverrides(profileNames) + closeRemoteOverrideDialog() + } catch (err) { + setConfirming(false) + setError(err instanceof Error ? err.message : String(err)) + } finally { + setSaving(false) + } + } + + const submit = () => { + if (!canSubmit || needsPlainTextOptIn) { + return + } + + // First-time connect gets the one-time risk confirmation; editing an + // existing override (token rotation, URL fix) skips straight to the save. + if (!loaded?.hasOverride && !confirming) { + setConfirming(true) + + return + } + + void performSave() + } + + const removeOverride = async () => { + if (!profile) { + return + } + + setSaving(true) + setError(null) + + try { + await window.hermesDesktop.applyConnectionConfig({ mode: 'local', profile }) + notify({ kind: 'success', title: p.removedTitle, message: p.removedMessage(profile) }) + await refreshProfileRemoteOverrides(profileNames) + closeRemoteOverrideDialog() + } catch (err) { + notifyError(err, p.removeFailed) + setSaving(false) + } + } + + const host = remoteHostLabel(trimmedUrl) || trimmedUrl + + // Mounted permanently in the rail; render nothing until a profile's dialog + // is actually requested (also keeps the closed tree from touching i18n). + if (!open) { + return null + } + + return ( + !next && close()} open={open}> + + {confirming ? ( + <> + + {p.confirmTitle} + {p.confirmNote(profile ?? '', host)} + + {error &&

{error}

} + + + + + + ) : ( + <> + + {p.title(profile ?? '')} + {p.description} + + +
+ + + + + {loaded && loaded.secureTokenStorage === false && Boolean(token.trim()) && ( + + )} + + {collision &&

{p.collisionWarning(collision)}

} + {error &&

{error}

} +
+ + + {loaded?.hasOverride && ( + + )} + + + + + )} +
+
+ ) +} diff --git a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx index 5b86c788f5c7..a29e75453aad 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-switcher.tsx @@ -70,6 +70,11 @@ import { setShowAllProfiles, sortByProfileOrder } from '@/store/profile' +import { + $profileRemoteOverrides, + openRemoteOverrideDialog, + refreshProfileRemoteOverrides +} from '@/store/profile-remote-override' import { runExportProfileFlow, runImportProfileFlow } from '@/store/profile-share' import type { ProfileInfo } from '@/types/hermes' @@ -78,6 +83,7 @@ import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog' import { RenameProfileDialog } from '../../profiles/rename-profile-dialog' import { PROFILES_ROUTE, SETTINGS_ROUTE } from '../../routes' +import { ProfileRemoteOverrideDialog } from './profile-remote-override-dialog' import { useProfilePrewarm } from './use-profile-prewarm' import { useProfileRailRefreshOnActive } from './use-profile-rail-refresh-on-active' @@ -124,6 +130,7 @@ export function ProfileRail() { const gatewayProfile = useStore($activeGatewayProfile) const order = useStore($profileOrder) const colors = useStore($profileColors) + const remoteOverrides = useStore($profileRemoteOverrides) const multipleConnections = useStore($hasMultipleConnections) const navigate = useNavigate() @@ -223,6 +230,16 @@ export function ProfileRail() { // wiring. useProfileRailRefreshOnActive() + // Which profiles carry a per-profile remote override (connection.json + // profiles.) — refreshed whenever the profile list changes so the + // rail's "remote" badge tracks create/rename/override edits. + const profileNames = profiles.map(profile => profile.name) + const profileNamesKey = profileNames.join('\u0000') + + useEffect(() => { + void refreshProfileRemoteOverrides(profileNamesKey ? profileNamesKey.split('\u0000') : []) + }, [profileNamesKey]) + // Open the create dialog when the `profile.create` hotkey fires (the dialog // state lives here, so the global keybind bumps a request atom we watch). const createRequest = useStore($profileCreateRequest) @@ -305,11 +322,13 @@ export function ProfileRail() { color={resolveProfileColor(profile.name, colors)} key={profile.name} label={profileLabel(profile)} + onConnectRemote={() => openRemoteOverrideDialog(profile.name)} onDelete={() => setPendingDelete(profile)} onEditSoul={() => setPendingSoul(profile.name)} onRecolor={color => setProfileColor(profile.name, color)} onRename={() => setPendingRename(profile)} onSelect={() => selectProfile(profile.name)} + remoteHost={remoteOverrides[normalizeProfileKey(profile.name)]?.host ?? null} /> ))} @@ -368,6 +387,8 @@ export function ProfileRail() { /> setPendingSoul(null)} profileName={pendingSoul} /> + + ) } @@ -618,7 +639,11 @@ interface ProfileSquareProps { onRecolor: (color: null | string) => void onRename: () => void onEditSoul: () => void + onConnectRemote: () => void onDelete: () => void + // hostname[:port] of this profile's remote override, or null when the + // profile runs locally. Drives the "remote" badge on the square. + remoteHost: null | string } // Hold this long without moving (a drag would have started first) to open the @@ -636,11 +661,13 @@ function ProfileSquare({ active, color, label, + onConnectRemote, onDelete, onEditSoul, onRecolor, onRename, - onSelect + onSelect, + remoteHost }: ProfileSquareProps) { const { t } = useI18n() const p = t.profiles @@ -693,7 +720,7 @@ function ProfileSquare({ - {label} + {remoteHost ? `${label} · ${p.remoteOverride.badge(remoteHost)}` : label} @@ -783,6 +822,10 @@ function ProfileSquare({ {p.exportProfile} + + + {remoteHost ? p.remoteOverride.badge(remoteHost) : p.remoteOverride.menuItem} + `التبديل إلى ${name}`, switchConnectionFailed: name => `تعذّر الاتصال بـ ${name}`, manageProfiles: 'إدارة الملفات الشخصية', + remoteOverride: { + menuItem: 'الاتصال بمضيف بعيد…', + badge: (host: string) => `يعمل على ${host}`, + title: (profile: string) => `ربط ${profile} بمضيف بعيد`, + description: 'ستعمل جلسات هذا الملف الشخصي على خادم Hermes البعيد الذي تحدده، بدلاً من هذا الجهاز.', + urlLabel: 'العنوان البعيد', + urlPlaceholder: 'https://hermes.example.com', + urlInvalid: 'أدخل عنواناً كاملاً يبدأ بـ http:// أو https://', + tokenLabel: 'رمز الوصول', + tokenPlaceholder: 'الصق رمز الجلسة البعيد', + tokenSavedHint: 'يوجد رمز محفوظ بالفعل. اتركه فارغاً للاحتفاظ به.', + plainTextOptIn: + 'لا يتوفر تخزين مفاتيح آمن على هذا الجهاز، لذا سيُحفظ الرمز على القرص دون تشفير. احفظه على أي حال.', + collisionWarning: (label: string) => + `توجد بوابة باسم «${label}» في الإعدادات بالفعل. اتصال هذا الملف الشخصي منفصل ولن يغيّرها.`, + confirmTitle: 'ربط هذا الملف الشخصي بمضيف بعيد؟', + confirmNote: (profile: string, host: string) => + `ستعمل المحادثات الجديدة في ${profile} على ${host}. سيقوم ذلك الجهاز بتنفيذ الأوامر وقراءة الملفات هناك، وليس هنا. اتصل فقط بمضيف تثق به.`, + confirmBack: 'رجوع', + connect: 'اتصال', + connecting: 'جارٍ الاتصال…', + disconnect: 'إزالة الاتصال البعيد', + savedTitle: 'تم ربط الملف الشخصي', + savedMessage: (profile: string, host: string) => `${profile} يعمل الآن على ${host}`, + removedTitle: 'تمت إزالة الاتصال البعيد', + removedMessage: (profile: string) => `${profile} يعمل الآن على هذا الجهاز`, + removeFailed: 'تعذّرت إزالة الاتصال البعيد', + authFailedTitle: 'رفض المضيف البعيد الرمز المحفوظ', + authFailedMessage: (profile: string, host: string) => + `رفض ${host} الرمز المحفوظ لـ ${profile}. ربما تم تغييره على الجانب البعيد.`, + updateToken: 'أدخل رمزاً جديداً…' + }, actions: 'إجراءات', color: 'اللون', colorFor: 'اللون', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 26c50a4200aa..e4a112700e35 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1815,6 +1815,39 @@ export const en: Translations = { switchConnectionFailed: name => `Could not connect to ${name}`, manageProfiles: 'Manage profiles…', connectGateway: 'Manage gateways…', + remoteOverride: { + menuItem: 'Connect to a remote host…', + badge: (host: string) => `Runs on ${host}`, + title: (profile: string) => `Connect ${profile} to a remote host`, + description: + 'Sessions in this profile will run on the remote Hermes you point it at, instead of this computer.', + urlLabel: 'Remote address', + urlPlaceholder: 'https://hermes.example.com', + urlInvalid: 'Enter a full address starting with http:// or https://', + tokenLabel: 'Access token', + tokenPlaceholder: 'Paste the remote session token', + tokenSavedHint: 'A token is already saved. Leave blank to keep it.', + plainTextOptIn: + 'This computer has no secure key storage, so the token would be saved unencrypted on disk. Save it anyway.', + collisionWarning: (label: string) => + `A gateway named “${label}” already exists in Settings. This profile connection is separate and will not change it.`, + confirmTitle: 'Connect this profile to a remote host?', + confirmNote: (profile: string, host: string) => + `New chats in ${profile} will run on ${host}. That computer will run commands and read files there, not on this one. Only connect to a host you trust.`, + confirmBack: 'Back', + connect: 'Connect', + connecting: 'Connecting…', + disconnect: 'Remove remote connection', + savedTitle: 'Profile connected', + savedMessage: (profile: string, host: string) => `${profile} now runs on ${host}`, + removedTitle: 'Remote connection removed', + removedMessage: (profile: string) => `${profile} now runs on this computer`, + removeFailed: 'Could not remove the remote connection', + authFailedTitle: 'Remote host rejected the saved token', + authFailedMessage: (profile: string, host: string) => + `${host} refused the token saved for ${profile}. It may have been changed on the remote side.`, + updateToken: 'Enter new token…' + }, actions: 'Actions', color: 'Color…', colorFor: 'Color', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index cf8015972a9d..0607f2260d81 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1527,6 +1527,38 @@ export const ja = defineLocale({ switchToConnection: name => `${name} に切り替え`, switchConnectionFailed: name => `${name} に接続できませんでした`, manageProfiles: 'プロファイルを管理…', + remoteOverride: { + menuItem: 'リモートホストに接続…', + badge: (host: string) => `${host} で実行中`, + title: (profile: string) => `${profile} をリモートホストに接続`, + description: 'このプロファイルのセッションは、このパソコンではなく指定したリモートの Hermes で実行されます。', + urlLabel: 'リモートアドレス', + urlPlaceholder: 'https://hermes.example.com', + urlInvalid: 'http:// または https:// で始まる完全なアドレスを入力してください', + tokenLabel: 'アクセストークン', + tokenPlaceholder: 'リモートのセッショントークンを貼り付け', + tokenSavedHint: 'トークンは保存済みです。空欄のままにすると保持されます。', + plainTextOptIn: + 'このパソコンには安全な鍵ストレージがないため、トークンは暗号化されずにディスクへ保存されます。それでも保存する。', + collisionWarning: (label: string) => + `「${label}」という名前のゲートウェイが設定に既に存在します。このプロファイル接続は別物であり、それを変更しません。`, + confirmTitle: 'このプロファイルをリモートホストに接続しますか?', + confirmNote: (profile: string, host: string) => + `${profile} の新しいチャットは ${host} で実行されます。コマンドの実行やファイルの読み取りはこのパソコンではなくそのコンピュータで行われます。信頼できるホストにのみ接続してください。`, + confirmBack: '戻る', + connect: '接続', + connecting: '接続中…', + disconnect: 'リモート接続を解除', + savedTitle: 'プロファイルを接続しました', + savedMessage: (profile: string, host: string) => `${profile} は ${host} で実行されます`, + removedTitle: 'リモート接続を解除しました', + removedMessage: (profile: string) => `${profile} はこのパソコンで実行されます`, + removeFailed: 'リモート接続を解除できませんでした', + authFailedTitle: 'リモートホストが保存済みトークンを拒否しました', + authFailedMessage: (profile: string, host: string) => + `${host} が ${profile} 用に保存されたトークンを拒否しました。リモート側で変更された可能性があります。`, + updateToken: '新しいトークンを入力…' + }, actions: 'アクション', color: 'カラー…', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 5c51c20fdf3d..ec1bd5314401 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1535,6 +1535,34 @@ export interface Translations { switchConnectionFailed: (name: string) => string manageProfiles: string connectGateway: string + remoteOverride: { + menuItem: string + badge: (host: string) => string + title: (profile: string) => string + description: string + urlLabel: string + urlPlaceholder: string + urlInvalid: string + tokenLabel: string + tokenPlaceholder: string + tokenSavedHint: string + plainTextOptIn: string + collisionWarning: (label: string) => string + confirmTitle: string + confirmNote: (profile: string, host: string) => string + confirmBack: string + connect: string + connecting: string + disconnect: string + savedTitle: string + savedMessage: (profile: string, host: string) => string + removedTitle: string + removedMessage: (profile: string) => string + removeFailed: string + authFailedTitle: string + authFailedMessage: (profile: string, host: string) => string + updateToken: string + } actions: string color: string colorFor: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 4be98f606bb2..7edaa7c6e633 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1471,6 +1471,37 @@ export const zhHant = defineLocale({ switchToConnection: name => `切換至 ${name}`, switchConnectionFailed: name => `無法連線至 ${name}`, manageProfiles: '管理設定檔…', + remoteOverride: { + menuItem: '連線至遠端主機…', + badge: (host: string) => `執行於 ${host}`, + title: (profile: string) => `將 ${profile} 連線至遠端主機`, + description: '此設定檔中的工作階段將在你指定的遠端 Hermes 上執行,而不是這台電腦。', + urlLabel: '遠端位址', + urlPlaceholder: 'https://hermes.example.com', + urlInvalid: '請輸入以 http:// 或 https:// 開頭的完整位址', + tokenLabel: '存取權杖', + tokenPlaceholder: '貼上遠端工作階段權杖', + tokenSavedHint: '已儲存權杖。留空以保留現有權杖。', + plainTextOptIn: '這台電腦沒有安全金鑰儲存空間,權杖將以未加密方式儲存到磁碟。仍要儲存。', + collisionWarning: (label: string) => + `設定中已存在名為「${label}」的閘道。此設定檔連線是獨立的,不會變更它。`, + confirmTitle: '將此設定檔連線至遠端主機?', + confirmNote: (profile: string, host: string) => + `${profile} 中的新對話將在 ${host} 上執行。指令執行與檔案讀取都會發生在那台電腦上,而不是這台。請只連線你信任的主機。`, + confirmBack: '返回', + connect: '連線', + connecting: '連線中…', + disconnect: '移除遠端連線', + savedTitle: '設定檔已連線', + savedMessage: (profile: string, host: string) => `${profile} 現在執行於 ${host}`, + removedTitle: '已移除遠端連線', + removedMessage: (profile: string) => `${profile} 現在在這台電腦上執行`, + removeFailed: '無法移除遠端連線', + authFailedTitle: '遠端主機拒絕了已儲存的權杖', + authFailedMessage: (profile: string, host: string) => + `${host} 拒絕了為 ${profile} 儲存的權杖。它可能已在遠端被變更。`, + updateToken: '輸入新權杖…' + }, actions: '動作', color: '顏色…', colorFor: '顏色', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 3d8f0fdfe284..c6393e6c1f37 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2001,6 +2001,37 @@ export const zh: Translations = { switchConnectionFailed: name => `无法连接到 ${name}`, manageProfiles: '管理配置档案…', connectGateway: '管理网关…', + remoteOverride: { + menuItem: '连接到远程主机…', + badge: (host: string) => `运行于 ${host}`, + title: (profile: string) => `将 ${profile} 连接到远程主机`, + description: '此配置档案中的会话将在你指定的远程 Hermes 上运行,而不是这台电脑。', + urlLabel: '远程地址', + urlPlaceholder: 'https://hermes.example.com', + urlInvalid: '请输入以 http:// 或 https:// 开头的完整地址', + tokenLabel: '访问令牌', + tokenPlaceholder: '粘贴远程会话令牌', + tokenSavedHint: '已保存令牌。留空以保留现有令牌。', + plainTextOptIn: '这台电脑没有安全密钥存储,令牌将以未加密方式保存到磁盘。仍然保存。', + collisionWarning: (label: string) => + `设置中已存在名为“${label}”的网关。此配置档案连接是独立的,不会更改它。`, + confirmTitle: '将此配置档案连接到远程主机?', + confirmNote: (profile: string, host: string) => + `${profile} 中的新对话将在 ${host} 上运行。命令和文件读取都发生在那台电脑上,而不是这台。请只连接你信任的主机。`, + confirmBack: '返回', + connect: '连接', + connecting: '连接中…', + disconnect: '移除远程连接', + savedTitle: '配置档案已连接', + savedMessage: (profile: string, host: string) => `${profile} 现在运行于 ${host}`, + removedTitle: '已移除远程连接', + removedMessage: (profile: string) => `${profile} 现在在这台电脑上运行`, + removeFailed: '无法移除远程连接', + authFailedTitle: '远程主机拒绝了已保存的令牌', + authFailedMessage: (profile: string, host: string) => + `${host} 拒绝了为 ${profile} 保存的令牌。它可能已在远程端被更改。`, + updateToken: '输入新令牌…' + }, actions: '操作', color: '颜色…', colorFor: '颜色', diff --git a/apps/desktop/src/store/profile-remote-override.test.ts b/apps/desktop/src/store/profile-remote-override.test.ts new file mode 100644 index 000000000000..a3f300a2880e --- /dev/null +++ b/apps/desktop/src/store/profile-remote-override.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $notifications } from './notifications' +import { + $profileRemoteOverrides, + $remoteOverrideDialogProfile, + closeRemoteOverrideDialog, + notifyRemoteOverrideAuthFailure, + openRemoteOverrideDialog, + refreshProfileRemoteOverrides, + remoteHostLabel +} from './profile-remote-override' + +const getConnectionConfig = vi.fn() + +beforeEach(() => { + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { getConnectionConfig } + }) + $profileRemoteOverrides.set({}) + $remoteOverrideDialogProfile.set(null) + $notifications.set([]) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('remoteHostLabel', () => { + it('keeps a non-default port and drops default ones', () => { + expect(remoteHostLabel('https://hermes.example.com:8443/x')).toBe('hermes.example.com:8443') + expect(remoteHostLabel('https://hermes.example.com:443')).toBe('hermes.example.com') + expect(remoteHostLabel('http://hermes.example.com:80')).toBe('hermes.example.com') + }) + + it('returns empty for unparseable input', () => { + expect(remoteHostLabel('not a url')).toBe('') + expect(remoteHostLabel('')).toBe('') + }) +}) + +describe('refreshProfileRemoteOverrides', () => { + it('publishes only profiles whose scope resolves to a remote override', async () => { + getConnectionConfig.mockImplementation(async (profile: string) => { + if (profile === 'work') { + return { mode: 'remote', remoteUrl: 'https://work.example.com' } + } + + if (profile === 'cloudy') { + return { mode: 'cloud', remoteUrl: 'https://cloud.example.com:8443' } + } + + return { mode: 'local', remoteUrl: '' } + }) + + await refreshProfileRemoteOverrides(['work', 'cloudy', 'home']) + + expect($profileRemoteOverrides.get()).toEqual({ + work: { host: 'work.example.com', url: 'https://work.example.com' }, + cloudy: { host: 'cloud.example.com:8443', url: 'https://cloud.example.com:8443' } + }) + }) + + it('drops a stale override entry when the profile went back to local', async () => { + $profileRemoteOverrides.set({ work: { host: 'old.example.com', url: 'https://old.example.com' } }) + getConnectionConfig.mockResolvedValue({ mode: 'local', remoteUrl: '' }) + + await refreshProfileRemoteOverrides(['work']) + + expect($profileRemoteOverrides.get()).toEqual({}) + }) + + it('leaves a profile unbadged when its scope read fails, without failing the rest', async () => { + getConnectionConfig.mockImplementation(async (profile: string) => { + if (profile === 'broken') { + throw new Error('bridge unavailable') + } + + return { mode: 'remote', remoteUrl: 'https://ok.example.com' } + }) + + await refreshProfileRemoteOverrides(['broken', 'work']) + + expect(Object.keys($profileRemoteOverrides.get())).toEqual(['work']) + }) +}) + +describe('notifyRemoteOverrideAuthFailure', () => { + beforeEach(() => { + $profileRemoteOverrides.set({ work: { host: 'work.example.com', url: 'https://work.example.com' } }) + }) + + it('surfaces a re-enter-token toast whose action reopens the dialog on a 401', () => { + const handled = notifyRemoteOverrideAuthFailure('work', new Error('WebSocket handshake failed: 401 Unauthorized')) + + expect(handled).toBe(true) + const toast = $notifications.get()[0] + expect(toast.kind).toBe('error') + expect(toast.action).toBeTruthy() + + toast.action?.onClick() + expect($remoteOverrideDialogProfile.get()).toBe('work') + }) + + it('ignores connectivity failures — a down host is not a rotated token', () => { + expect(notifyRemoteOverrideAuthFailure('work', new Error('connect ECONNREFUSED 10.0.0.5:8000'))).toBe(false) + expect(notifyRemoteOverrideAuthFailure('work', new Error('timeout waiting for backend'))).toBe(false) + expect($notifications.get()).toEqual([]) + }) + + it('ignores profiles without an override entirely', () => { + expect(notifyRemoteOverrideAuthFailure('home', new Error('401 Unauthorized'))).toBe(false) + expect($notifications.get()).toEqual([]) + }) +}) + +describe('dialog open/close atoms', () => { + it('opens for a profile and closes back to null', () => { + openRemoteOverrideDialog('work') + expect($remoteOverrideDialogProfile.get()).toBe('work') + closeRemoteOverrideDialog() + expect($remoteOverrideDialogProfile.get()).toBeNull() + }) +}) diff --git a/apps/desktop/src/store/profile-remote-override.ts b/apps/desktop/src/store/profile-remote-override.ts new file mode 100644 index 000000000000..14a39175e422 --- /dev/null +++ b/apps/desktop/src/store/profile-remote-override.ts @@ -0,0 +1,132 @@ +import { atom } from 'nanostores' + +import { translateNow } from '@/i18n' +import { notify } from '@/store/notifications' + +// ── Per-profile remote overrides (connection.json `profiles.`) ──────── +// The Electron main already routes a profile with a remote entry to its own +// pooled backend (profileRemoteOverride → resolveProfileBackendRoute case 2); +// until now the only way to WRITE that entry was hand-editing connection.json +// (#91349). This store is the renderer-side cache of which profiles carry an +// override, so the rail can badge them and the dialog can be opened from +// anywhere (context menu, auth-failure toast) without threading callbacks. +// +// Deliberately no import from store/profile: profile.ts imports THIS module +// for the auth-failure toast, so the dependency must stay one-way. + +export interface ProfileRemoteOverride { + /** hostname[:port] parsed from the override URL — the display label. */ + host: string + url: string +} + +/** profile key → its remote override, refreshed from Electron on demand. */ +export const $profileRemoteOverrides = atom>({}) + +// The profile whose "Connect to a remote host" dialog is open, or null. An +// atom (not component state) so the rail's context menu AND the re-enter-token +// toast action can both open the same dialog. +export const $remoteOverrideDialogProfile = atom(null) + +export function openRemoteOverrideDialog(profile: string): void { + $remoteOverrideDialogProfile.set(profile) +} + +export function closeRemoteOverrideDialog(): void { + $remoteOverrideDialogProfile.set(null) +} + +/** hostname[:port] for a gateway URL, or '' when it does not parse. */ +export function remoteHostLabel(url: string): string { + try { + const parsed = new URL(String(url || '')) + + if (!parsed.hostname) { + return '' + } + + return parsed.port && parsed.port !== '80' && parsed.port !== '443' + ? `${parsed.hostname}:${parsed.port}` + : parsed.hostname + } catch { + return '' + } +} + +/** + * Re-pull each named profile's connection scope from Electron and publish the + * ones that resolve to a remote/cloud override. Best-effort per profile: a + * single failed read keeps that profile unbadged rather than failing the lot. + */ +export async function refreshProfileRemoteOverrides(names: string[]): Promise { + const getConnectionConfig = window.hermesDesktop?.getConnectionConfig + + if (!getConnectionConfig) { + return + } + + const next: Record = {} + + await Promise.all( + names.map(async name => { + const key = String(name || '').trim() + + if (!key) { + return + } + + try { + const config = await getConnectionConfig(key) + + if ((config.mode === 'remote' || config.mode === 'cloud') && config.remoteUrl) { + next[key] = { host: remoteHostLabel(config.remoteUrl) || config.remoteUrl, url: config.remoteUrl } + } + } catch { + // Backend bridge hiccup — leave this profile unbadged until the next refresh. + } + }) + ) + + $profileRemoteOverrides.set(next) +} + +// The token-rotation failure shape: the remote host answered but refused the +// credentials. Mirrors the auth-vocabulary used by suggestion-providers/repair +// — connectivity failures (timeout, refused, DNS) stay generic so a down host +// doesn't misread as a revoked token. +const AUTH_FAILURE_RE = + /\b(401|403|unauthorized|forbidden)\b|invalid[_ ]?token|token .*(expired|invalid|rejected)|authenticat\w+ (failed|required|expired)/i + +/** + * When switching to a profile with a remote override fails because the host + * rejected the saved token (rotated/revoked), surface a re-enter-token toast + * whose action reopens the override dialog — never a silently dead profile. + * Returns true when the toast was shown (callers keep their generic handling + * for everything else). + */ +export function notifyRemoteOverrideAuthFailure(profile: string, error: unknown): boolean { + const key = String(profile || '').trim() + const override = key ? $profileRemoteOverrides.get()[key] : undefined + + if (!override) { + return false + } + + const message = error instanceof Error ? error.message : String(error ?? '') + + if (!AUTH_FAILURE_RE.test(message)) { + return false + } + + notify({ + kind: 'error', + title: translateNow('profiles.remoteOverride.authFailedTitle'), + message: translateNow('profiles.remoteOverride.authFailedMessage', key, override.host), + action: { + label: translateNow('profiles.remoteOverride.updateToken'), + onClick: () => openRemoteOverrideDialog(key) + } + }) + + return true +} diff --git a/apps/desktop/src/store/profile.ts b/apps/desktop/src/store/profile.ts index f2dc71e502dd..c0cbb262a0a2 100644 --- a/apps/desktop/src/store/profile.ts +++ b/apps/desktop/src/store/profile.ts @@ -14,6 +14,7 @@ import { } from '@/lib/storage' import { invalidateCronModelImpactScopeState } from '@/store/cron-model-impact-scope' import { $gateway, ensureGatewayForAgent, ensureGatewayForProfile, openGatewayForProfile } from '@/store/gateway' +import { notifyRemoteOverrideAuthFailure } from '@/store/profile-remote-override' import { setConnection } from '@/store/session' import { resetStarmapGraph } from '@/store/starmap' import type { ProfileInfo } from '@/types/hermes' @@ -516,7 +517,10 @@ export function selectProfile(name: string): void { requestFreshSession() } - void ensureGatewayProfile(target) + // A profile with a remote override can fail to activate because the remote + // host rejected its saved token (rotated/revoked). That must surface as a + // "re-enter token" affordance, never a silently dead profile (#91349). + void ensureGatewayProfile(target).catch(error => notifyRemoteOverrideAuthFailure(target, error)) } // Start a fresh session in `name` WITHOUT collapsing the "All profiles" browse @@ -530,7 +534,7 @@ export function newSessionInProfile(name: string): void { $newChatProfile.set(target) $newChatRoute.set(null) requestFreshSession() - void ensureGatewayProfile(target) + void ensureGatewayProfile(target).catch(error => notifyRemoteOverrideAuthFailure(target, error)) } /** Start a draft owned by a specific registry agent. Foreground activation is From eaa61ff62d556055542cd9fb10857d3d53a2f9f5 Mon Sep 17 00:00:00 2001 From: tachyon-r <291518778+tachyon-r@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:18:58 -0400 Subject: [PATCH 094/666] fix(bot-mode): clean up message tempfiles --- tests/tools/test_bot_mode_dm.py | 183 ++++++++++++++++++++++++++++++-- tools/bot_mode_dm.py | 149 ++++++++++++++++++++++++-- 2 files changed, 314 insertions(+), 18 deletions(-) diff --git a/tests/tools/test_bot_mode_dm.py b/tests/tools/test_bot_mode_dm.py index 18da42cf83c6..8d7ee9331008 100644 --- a/tests/tools/test_bot_mode_dm.py +++ b/tests/tools/test_bot_mode_dm.py @@ -6,6 +6,9 @@ """ import json +import shlex +import subprocess +import sys import textwrap from pathlib import Path @@ -206,6 +209,12 @@ def fake_terminal_tool(command, **kwargs): return calls +def _runner_parts(command): + parts = shlex.split(command) + marker = parts.index("--run-delivery") + return parts[marker + 1], parts[marker + 2], parts[marker + 3 :] + + def test_local_delivery_command_and_ack(tmp_path, monkeypatch): calls = _capture_spawn(monkeypatch) home = _managed_home(tmp_path, teammates=("researcher",)) @@ -228,14 +237,25 @@ def test_local_delivery_command_and_ack(tmp_path, monkeypatch): assert call["background"] is True assert call["notify_on_complete"] is True command = call["command"] - assert command.startswith("hermes -p researcher chat --in ~ -c \"Bot Chat\"") - assert "--query-file" in command + mode, dm_file, transport_argv = _runner_parts(command) + assert mode == "query-file" + assert transport_argv == [ + "hermes", + "-p", + "researcher", + "chat", + "--in", + "~", + "-c", + "Bot Chat", + "--create-if-missing", + "-Q", + ] # message body rides the temp file, never the command line assert "final" not in command assert "$(" not in command # attribution prefix applied server-side; body verbatim inside the file - dm_file = command.rsplit(" ", 1)[-1].strip("'") content = Path(dm_file).read_text(encoding="utf-8") assert content.startswith("Message from 🤖 hermes (@hermes): ") assert '$(and this is not shell)' in content @@ -251,15 +271,18 @@ def test_peer_delivery_command(tmp_path, monkeypatch): ) assert result["status"] == "sent" assert "spark" in result["to"] - command = calls[0]["command"] - assert command.startswith("hermes peer dm spark/researcher < ") + mode, _dm_file, transport_argv = _runner_parts(calls[0]["command"]) + assert mode == "stdin" + assert transport_argv == ["hermes", "peer", "dm", "spark/researcher"] # bare peer name targets the peer's main agent result2 = json.loads( bot_mode_dm.message_agent_tool(target="spark", message="ping", agent=agent) ) assert result2["status"] == "sent" - assert calls[1]["command"].startswith("hermes peer dm spark < ") + mode, _dm_file, transport_argv = _runner_parts(calls[1]["command"]) + assert mode == "stdin" + assert transport_argv == ["hermes", "peer", "dm", "spark"] def test_named_profile_sender_prefix(tmp_path, monkeypatch): @@ -273,7 +296,7 @@ def test_named_profile_sender_prefix(tmp_path, monkeypatch): bot_mode_dm.message_agent_tool(target="researcher", message="hi", agent=agent) ) assert result["status"] == "sent" - dm_file = calls[0]["command"].rsplit(" ", 1)[-1].strip("'") + _mode, dm_file, _transport_argv = _runner_parts(calls[0]["command"]) assert Path(dm_file).read_text(encoding="utf-8").startswith( "Message from 🤖 coder (@coder): " ) @@ -294,3 +317,149 @@ def boom(command, **kwargs): ) assert "error" in result assert "could not be started" in result["error"] + + +# ── plaintext tempfile lifecycle ───────────────────────────────────────────── + + +@pytest.mark.parametrize("stdin_file", [False, True]) +def test_delivery_runner_keeps_file_for_child_then_unlinks(tmp_path, stdin_file): + dm_file = tmp_path / "message with spaces.txt" + dm_file.write_text("secret $(not shell)", encoding="utf-8") + observed = tmp_path / "observed.txt" + child = tmp_path / "child.py" + child.write_text( + textwrap.dedent( + """\ + import pathlib + import sys + + source = sys.stdin if sys.argv[1] == "-" else open(sys.argv[1], encoding="utf-8") + with source: + pathlib.Path(sys.argv[2]).write_text(source.read(), encoding="utf-8") + """ + ), + encoding="utf-8", + ) + source_arg = "-" if stdin_file else str(dm_file) + + returncode = bot_mode_dm._run_delivery( + [sys.executable, str(child), source_arg, str(observed)], + str(dm_file), + stdin_file=stdin_file, + ) + + assert returncode == 0 + assert observed.read_text(encoding="utf-8") == "secret $(not shell)" + assert not dm_file.exists() + + +def test_delivery_runner_unlinks_when_child_launch_raises(tmp_path, monkeypatch): + dm_file = tmp_path / "message.txt" + dm_file.write_text("secret", encoding="utf-8") + + def boom(*args, **kwargs): + raise RuntimeError("child launch failed") + + monkeypatch.setattr(subprocess, "run", boom) + with pytest.raises(RuntimeError, match="child launch failed"): + bot_mode_dm._run_delivery(["hermes"], str(dm_file), stdin_file=False) + assert not dm_file.exists() + + +def test_delivery_runner_preserves_child_failure_and_unlinks(tmp_path): + dm_file = tmp_path / "message.txt" + dm_file.write_text("secret", encoding="utf-8") + child = tmp_path / "fail.py" + child.write_text( + "import pathlib, sys\n" + "assert pathlib.Path(sys.argv[-1]).read_text(encoding='utf-8') == 'secret'\n" + "raise SystemExit(7)\n", + encoding="utf-8", + ) + + returncode = bot_mode_dm._run_delivery( + [sys.executable, str(child)], str(dm_file), stdin_file=False + ) + + assert returncode == 7 + assert not dm_file.exists() + + +@pytest.mark.parametrize( + ("terminal_result", "raises"), + [ + (json.dumps({"error": "rejected"}), False), + ("not json", False), + (None, True), + ], +) +def test_spawn_failure_unlinks_untransferred_file( + tmp_path, monkeypatch, terminal_result, raises +): + dm_file = tmp_path / "message.txt" + dm_file.write_text("secret", encoding="utf-8") + + import tools.terminal_tool as terminal_tool_module + + def fail_spawn(command, **kwargs): + assert dm_file.exists() + if raises: + raise RuntimeError("spawn failed") + return terminal_result + + monkeypatch.setattr(terminal_tool_module, "terminal_tool", fail_spawn) + result = json.loads( + bot_mode_dm._spawn_delivery( + "unused", "@researcher", dm_file=str(dm_file), task_id=None, agent=None + ) + ) + + assert "error" in result + assert not dm_file.exists() + + +def test_successful_spawn_transfers_cleanup_to_runner(tmp_path, monkeypatch): + dm_file = tmp_path / "message.txt" + dm_file.write_text("secret", encoding="utf-8") + + import tools.terminal_tool as terminal_tool_module + + def launched(command, **kwargs): + assert dm_file.exists() + return json.dumps({"session_id": "proc_test1234"}) + + monkeypatch.setattr(terminal_tool_module, "terminal_tool", launched) + result = json.loads( + bot_mode_dm._spawn_delivery( + "unused", "@researcher", dm_file=str(dm_file), task_id=None, agent=None + ) + ) + + assert result["status"] == "sent" + assert dm_file.exists(), "the parent must not delete before the background runner reads" + + +def test_write_dm_file_unlinks_partial_file_on_write_exception(tmp_path, monkeypatch): + dm_file = tmp_path / "partial.txt" + real_mkstemp = bot_mode_dm.tempfile.mkstemp + + def fixed_mkstemp(**kwargs): + return real_mkstemp(dir=tmp_path, **kwargs) + + class BrokenWriter: + def __enter__(self): + return self + + def __exit__(self, *exc_info): + return False + + def write(self, content): + raise OSError("disk full") + + monkeypatch.setattr(bot_mode_dm.tempfile, "mkstemp", fixed_mkstemp) + monkeypatch.setattr(bot_mode_dm.os, "fdopen", lambda *args, **kwargs: BrokenWriter()) + + with pytest.raises(OSError, match="disk full"): + bot_mode_dm._write_dm_file("secret") + assert list(tmp_path.glob("hermes-dm-*.txt")) == [] diff --git a/tools/bot_mode_dm.py b/tools/bot_mode_dm.py index 25785a98a7d8..dbacb11e3c8c 100644 --- a/tools/bot_mode_dm.py +++ b/tools/bot_mode_dm.py @@ -45,6 +45,8 @@ import os import re import shlex +import subprocess +import sys import tempfile import time from pathlib import Path @@ -290,9 +292,15 @@ def message_agent_tool( f"No registered peer named '{peer_name}'.", roster=teammates, peers=peers ) dm_target = f"{peer_name}/{peer_profile}" if peer_profile else peer_name - command = f"hermes peer dm {shlex.quote(dm_target)} < {shlex.quote(_write_dm_file(prefix + body))}" label = f"@{peer_profile or peer_name} on peer '{peer_name}'" - return _spawn_delivery(command, label, task_id=task_id, agent=agent) + return _start_delivery( + ["hermes", "peer", "dm", dm_target], + prefix + body, + label, + stdin_file=True, + task_id=task_id, + agent=agent, + ) # ── local teammate ── if not _LOCAL_TARGET_RE.match(raw_target) and "@" not in raw_target: @@ -326,12 +334,25 @@ def message_agent_tool( return relayed return _err("You can't message yourself. Pick a teammate from the roster.") - dm_file = _write_dm_file(prefix + body) - command = ( - f"hermes -p {shlex.quote(resolved)} chat --in ~ -c \"Bot Chat\" " - f"--create-if-missing -Q --query-file {shlex.quote(dm_file)}" + return _start_delivery( + [ + "hermes", + "-p", + resolved, + "chat", + "--in", + "~", + "-c", + "Bot Chat", + "--create-if-missing", + "-Q", + ], + prefix + body, + f"@{_handle(resolved)}", + stdin_file=False, + task_id=task_id, + agent=agent, ) - return _spawn_delivery(command, f"@{_handle(resolved)}", task_id=task_id, agent=agent) def _try_relay_delivery( @@ -395,13 +416,90 @@ def _try_relay_delivery( def _write_dm_file(content: str) -> str: """The message rides a temp file — never inline shell text.""" fd, path = tempfile.mkstemp(prefix="hermes-dm-", suffix=".txt", text=True) - with os.fdopen(fd, "w", encoding="utf-8") as f: - f.write(content) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + except BaseException: + # fdopen owns the descriptor once it succeeds, but if fdopen itself + # failed the raw descriptor is still ours. Closing twice is harmless. + try: + os.close(fd) + except OSError: + pass + _unlink_dm_file(path) + raise return path -def _spawn_delivery(command: str, label: str, *, task_id: Optional[str], agent: Any) -> str: - """Run the delivery command tracked + background, notify on completion.""" +def _unlink_dm_file(path: str) -> None: + try: + os.unlink(path) + except OSError: + pass + + +def _run_delivery(argv: list[str], dm_file: str, *, stdin_file: bool) -> int: + """Run one DM transport and remove its plaintext file after consumption.""" + try: + if stdin_file: + with open(dm_file, "r", encoding="utf-8") as stream: + return subprocess.run(argv, stdin=stream, check=False).returncode + return subprocess.run( + [*argv, "--query-file", dm_file], + check=False, + ).returncode + finally: + _unlink_dm_file(dm_file) + + +def _delivery_command(argv: list[str], dm_file: str, *, stdin_file: bool) -> str: + """Build an argv-safe command for the cleanup-owning background runner.""" + runner_argv = [ + sys.executable, + str(Path(__file__).resolve()), + "--run-delivery", + "stdin" if stdin_file else "query-file", + dm_file, + *argv, + ] + return shlex.join(runner_argv) + + +def _start_delivery( + argv: list[str], + content: str, + label: str, + *, + stdin_file: bool, + task_id: Optional[str], + agent: Any, +) -> str: + """Create a DM file and transfer its cleanup ownership to the runner.""" + dm_file = _write_dm_file(content) + try: + command = _delivery_command(argv, dm_file, stdin_file=stdin_file) + except BaseException: + _unlink_dm_file(dm_file) + raise + return _spawn_delivery( + command, + label, + dm_file=dm_file, + task_id=task_id, + agent=agent, + ) + + +def _spawn_delivery( + command: str, + label: str, + *, + dm_file: str, + task_id: Optional[str], + agent: Any, +) -> str: + """Launch the cleanup-owning runner and transfer file ownership on ack.""" + transferred = False try: from tools.terminal_tool import terminal_tool @@ -418,6 +516,11 @@ def _spawn_delivery(command: str, label: str, *, task_id: Optional[str], agent: proc_id = parsed.get("session_id") or "" if parsed.get("error"): return _err(f"Delivery to {label} failed to start: {parsed['error']}") + if not proc_id: + return _err(f"Delivery to {label} failed to start: no process id returned") + # From this point the background runner owns the file and removes it + # only after the local query-file or peer stdin consumer has finished. + transferred = True return json.dumps( { "status": "sent", @@ -435,6 +538,26 @@ def _spawn_delivery(command: str, label: str, *, task_id: Optional[str], agent: except Exception as exc: logger.error("message_agent delivery spawn failed: %s", exc, exc_info=True) return _err(f"Delivery to {label} could not be started: {exc}") + finally: + if not transferred: + _unlink_dm_file(dm_file) + + +def _delivery_main(args: list[str]) -> int: + if len(args) < 3 or args[0] != "--run-delivery": + return 2 + stdin_file = args[1] == "stdin" + if not stdin_file and args[1] != "query-file": + return 2 + dm_file = args[2] + try: + return _run_delivery(args[3:], dm_file, stdin_file=stdin_file) + except Exception as exc: + print( + f"message_agent delivery failed: {type(exc).__name__}: {exc}", + file=sys.stderr, + ) + return 1 # ── agent-context helpers (mirror system_prompt.py's resolution) ───────────── @@ -464,3 +587,7 @@ def _session_title(agent: Any) -> str: except Exception: pass return "" + + +if __name__ == "__main__": # pragma: no cover - exercised as a background process + raise SystemExit(_delivery_main(sys.argv[1:])) From 0ae18cdae022e5071b318b797bf896e8c7c97057 Mon Sep 17 00:00:00 2001 From: tachyon-r <291518778+tachyon-r@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:04:05 -0400 Subject: [PATCH 095/666] test(bot-mode): cover delivery runner and sweep orphans --- tests/tools/test_bot_mode_dm.py | 112 +++++++++++++++++++++++++++++++- tools/bot_mode_dm.py | 46 +++++++++++-- 2 files changed, 152 insertions(+), 6 deletions(-) diff --git a/tests/tools/test_bot_mode_dm.py b/tests/tools/test_bot_mode_dm.py index 8d7ee9331008..5cda30028578 100644 --- a/tests/tools/test_bot_mode_dm.py +++ b/tests/tools/test_bot_mode_dm.py @@ -10,6 +10,7 @@ import subprocess import sys import textwrap +import time from pathlib import Path import pytest @@ -386,6 +387,87 @@ def test_delivery_runner_preserves_child_failure_and_unlinks(tmp_path): assert not dm_file.exists() +@pytest.mark.parametrize("args", [[], ["--run-delivery"], ["--run-delivery", "bad", "x"]]) +def test_delivery_main_rejects_invalid_cli(args): + assert bot_mode_dm._delivery_main(args) == 2 + + +@pytest.mark.parametrize("mode", ["stdin", "query-file"]) +def test_delivery_main_runs_valid_cli_and_unlinks(tmp_path, mode): + dm_file = tmp_path / "message.txt" + dm_file.write_text("secret", encoding="utf-8") + observed = tmp_path / "observed.txt" + child = tmp_path / "child.py" + child.write_text( + "import pathlib, sys\n" + "source = sys.stdin if sys.argv[1] == '-' else open(sys.argv[1], encoding='utf-8')\n" + "with source:\n" + " pathlib.Path(sys.argv[2]).write_text(source.read(), encoding='utf-8')\n", + encoding="utf-8", + ) + source_arg = "-" if mode == "stdin" else str(dm_file) + + returncode = bot_mode_dm._delivery_main( + [ + "--run-delivery", + mode, + str(dm_file), + sys.executable, + str(child), + source_arg, + str(observed), + ] + ) + + assert returncode == 0 + assert observed.read_text(encoding="utf-8") == "secret" + assert not dm_file.exists() + + +def test_delivery_main_maps_launch_exception_to_one_and_unlinks(tmp_path, monkeypatch): + dm_file = tmp_path / "message.txt" + dm_file.write_text("secret", encoding="utf-8") + + def boom(*args, **kwargs): + raise RuntimeError("child launch failed") + + monkeypatch.setattr(subprocess, "run", boom) + assert ( + bot_mode_dm._delivery_main( + ["--run-delivery", "query-file", str(dm_file), "missing-transport"] + ) + == 1 + ) + assert not dm_file.exists() + + +@pytest.mark.parametrize("stdin_file", [False, True]) +def test_real_delivery_command_round_trip(tmp_path, stdin_file): + dm_file = tmp_path / "message with spaces.txt" + dm_file.write_text("secret λ\nsecond line", encoding="utf-8") + observed = tmp_path / "observed with spaces.txt" + child = tmp_path / "child with spaces.py" + child.write_text( + "import pathlib, sys\n" + "source = sys.stdin if sys.argv[1] == '-' else open(sys.argv[1], encoding='utf-8')\n" + "with source:\n" + " pathlib.Path(sys.argv[2]).write_text(source.read(), encoding='utf-8')\n", + encoding="utf-8", + ) + source_arg = "-" if stdin_file else str(dm_file) + command = bot_mode_dm._delivery_command( + [sys.executable, str(child), source_arg, str(observed)], + str(dm_file), + stdin_file=stdin_file, + ) + + result = subprocess.run(shlex.split(command), check=False) + + assert result.returncode == 0 + assert observed.read_text(encoding="utf-8") == "secret λ\nsecond line" + assert not dm_file.exists() + + @pytest.mark.parametrize( ("terminal_result", "raises"), [ @@ -445,7 +527,8 @@ def test_write_dm_file_unlinks_partial_file_on_write_exception(tmp_path, monkeyp real_mkstemp = bot_mode_dm.tempfile.mkstemp def fixed_mkstemp(**kwargs): - return real_mkstemp(dir=tmp_path, **kwargs) + kwargs["dir"] = tmp_path + return real_mkstemp(**kwargs) class BrokenWriter: def __enter__(self): @@ -462,4 +545,29 @@ def write(self, content): with pytest.raises(OSError, match="disk full"): bot_mode_dm._write_dm_file("secret") - assert list(tmp_path.glob("hermes-dm-*.txt")) == [] + assert list(tmp_path.glob("dm-*.txt")) == [] + + +def test_sweeper_removes_only_stale_dm_files(tmp_path, monkeypatch): + dm_dir = tmp_path / bot_mode_dm._DM_DIR_NAME + dm_dir.mkdir() + legacy_stale = tmp_path / "hermes-dm-stale.txt" + stale = dm_dir / "dm-stale.txt" + fresh = dm_dir / "dm-fresh.txt" + unrelated = tmp_path / "other.txt" + for path in (legacy_stale, stale, fresh, unrelated): + path.write_text("secret", encoding="utf-8") + now = time.time() + old = now - bot_mode_dm._DM_STALE_SECONDS - 1 + import os + + os.utime(legacy_stale, (old, old)) + os.utime(stale, (old, old)) + monkeypatch.setattr(bot_mode_dm.tempfile, "gettempdir", lambda: str(tmp_path)) + + bot_mode_dm._sweep_stale_dm_files(now=now) + + assert not legacy_stale.exists() + assert not stale.exists() + assert fresh.exists() + assert unrelated.exists() diff --git a/tools/bot_mode_dm.py b/tools/bot_mode_dm.py index dbacb11e3c8c..7092ed5e88b2 100644 --- a/tools/bot_mode_dm.py +++ b/tools/bot_mode_dm.py @@ -60,6 +60,12 @@ # runaway paste can't turn one DM into a context bomb on the recipient. MESSAGE_MAX_CHARS = 16000 +# A runner normally owns and removes each file. This bounds the residual +# plaintext lifetime if the machine dies after background-spawn acknowledgement +# but before the runner reaches its ``finally`` block. +_DM_DIR_NAME = "hermes-dm" +_DM_STALE_SECONDS = 24 * 60 * 60 + _PEER_TARGET_RE = re.compile(r"^([a-z0-9][a-z0-9_-]{0,63})/([a-zA-Z0-9][a-zA-Z0-9_-]{0,63})$") _LOCAL_TARGET_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$") @@ -413,9 +419,34 @@ def _try_relay_delivery( return None +def _dm_dir() -> Path: + path = Path(tempfile.gettempdir()) / _DM_DIR_NAME + path.mkdir(mode=0o700, parents=True, exist_ok=True) + return path + + +def _sweep_stale_dm_files(*, now: float | None = None) -> None: + """Best-effort cleanup for files orphaned before their runner started.""" + cutoff = (time.time() if now is None else now) - _DM_STALE_SECONDS + # Include the legacy temp-root location so upgrades clean files created by + # versions predating the dedicated directory. + locations = ((Path(tempfile.gettempdir()), "hermes-dm-*.txt"), (_dm_dir(), "*.txt")) + for directory, pattern in locations: + try: + for candidate in directory.glob(pattern): + try: + if candidate.is_file() and candidate.stat().st_mtime < cutoff: + candidate.unlink() + except OSError: + pass + except OSError: + pass + + def _write_dm_file(content: str) -> str: """The message rides a temp file — never inline shell text.""" - fd, path = tempfile.mkstemp(prefix="hermes-dm-", suffix=".txt", text=True) + _sweep_stale_dm_files() + fd, path = tempfile.mkstemp(prefix="dm-", suffix=".txt", dir=_dm_dir(), text=True) try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(content) @@ -442,6 +473,8 @@ def _run_delivery(argv: list[str], dm_file: str, *, stdin_file: bool) -> int: """Run one DM transport and remove its plaintext file after consumption.""" try: if stdin_file: + # Keep the file open until the transport exits; cleanup occurs + # after subprocess.run returns, not merely after stdin reaches EOF. with open(dm_file, "r", encoding="utf-8") as stream: return subprocess.run(argv, stdin=stream, check=False).returncode return subprocess.run( @@ -494,11 +527,16 @@ def _spawn_delivery( command: str, label: str, *, - dm_file: str, + dm_file: Optional[str] = None, task_id: Optional[str], agent: Any, ) -> str: - """Launch the cleanup-owning runner and transfer file ownership on ack.""" + """Launch the cleanup-owning runner and transfer file ownership on ack. + + ``dm_file`` is None for relay deliveries: the waiter command watches a + reply file, and the envelope artifacts are owned and swept by + ``tools/bot_relay.py`` — there is no plaintext DM tempfile to reclaim. + """ transferred = False try: from tools.terminal_tool import terminal_tool @@ -539,7 +577,7 @@ def _spawn_delivery( logger.error("message_agent delivery spawn failed: %s", exc, exc_info=True) return _err(f"Delivery to {label} could not be started: {exc}") finally: - if not transferred: + if dm_file and not transferred: _unlink_dm_file(dm_file) From 08742d0e32a1a3b7e9a795fe5d0923064bcbb0c6 Mon Sep 17 00:00:00 2001 From: tachyon-r <291518778+tachyon-r@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:08:32 -0400 Subject: [PATCH 096/666] fix(bot-mode): isolate DM tempfiles per user --- tests/tools/test_bot_mode_dm.py | 45 ++++++++++++++++++++++++++++----- tools/bot_mode_dm.py | 18 +++++++++++-- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/tests/tools/test_bot_mode_dm.py b/tests/tools/test_bot_mode_dm.py index 5cda30028578..6bf24b3fa827 100644 --- a/tests/tools/test_bot_mode_dm.py +++ b/tests/tools/test_bot_mode_dm.py @@ -6,6 +6,7 @@ """ import json +import os import shlex import subprocess import sys @@ -549,8 +550,8 @@ def write(self, content): def test_sweeper_removes_only_stale_dm_files(tmp_path, monkeypatch): - dm_dir = tmp_path / bot_mode_dm._DM_DIR_NAME - dm_dir.mkdir() + monkeypatch.setattr(bot_mode_dm.tempfile, "gettempdir", lambda: str(tmp_path)) + dm_dir = bot_mode_dm._dm_dir() legacy_stale = tmp_path / "hermes-dm-stale.txt" stale = dm_dir / "dm-stale.txt" fresh = dm_dir / "dm-fresh.txt" @@ -559,15 +560,47 @@ def test_sweeper_removes_only_stale_dm_files(tmp_path, monkeypatch): path.write_text("secret", encoding="utf-8") now = time.time() old = now - bot_mode_dm._DM_STALE_SECONDS - 1 - import os - os.utime(legacy_stale, (old, old)) os.utime(stale, (old, old)) - monkeypatch.setattr(bot_mode_dm.tempfile, "gettempdir", lambda: str(tmp_path)) - bot_mode_dm._sweep_stale_dm_files(now=now) assert not legacy_stale.exists() assert not stale.exists() assert fresh.exists() assert unrelated.exists() + + +def test_dm_dir_is_private_and_uid_scoped_on_posix(tmp_path, monkeypatch): + monkeypatch.setattr(bot_mode_dm.tempfile, "gettempdir", lambda: str(tmp_path)) + + dm_dir = bot_mode_dm._dm_dir() + + if hasattr(os, "getuid"): + assert dm_dir.name == f"{bot_mode_dm._DM_DIR_NAME}-{os.getuid()}" + else: + assert dm_dir.name == bot_mode_dm._DM_DIR_NAME + assert dm_dir.stat().st_mode & 0o777 == 0o700 + + +def test_dm_dir_repairs_restrictive_owner_mode(tmp_path, monkeypatch): + monkeypatch.setattr(bot_mode_dm.tempfile, "gettempdir", lambda: str(tmp_path)) + uid = os.getuid() if hasattr(os, "getuid") else None + dirname = f"{bot_mode_dm._DM_DIR_NAME}-{uid}" if uid is not None else bot_mode_dm._DM_DIR_NAME + dm_dir = tmp_path / dirname + dm_dir.mkdir(mode=0o500) + dm_dir.chmod(0o500) + + assert bot_mode_dm._dm_dir() == dm_dir + assert dm_dir.stat().st_mode & 0o777 == 0o700 + + +@pytest.mark.skipif(not hasattr(os, "getuid"), reason="POSIX ownership contract") +def test_dm_dir_rejects_precreated_symlink(tmp_path, monkeypatch): + target = tmp_path / "attacker-controlled" + target.mkdir() + expected = tmp_path / f"{bot_mode_dm._DM_DIR_NAME}-{os.getuid()}" + expected.symlink_to(target, target_is_directory=True) + monkeypatch.setattr(bot_mode_dm.tempfile, "gettempdir", lambda: str(tmp_path)) + + with pytest.raises(PermissionError, match="not a directory"): + bot_mode_dm._dm_dir() diff --git a/tools/bot_mode_dm.py b/tools/bot_mode_dm.py index 7092ed5e88b2..04698a1495b0 100644 --- a/tools/bot_mode_dm.py +++ b/tools/bot_mode_dm.py @@ -45,6 +45,7 @@ import os import re import shlex +import stat import subprocess import sys import tempfile @@ -420,8 +421,21 @@ def _try_relay_delivery( def _dm_dir() -> Path: - path = Path(tempfile.gettempdir()) / _DM_DIR_NAME - path.mkdir(mode=0o700, parents=True, exist_ok=True) + uid_getter = getattr(os, "getuid", None) + uid = uid_getter() if callable(uid_getter) else None + dirname = f"{_DM_DIR_NAME}-{uid}" if uid is not None else _DM_DIR_NAME + path = Path(tempfile.gettempdir()) / dirname + path.mkdir(mode=0o700, exist_ok=True) + + # Shared POSIX temp roots need a per-user directory. Fail closed if an + # attacker pre-created the expected path or replaced it with a symlink. + info = path.lstat() + if not stat.S_ISDIR(info.st_mode): + raise PermissionError(f"DM temp path is not a directory: {path}") + if uid is not None and info.st_uid != uid: + raise PermissionError(f"DM temp directory is owned by another user: {path}") + if stat.S_IMODE(info.st_mode) != 0o700: + path.chmod(0o700) return path From 793fba428ade996975cb300eb3aa512ebe6ffb41 Mon Sep 17 00:00:00 2001 From: mehmetkr-31 Date: Sun, 23 Aug 2026 02:57:41 -0700 Subject: [PATCH 097/666] fix(bot-mode): reap orphaned DM payloads from gateway housekeeping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-band sweep in _write_dm_file only runs when another DM is written — a gateway that never sends one keeps orphans forever. Expose the sweep as cleanup_bot_dm_cache() with the same contract as the other cleanup_*_cache helpers (returns files removed) and wire it into the gateway housekeeping loop on the hourly media-cache cadence. Also sweeps legacy hermes-dm-*.txt and hermes-relay-dm-*.txt orphans in the OS temp root. Folded in from #92407 (mehmetkr-31), adapted to the runner-owned cleanup design salvaged from #91902. --- gateway/run.py | 2 + tests/tools/test_bot_dm_payload_cache.py | 74 ++++++++++++++++++++++++ tools/bot_mode_dm.py | 37 ++++++++++-- 3 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 tests/tools/test_bot_dm_payload_cache.py diff --git a/gateway/run.py b/gateway/run.py index 76526eba8c6a..91e7f513bc97 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -30196,6 +30196,7 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop cleanup_video_cache, ) from tools.tool_result_storage import cleanup_spillover_cache + from tools.bot_mode_dm import cleanup_bot_dm_cache from hermes_cli.debug import _sweep_expired_pastes IMAGE_CACHE_EVERY = 60 # ticks — once per hour at default 60s interval @@ -30215,6 +30216,7 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop ("Video", cleanup_video_cache), ("Screenshot", cleanup_screenshot_cache), ("Spillover", cleanup_spillover_cache), + ("Bot DM", cleanup_bot_dm_cache), ) logger.info("Gateway housekeeping started (interval=%ds)", interval) diff --git a/tests/tools/test_bot_dm_payload_cache.py b/tests/tools/test_bot_dm_payload_cache.py new file mode 100644 index 000000000000..1cbcfdcd0f85 --- /dev/null +++ b/tests/tools/test_bot_dm_payload_cache.py @@ -0,0 +1,74 @@ +"""DM payload files must be reaped by gateway housekeeping, not only in-band. + +`message_agent` writes the message body to a file and hands the path to a +*background* delivery, so it cannot be removed at the call site. The runner +owns per-delivery cleanup, and `_write_dm_file` sweeps opportunistically — +but a gateway that never sends another DM would still keep orphans forever. +`cleanup_bot_dm_cache` follows the same contract as the other +``cleanup_*_cache`` helpers (returns the number of files removed) so the +gateway housekeeping loop in ``gateway/run.py`` prunes this cache on the +same hourly cadence as the media caches. +""" + +import os +import time +from pathlib import Path + +import pytest + +from tools import bot_mode_dm + + +@pytest.fixture() +def temp_root(tmp_path, monkeypatch): + monkeypatch.setattr(bot_mode_dm.tempfile, "gettempdir", lambda: str(tmp_path)) + return tmp_path + + +def _age(path: Path, seconds: float) -> None: + past = time.time() - seconds + os.utime(path, (past, past)) + + +class TestCleanupContract: + def test_expired_payloads_are_removed_and_counted(self, temp_root): + old = Path(bot_mode_dm._write_dm_file("stale")) + fresh = Path(bot_mode_dm._write_dm_file("recent")) + _age(old, bot_mode_dm._DM_STALE_SECONDS + 1) + + removed = bot_mode_dm.cleanup_bot_dm_cache() + + assert removed == 1 + assert not old.exists() + assert fresh.exists(), "a payload still in flight was reaped" + + def test_cleanup_reports_how_many_it_removed(self, temp_root): + # write all files first: _write_dm_file itself sweeps opportunistically + paths = [Path(bot_mode_dm._write_dm_file("x")) for _ in range(3)] + for p in paths: + _age(p, bot_mode_dm._DM_STALE_SECONDS + 1) + assert bot_mode_dm.cleanup_bot_dm_cache() == 3 + + def test_a_missing_dm_dir_is_not_an_error(self, temp_root): + assert bot_mode_dm.cleanup_bot_dm_cache() == 0 + + def test_legacy_and_relay_prefixed_orphans_are_swept(self, temp_root): + legacy = temp_root / "hermes-dm-legacy.txt" + relay = temp_root / "hermes-relay-dm-orphan.txt" + unrelated = temp_root / "other.txt" + for f in (legacy, relay, unrelated): + f.write_text("secret", encoding="utf-8") + _age(f, bot_mode_dm._DM_STALE_SECONDS + 1) + + removed = bot_mode_dm.cleanup_bot_dm_cache() + + assert removed == 2 + assert not legacy.exists() + assert not relay.exists() + assert unrelated.exists() + + def test_shorter_max_age_hours_is_honored(self, temp_root): + recent = Path(bot_mode_dm._write_dm_file("an hour old")) + _age(recent, 2 * 3600) + assert bot_mode_dm.cleanup_bot_dm_cache(max_age_hours=1) == 1 + assert not recent.exists() diff --git a/tools/bot_mode_dm.py b/tools/bot_mode_dm.py index 04698a1495b0..2eda26eadb49 100644 --- a/tools/bot_mode_dm.py +++ b/tools/bot_mode_dm.py @@ -439,22 +439,47 @@ def _dm_dir() -> Path: return path -def _sweep_stale_dm_files(*, now: float | None = None) -> None: - """Best-effort cleanup for files orphaned before their runner started.""" - cutoff = (time.time() if now is None else now) - _DM_STALE_SECONDS - # Include the legacy temp-root location so upgrades clean files created by - # versions predating the dedicated directory. - locations = ((Path(tempfile.gettempdir()), "hermes-dm-*.txt"), (_dm_dir(), "*.txt")) +def cleanup_bot_dm_cache( + max_age_hours: float = _DM_STALE_SECONDS / 3600, *, now: float | None = None +) -> int: + """Delete orphaned DM payload files older than *max_age_hours*. + + Same contract as the other ``cleanup_*_cache`` helpers — returns the + number of files removed — so the gateway housekeeping loop can prune + this cache on the same hourly cadence as the media caches, even on + installs that never send another DM (the in-band sweep in + ``_write_dm_file`` only runs when a DM is written). + """ + cutoff = (time.time() if now is None else now) - max_age_hours * 3600 + removed = 0 + # Include the legacy temp-root locations so upgrades clean files created + # by versions predating the dedicated directory. + temp_root = Path(tempfile.gettempdir()) + locations: list[tuple[Path, str]] = [ + (temp_root, "hermes-dm-*.txt"), + (temp_root, "hermes-relay-dm-*.txt"), + ] + try: + locations.append((_dm_dir(), "*.txt")) + except OSError: + pass for directory, pattern in locations: try: for candidate in directory.glob(pattern): try: if candidate.is_file() and candidate.stat().st_mtime < cutoff: candidate.unlink() + removed += 1 except OSError: pass except OSError: pass + return removed + + +def _sweep_stale_dm_files(*, now: float | None = None) -> None: + """Best-effort cleanup for files orphaned before their runner started.""" + cleanup_bot_dm_cache(now=now) def _write_dm_file(content: str) -> str: From 764dba69532e2ead23f57c2db3998ec007a3d3ce Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:57:51 -0700 Subject: [PATCH 098/666] fix(bot-relay): sweep stale relay artifacts + never leak the deliver tempfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen the DM tempfile-leak fix (#91902/#92407) to the sibling sites PR #92784 introduced: - tools/bot_relay.py: expose the 6h stale sweep as cleanup_bot_relay_artifacts() (cleanup_*_cache contract) and wire it into gateway housekeeping — previously it ran only when the Desktop drained the outbox, so plaintext envelopes/replies queued while the Desktop was away could sit on disk forever. - tui_gateway/methods_bot_relay.py: move the payload write inside the try/finally so a failed write no longer leaks hermes-relay-dm-*.txt. - tools/bot_mode_dm.py: _spawn_delivery takes dm_file=None for relay waiter deliveries, which have no plaintext DM tempfile to reclaim. --- gateway/run.py | 2 ++ tests/tools/test_bot_relay.py | 37 +++++++++++++++++++++ tests/tui_gateway/test_bot_relay_methods.py | 33 ++++++++++++++++++ tools/bot_relay.py | 30 +++++++++++++++-- tui_gateway/methods_bot_relay.py | 4 +-- 5 files changed, 102 insertions(+), 4 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 91e7f513bc97..fca3365cfac8 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -30197,6 +30197,7 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop ) from tools.tool_result_storage import cleanup_spillover_cache from tools.bot_mode_dm import cleanup_bot_dm_cache + from tools.bot_relay import cleanup_bot_relay_artifacts from hermes_cli.debug import _sweep_expired_pastes IMAGE_CACHE_EVERY = 60 # ticks — once per hour at default 60s interval @@ -30217,6 +30218,7 @@ def _start_gateway_housekeeping(stop_event: threading.Event, adapters=None, loop ("Screenshot", cleanup_screenshot_cache), ("Spillover", cleanup_spillover_cache), ("Bot DM", cleanup_bot_dm_cache), + ("Bot relay", cleanup_bot_relay_artifacts), ) logger.info("Gateway housekeeping started (interval=%ds)", interval) diff --git a/tests/tools/test_bot_relay.py b/tests/tools/test_bot_relay.py index 8692cd663666..d87861fcd6c6 100644 --- a/tests/tools/test_bot_relay.py +++ b/tests/tools/test_bot_relay.py @@ -288,3 +288,40 @@ def test_capability_fingerprint_changes_with_relay_roster(tmp_path): ]) after = bot_mode_probe.capability_fingerprint(home) assert before != after # eternal Bot Chats refresh once on roster change + + +# ── stale artifact sweep (housekeeping contract) ───────────────────────────── + + +def test_cleanup_bot_relay_artifacts_sweeps_stale_plaintext(tmp_path, monkeypatch): + import os as _os + import time as _time + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + target = {"profile": "scout", "handle": "scout", "connection_id": "cloud-1", + "connection_label": "", "title": "", "description": ""} + stale_env = bot_relay.enqueue_envelope( + tmp_path, target=target, message="old secret", + sender_profile="default", sender_handle="hermes", + ) + fresh_env = bot_relay.enqueue_envelope( + tmp_path, target=target, message="new secret", + sender_profile="default", sender_handle="hermes", + ) + base = bot_relay.relay_root(tmp_path) + stale_reply = bot_relay.write_reply(tmp_path, stale_env["id"], reply="done") + old = _time.time() - bot_relay.STALE_AFTER_SECONDS - 1 + _os.utime(base / bot_relay.OUTBOX_DIR / f"{stale_env['id']}.json", (old, old)) + _os.utime(stale_reply, (old, old)) + + removed = bot_relay.cleanup_bot_relay_artifacts() + + assert removed == 2 + assert not (base / bot_relay.OUTBOX_DIR / f"{stale_env['id']}.json").exists() + assert not stale_reply.exists() + assert (base / bot_relay.OUTBOX_DIR / f"{fresh_env['id']}.json").exists() + + +def test_cleanup_bot_relay_artifacts_missing_dir_is_zero(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "nope")) + assert bot_relay.cleanup_bot_relay_artifacts() == 0 diff --git a/tests/tui_gateway/test_bot_relay_methods.py b/tests/tui_gateway/test_bot_relay_methods.py index 747c031f1026..5aac95bb2369 100644 --- a/tests/tui_gateway/test_bot_relay_methods.py +++ b/tests/tui_gateway/test_bot_relay_methods.py @@ -105,3 +105,36 @@ def test_reply_roundtrip_and_id_validation(home): err = srv._methods["bot_relay.reply"](2, {"id": "../evil"}) assert "error" in err + + +def test_deliver_write_failure_still_removes_tempfile(home, monkeypatch, tmp_path): + """A failed payload write must not leak the relay DM tempfile.""" + import glob + import os + import tempfile as _tempfile + + made = [] + real_mkstemp = _tempfile.mkstemp + + def _tracking_mkstemp(*args, **kwargs): + kwargs["dir"] = str(tmp_path) + fd, path = real_mkstemp(*args, **kwargs) + made.append(path) + return fd, path + + class _BrokenWriter: + def __enter__(self): + return self + + def __exit__(self, *exc_info): + return False + + def write(self, content): + raise OSError("disk full") + + monkeypatch.setattr("tempfile.mkstemp", _tracking_mkstemp) + monkeypatch.setattr("os.fdopen", lambda *a, **k: _BrokenWriter()) + err = srv._methods["bot_relay.deliver"](1, {"profile": "ops", "message": "x"}) + assert "error" in err + assert made, "mkstemp was never reached" + assert not glob.glob(str(tmp_path / "hermes-relay-dm-*")), "tempfile leaked" diff --git a/tools/bot_relay.py b/tools/bot_relay.py index 2e7e20a85c41..6913bd2a8ac1 100644 --- a/tools/bot_relay.py +++ b/tools/bot_relay.py @@ -270,18 +270,44 @@ def write_reply( return path -def _sweep_stale(base: Path) -> None: - cutoff = time.time() - STALE_AFTER_SECONDS +def _sweep_stale(base: Path, *, now: float | None = None) -> int: + cutoff = (time.time() if now is None else now) - STALE_AFTER_SECONDS + removed = 0 for sub in (CLAIMED_DIR, REPLIES_DIR, OUTBOX_DIR): try: for path in (base / sub).glob("*.json"): try: if path.stat().st_mtime < cutoff: path.unlink() + removed += 1 except OSError: continue except OSError: continue + return removed + + +def cleanup_bot_relay_artifacts(max_age_hours: float | None = None) -> int: + """Sweep stale relay artifacts (envelopes/replies hold DM plaintext). + + ``_sweep_stale`` otherwise runs only when the Desktop drains the outbox + (``claim_pending_envelopes``) — if the Desktop never reconnects, queued + plaintext envelopes would sit on disk forever. Same contract as the + ``cleanup_*_cache`` helpers so the gateway housekeeping loop can call it + hourly. ``max_age_hours`` is accepted for signature compatibility but the + relay's own ``STALE_AFTER_SECONDS`` governs staleness. + """ + del max_age_hours # relay staleness is governed by STALE_AFTER_SECONDS + try: + home = Path(os.getenv("HERMES_HOME") or os.path.expanduser("~/.hermes")) + root = home.parent.parent if home.parent.name == "profiles" else home + base = relay_root(root) + if not base.is_dir(): + return 0 + return _sweep_stale(base) + except Exception: + logger.debug("bot_relay artifact sweep failed", exc_info=True) + return 0 # ── waiter (runs on the sender gateway via terminal background process) ───── diff --git a/tui_gateway/methods_bot_relay.py b/tui_gateway/methods_bot_relay.py index e337fca3c9a4..95702350776e 100644 --- a/tui_gateway/methods_bot_relay.py +++ b/tui_gateway/methods_bot_relay.py @@ -110,9 +110,9 @@ def _(rid, params: dict) -> dict: return _err(rid, 4092, f"no profile '{profile}' on this gateway") fd, tmp = tempfile.mkstemp(prefix="hermes-relay-dm-", suffix=".txt", text=True) - with os.fdopen(fd, "w", encoding="utf-8") as f: - f.write(message) try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(message) proc = subprocess.run( local_delivery_command(resolved, tmp), capture_output=True, From 265bdcac822804daf4448e227f766284b2ab46da Mon Sep 17 00:00:00 2001 From: John Paul Soliva Date: Sun, 23 Aug 2026 16:05:44 +0900 Subject: [PATCH 099/666] fix(api-server): a /p/ prefix on a non-multiplexed gateway fails closed instead of misdelivering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prefix is an address: the caller is naming WHICH agent the request is for. With gateway.multiplex_profiles off, _resolve_request_profile ignored the prefix entirely — "don't 404 a would-be valid route" — so a request explicitly addressed to one agent was silently answered by a different one. Observed live (Aug 2026): `hermes peer dm mini/researcher` was answered by the mini's DEFAULT agent with no error on either side, because that host runs one LaunchDaemon per profile and only the default daemon hosted an api_server. A wrong-agent answer is strictly worse than an error: the sender believes the addressee got the message. With multiplexing off the process serves exactly one profile, so the prefix is honored when it names that profile (peers address single-profile daemons this way without knowing the host's topology — get_active_profile_name() is the same identity the file already uses for model resolution) and rejected otherwise through the existing _PROFILE_REJECTED path (404). A process that cannot resolve its own identity rejects too: if it cannot prove who it is, it must not answer as anyone. Unprefixed requests are untouched, and multiplexed hosts are untouched — the change is confined to the prefix-present, multiplexing-off branch that previously discarded the caller's addressing. --- gateway/platforms/api_server.py | 31 +++-- ...t_api_server_profile_prefix_misdelivery.py | 115 ++++++++++++++++++ 2 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 tests/gateway/test_api_server_profile_prefix_misdelivery.py diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 88fe3d7cd550..228a17d1e30b 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -2081,12 +2081,13 @@ def _resolve_request_profile(self, request: "web.Request"): """Resolve + validate the /p// URL prefix on an API request. Returns: - - ``None`` when no profile prefix is present, or multiplexing is off - (the prefix is ignored; request handled as the default profile). + - ``None`` when no profile prefix is present, or when multiplexing + is off and the prefix names this process's own profile (the + request is already scoped to the only agent served here). - the profile name (str) when present, multiplexing is on, and the profile is one this gateway serves. - - ``_PROFILE_REJECTED`` when a prefix is present but the profile is - unknown/unconfigured (handler/middleware returns 404). + - ``_PROFILE_REJECTED`` when a prefix is present but names a profile + this process cannot serve (handler/middleware returns 404). """ profile = (request.match_info.get("profile") or "").strip() if not profile: @@ -2094,9 +2095,25 @@ def _resolve_request_profile(self, request: "web.Request"): runner = getattr(self, "gateway_runner", None) cfg = getattr(runner, "config", None) if not getattr(cfg, "multiplex_profiles", False): - # Prefix supplied but multiplexing is off — ignore it, behave as - # the single-profile gateway (don't 404 a would-be valid route). - return None + # A prefix names a specific agent. With multiplexing off this + # process serves exactly one, so honor the prefix only when it + # names that one (peers address single-profile daemons this way + # without knowing the host's topology). Anything else must fail + # closed: answering as the local profile would deliver the + # request to a DIFFERENT agent than the one addressed — silent + # misdelivery, strictly worse than a 404. Observed live (Aug + # 2026): `hermes peer dm mini/researcher` answered by the mini's + # default agent, with no error anywhere. + try: + from hermes_cli.profiles import get_active_profile_name + + own = get_active_profile_name() + except Exception: + return _PROFILE_REJECTED + if profile == own: + # Scoped to self: same handling as no prefix at all. + return None + return _PROFILE_REJECTED try: from hermes_cli.profiles import profiles_to_serve diff --git a/tests/gateway/test_api_server_profile_prefix_misdelivery.py b/tests/gateway/test_api_server_profile_prefix_misdelivery.py new file mode 100644 index 000000000000..bc37872db932 --- /dev/null +++ b/tests/gateway/test_api_server_profile_prefix_misdelivery.py @@ -0,0 +1,115 @@ +"""A ``/p//`` prefix on a non-multiplexed gateway must not misdeliver. + +The prefix is an address: the caller is naming WHICH agent the request is +for. The old behavior ignored it whenever ``gateway.multiplex_profiles`` was +off and answered as the process's own (single) profile — so a request +explicitly addressed to one agent was silently answered by a different one. +Observed live (Aug 2026): ``hermes peer dm mini/researcher`` was answered by +the mini's *default* agent, with no error on either side, because the mini +runs one LaunchDaemon per profile and only the default daemon hosted an +api_server. + +The contract now: with multiplexing off, a prefix naming this process's own +profile is honored (peers address single-profile daemons this way without +knowing the host's topology); any other name fails closed with the existing +404, because a wrong-agent answer is strictly worse than an error. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from gateway.platforms.api_server import _PROFILE_REJECTED, APIServerAdapter +from gateway.config import PlatformConfig + + +@pytest.fixture() +def adapter(): + # No gateway_runner configured -> multiplex_profiles is falsy, the exact + # shape of a standalone single-profile daemon. + return APIServerAdapter(PlatformConfig(enabled=True)) + + +def _request(profile: str | None): + return SimpleNamespace(match_info={} if profile is None else {"profile": profile}) + + +class TestResolverWithMultiplexOff: + def test_no_prefix_is_untouched(self, adapter): + assert adapter._resolve_request_profile(_request(None)) is None + + def test_prefix_naming_own_profile_is_honored(self, adapter, monkeypatch): + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "researcher" + ) + assert adapter._resolve_request_profile(_request("researcher")) is None + + def test_prefix_naming_default_on_default_home_is_honored(self, adapter, monkeypatch): + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "default" + ) + assert adapter._resolve_request_profile(_request("default")) is None + + def test_prefix_naming_another_agent_fails_closed(self, adapter, monkeypatch): + """The misdelivery case: addressed to researcher, running as default.""" + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "default" + ) + assert adapter._resolve_request_profile(_request("researcher")) is _PROFILE_REJECTED + + def test_unresolvable_own_identity_fails_closed(self, adapter, monkeypatch): + """If the process cannot prove who it is, it must not answer as anyone.""" + + def boom(): + raise RuntimeError("no home") + + monkeypatch.setattr("hermes_cli.profiles.get_active_profile_name", boom) + assert adapter._resolve_request_profile(_request("researcher")) is _PROFILE_REJECTED + + +class TestMultiplexOnUnchanged: + def test_served_profile_resolves(self, adapter, monkeypatch): + adapter.gateway_runner = SimpleNamespace( + config=SimpleNamespace( + multiplex_profiles=True, multiplex_profile_allowlist=None + ) + ) + monkeypatch.setattr( + "hermes_cli.profiles.profiles_to_serve", + lambda multiplex, profile_allowlist: [("worker", object())], + ) + assert adapter._resolve_request_profile(_request("worker")) == "worker" + assert adapter._resolve_request_profile(_request("ghost")) is _PROFILE_REJECTED + + +@pytest.mark.asyncio +async def test_http_request_addressed_to_another_agent_is_404_not_answered( + adapter, monkeypatch +): + """End to end through the real middleware: the wrong-agent request must + 404 instead of being served — a body would mean the misdelivery is back.""" + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", lambda: "default" + ) + + async def handler(request): + return web.json_response({"served_by": "default"}) + + app = web.Application(middlewares=[adapter._make_profile_prefix_middleware()]) + app.router.add_get("/p/{profile}/v1/test", handler) + app.router.add_get("/v1/test", handler) + + async with TestClient(TestServer(app)) as cli: + # Addressed to a different agent: refused. + resp = await cli.get("/p/researcher/v1/test") + assert resp.status == 404 + body = await resp.json() + assert "profile" in str(body.get("error", "")).lower() + + # Addressed to this agent, and unaddressed: both served. + assert (await cli.get("/p/default/v1/test")).status == 200 + assert (await cli.get("/v1/test")).status == 200 From 6cb1085d3d8b7af6849ff902dc58a88b7408da0c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:33:53 -0700 Subject: [PATCH 100/666] fix(gateway): /p// on a non-multiplex gateway fails closed instead of serving the owner profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A /p// URL prefix on a gateway with multiplex_profiles off was silently ignored: the request was handled as the gateway-owning profile, so /p/lokaj/v1/toolsets reported the OWNER's platform_toolsets (and every other profile-owned config read — skills, capabilities, model options, agent-run toolset resolution — resolved from the owner too). That is the exact repro in #91583 defect 2: enabling computer_use with 'hermes -p lokaj tools enable computer_use --platform api_server' showed enabled in lokaj's config while /p/lokaj/v1/toolsets stayed false, and enabling it on the owner profile flipped it true. Per-profile capability isolation is the intended design (ruling on a different profile's config. Multiplexed gateways were already correct — the profile-prefix middleware enters _profile_runtime_scope and every canonical config loader honors the HERMES_HOME override contextvar (verified empirically for load_config, get_config_path and _load_gateway_config) — the leak was only the non-multiplex fallthrough. Fix at the one seam both adapters share: _resolve_request_profile now rejects (404) a prefix naming any profile other than the one the gateway actually serves. A self-referential prefix (/p/default/ on the default gateway, /p/lokaj/ on a gateway launched for lokaj) still falls through so existing well-formed clients keep working. Same change in the webhook adapter, which had the identical fallthrough. New shared helper hermes_cli.profiles.profile_matches_home does the home comparison, fail-closed. Tests: tests/gateway/test_multiplex_toolsets_profile_isolation.py — E2E-style with two real profile homes + config.yamls under a temp HERMES_HOME, real aiohttp routing through the profile-prefix middleware: per-profile /p//v1/toolsets isolation for both owner and secondary (the #91583 repro asserts computer_use true under /p/lokaj only), cross-profile key rejection, and the fail-closed non-multiplex prefix for both adapters. Sabotage-verified: reverting the adapter change fails the 3 fail-closed tests. Fixes #91583 (defect 2). Repro and live validation by @kubaboski. --- gateway/platforms/api_server.py | 58 +++-- gateway/platforms/webhook.py | 24 ++- hermes_cli/profiles.py | 32 +++ ...st_multiplex_toolsets_profile_isolation.py | 204 ++++++++++++++++++ 4 files changed, 289 insertions(+), 29 deletions(-) create mode 100644 tests/gateway/test_multiplex_toolsets_profile_isolation.py diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 228a17d1e30b..bce42f23b590 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -66,6 +66,23 @@ # (no prefix / multiplexing off → handle as the default profile). _PROFILE_REJECTED = object() + +def _prefix_names_served_profile(profile: str) -> bool: + """True when a /p// prefix names the profile this gateway serves. + + Single-profile (non-multiplex) gateways historically ignored the prefix + and answered every /p// request from their own profile's config — + which silently served the gateway owner's toolsets/capabilities under + another profile's URL (#91583 defect 2). Only a self-referential prefix + may fall through; anything else must be rejected. Fail closed. + """ + try: + from hermes_cli.profiles import profile_matches_home + + return profile_matches_home(profile) + except Exception: + return False + # Profile selected by the /p// URL prefix for the current request. # Set by the profile-prefix middleware; read by handlers / _run_agent. _api_request_profile: ContextVar[Optional[str]] = ContextVar( @@ -2082,12 +2099,13 @@ def _resolve_request_profile(self, request: "web.Request"): Returns: - ``None`` when no profile prefix is present, or when multiplexing - is off and the prefix names this process's own profile (the - request is already scoped to the only agent served here). + is off and the prefix names this gateway's own profile (the + request is handled as the serving profile). - the profile name (str) when present, multiplexing is on, and the profile is one this gateway serves. - - ``_PROFILE_REJECTED`` when a prefix is present but names a profile - this process cannot serve (handler/middleware returns 404). + - ``_PROFILE_REJECTED`` when a prefix is present but the profile is + unknown/unconfigured, or names a profile this single-profile + gateway does not serve (handler/middleware returns 404). """ profile = (request.match_info.get("profile") or "").strip() if not profile: @@ -2095,25 +2113,19 @@ def _resolve_request_profile(self, request: "web.Request"): runner = getattr(self, "gateway_runner", None) cfg = getattr(runner, "config", None) if not getattr(cfg, "multiplex_profiles", False): - # A prefix names a specific agent. With multiplexing off this - # process serves exactly one, so honor the prefix only when it - # names that one (peers address single-profile daemons this way - # without knowing the host's topology). Anything else must fail - # closed: answering as the local profile would deliver the - # request to a DIFFERENT agent than the one addressed — silent - # misdelivery, strictly worse than a 404. Observed live (Aug - # 2026): `hermes peer dm mini/researcher` answered by the mini's - # default agent, with no error anywhere. - try: - from hermes_cli.profiles import get_active_profile_name - - own = get_active_profile_name() - except Exception: - return _PROFILE_REJECTED - if profile == own: - # Scoped to self: same handling as no prefix at all. - return None - return _PROFILE_REJECTED + # Prefix supplied but multiplexing is off. Only a self-referential + # prefix (naming the profile this gateway already serves) may fall + # through to the bare route. Silently ignoring ANY prefix served + # the gateway owner's config/toolsets/capabilities under another + # profile's URL — cross-profile capability leakage (#91583 + # defect 2) and silently misdelivered peer DMs (observed live: + # `hermes peer dm mini/researcher` answered by the mini's default + # agent) — so anything else fails closed as unknown. + return ( + None + if _prefix_names_served_profile(profile) + else _PROFILE_REJECTED + ) try: from hermes_cli.profiles import profiles_to_serve diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index bc3ebe0968e9..99eefcd634af 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -564,12 +564,14 @@ def _resolve_request_profile(self, request: "web.Request"): """Resolve + validate the /p// URL prefix on a webhook request. Returns: - - ``None`` when no profile prefix is present, or multiplexing is off - (the prefix is ignored, request handled as the default profile). + - ``None`` when no profile prefix is present, or when multiplexing + is off and the prefix names this gateway's own profile (the + request is handled as the serving profile). - the profile name (str) when present, multiplexing is on, and the profile is one this gateway serves. - ``_PROFILE_REJECTED`` when a prefix is present but the profile is - unknown/unconfigured (handler returns 404). + unknown/unconfigured, or names a profile this single-profile + gateway does not serve (handler returns 404). """ profile = (request.match_info.get("profile") or "").strip() if not profile: @@ -577,9 +579,19 @@ def _resolve_request_profile(self, request: "web.Request"): runner = self.gateway_runner cfg = getattr(runner, "config", None) if not getattr(cfg, "multiplex_profiles", False): - # Prefix supplied but multiplexing is off — ignore it, behave as - # the single-profile gateway (don't 404 a would-be valid route). - return None + # Prefix supplied but multiplexing is off. Only a self-referential + # prefix (naming this gateway's own profile) may fall through to + # the bare route; anything else fails closed — silently ignoring + # the prefix served the gateway owner's routes/config under + # another profile's URL (#91583 defect 2). + try: + from hermes_cli.profiles import profile_matches_home + + if profile_matches_home(profile): + return None + except Exception: + pass + return _PROFILE_REJECTED try: from hermes_cli.profiles import profiles_to_serve served = { diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 6e83a824f323..fbcd9cbd8762 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -387,6 +387,38 @@ def profile_exists(name: str) -> bool: return get_profile_dir(canon).is_dir() +def profile_matches_home(name: str, home: "Path | None" = None) -> bool: + """Return True when *name* refers to the profile served from *home*. + + ``home`` defaults to the process's current Hermes home + (:func:`hermes_constants.get_hermes_home`). Used by single-profile + gateways to decide whether a ``/p//`` URL prefix is + self-referential (safe to serve on the bare route) or names a *different* + profile — in which case the request must fail closed rather than silently + resolve config/toolsets from the gateway owner (#91583 defect 2). + + Invalid profile names return False (fail closed). + """ + try: + target = get_profile_dir(name) + except Exception: + return False + if home is None: + try: + from hermes_constants import get_hermes_home + + home = get_hermes_home() + except Exception: + return False + try: + return ( + Path(target).expanduser().resolve(strict=False) + == Path(home).expanduser().resolve(strict=False) + ) + except Exception: + return False + + def list_profile_names() -> List[str]: """Cheap name-only profile listing: ``default`` plus profile dirs. diff --git a/tests/gateway/test_multiplex_toolsets_profile_isolation.py b/tests/gateway/test_multiplex_toolsets_profile_isolation.py new file mode 100644 index 000000000000..86a8f320dea7 --- /dev/null +++ b/tests/gateway/test_multiplex_toolsets_profile_isolation.py @@ -0,0 +1,204 @@ +"""Per-profile toolset isolation on the multiplexed api_server (#91583 defect 2). + +Two defects covered: + +1. With multiplexing ON, ``/p//v1/toolsets`` must reflect profile *x*'s own + ``platform_toolsets.api_server`` — for both the gateway-owning default + profile and a secondary profile — never the listener owner's config. + +2. With multiplexing OFF, a ``/p//`` prefix used to be silently + ignored, serving the gateway owner's config under another profile's URL. + It must now be rejected (404); only a self-referential prefix (naming the + profile this gateway actually serves) falls through. + +E2E-style: real profile homes under a temp HERMES root, real config.yaml +files read through the canonical loaders, and real aiohttp request routing +(TestClient) through the profile-prefix middleware. No mocked config reads. +""" +from __future__ import annotations + +import pytest + +aiohttp = pytest.importorskip("aiohttp") +from aiohttp import web # noqa: E402 +from aiohttp.test_utils import TestClient, TestServer # noqa: E402 + +from gateway.config import GatewayConfig, PlatformConfig # noqa: E402 +from gateway.platforms.api_server import ( # noqa: E402 + APIServerAdapter, + _PROFILE_REJECTED, +) + +OWNER_KEY = "owner-key-1234567890abcdef" +LOKAJ_KEY = "lokaj-key-1234567890abcdef" + + +@pytest.fixture() +def hermes_root(tmp_path, monkeypatch): + """Two real profile homes: default (owner) and 'lokaj' (secondary).""" + root = tmp_path / "hermes" + lokaj = root / "profiles" / "lokaj" + lokaj.mkdir(parents=True) + (root / "config.yaml").write_text( + "platform_toolsets:\n api_server: [web, file]\n", + encoding="utf-8", + ) + (lokaj / "config.yaml").write_text( + "platform_toolsets:\n api_server: [web, file, computer_use]\n", + encoding="utf-8", + ) + (lokaj / ".env").write_text(f"API_SERVER_KEY={LOKAJ_KEY}\n", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(root)) + # get_default_hermes_root memoizes per (native_home, env_home) pair, so + # the env change alone re-keys it; no cache reset needed. + return root + + +def _make_adapter(multiplex: bool) -> APIServerAdapter: + cfg = PlatformConfig(enabled=True, extra={"key": OWNER_KEY}) + adapter = APIServerAdapter(cfg) + + class _Runner: + config = GatewayConfig(multiplex_profiles=multiplex) + + adapter.gateway_runner = _Runner() + return adapter + + +def _make_app(adapter: APIServerAdapter) -> web.Application: + """Mirror connect()'s wiring: middleware + native and /p/ mirror routes.""" + app = web.Application(middlewares=[adapter._make_profile_prefix_middleware()]) + app["api_server_adapter"] = adapter + for method, path, handler in adapter._http_route_table(): + app.router.add_route(method, path, handler) + app.router.add_route(method, f"/p/{{profile}}{path}", handler) + return app + + +async def _enabled_toolsets(cli: TestClient, path: str, key: str): + resp = await cli.get(path, headers={"Authorization": f"Bearer {key}"}) + if resp.status != 200: + return resp.status, None + body = await resp.json() + return resp.status, {d["name"] for d in body["data"] if d["enabled"]} + + +class TestMultiplexOnToolsetIsolation: + @pytest.mark.asyncio + async def test_each_profile_sees_its_own_toolsets(self, hermes_root): + adapter = _make_adapter(multiplex=True) + async with TestClient(TestServer(_make_app(adapter))) as cli: + # Owner (default) profile: bare route and /p/default mirror. + for path in ("/v1/toolsets", "/p/default/v1/toolsets"): + status, enabled = await _enabled_toolsets(cli, path, OWNER_KEY) + assert status == 200, path + assert "computer_use" not in enabled, path + assert {"web", "file"} <= enabled, path + + # Secondary profile: its own config, its own key. + status, enabled = await _enabled_toolsets( + cli, "/p/lokaj/v1/toolsets", LOKAJ_KEY + ) + assert status == 200 + # The exact repro from #91583 defect 2: computer_use enabled in + # lokaj's config must be reported enabled under /p/lokaj/… + assert "computer_use" in enabled + + @pytest.mark.asyncio + async def test_owner_key_does_not_open_secondary_profile(self, hermes_root): + """Cross-profile auth stays closed: owner key must not read lokaj.""" + adapter = _make_adapter(multiplex=True) + async with TestClient(TestServer(_make_app(adapter))) as cli: + status, _ = await _enabled_toolsets( + cli, "/p/lokaj/v1/toolsets", OWNER_KEY + ) + assert status == 401 + + @pytest.mark.asyncio + async def test_unknown_profile_is_404(self, hermes_root): + adapter = _make_adapter(multiplex=True) + async with TestClient(TestServer(_make_app(adapter))) as cli: + resp = await cli.get( + "/p/ghost/v1/toolsets", + headers={"Authorization": f"Bearer {OWNER_KEY}"}, + ) + assert resp.status == 404 + + +class TestMultiplexOffPrefixFailsClosed: + """Single-profile gateways must not serve another profile's URL.""" + + def test_foreign_prefix_rejected(self, hermes_root): + adapter = _make_adapter(multiplex=False) + + class _Req: + match_info = {"profile": "lokaj"} + + assert adapter._resolve_request_profile(_Req()) is _PROFILE_REJECTED + + def test_self_referential_prefix_falls_through(self, hermes_root): + """/p/default/ on the default-profile gateway keeps working.""" + adapter = _make_adapter(multiplex=False) + + class _Req: + match_info = {"profile": "default"} + + assert adapter._resolve_request_profile(_Req()) is None + + def test_own_named_profile_prefix_falls_through(self, hermes_root, monkeypatch): + """A gateway launched FOR profile lokaj accepts /p/lokaj/…""" + monkeypatch.setenv( + "HERMES_HOME", str(hermes_root / "profiles" / "lokaj") + ) + adapter = _make_adapter(multiplex=False) + + class _Req: + match_info = {"profile": "lokaj"} + + assert adapter._resolve_request_profile(_Req()) is None + + @pytest.mark.asyncio + async def test_foreign_prefix_is_404_end_to_end(self, hermes_root): + adapter = _make_adapter(multiplex=False) + async with TestClient(TestServer(_make_app(adapter))) as cli: + resp = await cli.get( + "/p/lokaj/v1/toolsets", + headers={"Authorization": f"Bearer {OWNER_KEY}"}, + ) + assert resp.status == 404 + # Bare route unaffected. + status, enabled = await _enabled_toolsets( + cli, "/v1/toolsets", OWNER_KEY + ) + assert status == 200 + assert "computer_use" not in enabled + + +class TestWebhookMultiplexOffPrefixFailsClosed: + """Same bug class in the webhook adapter's prefix resolver.""" + + def _adapter(self, multiplex: bool): + from gateway.platforms.webhook import WebhookAdapter, _PROFILE_REJECTED + + class _Runner: + config = GatewayConfig(multiplex_profiles=multiplex) + + adapter = WebhookAdapter.__new__(WebhookAdapter) + adapter.gateway_runner = _Runner() + return adapter, _PROFILE_REJECTED + + def test_foreign_prefix_rejected(self, hermes_root): + adapter, rejected = self._adapter(multiplex=False) + + class _Req: + match_info = {"profile": "lokaj"} + + assert adapter._resolve_request_profile(_Req()) is rejected + + def test_self_referential_prefix_falls_through(self, hermes_root): + adapter, _rejected = self._adapter(multiplex=False) + + class _Req: + match_info = {"profile": "default"} + + assert adapter._resolve_request_profile(_Req()) is None From b766607b5b92879c21ffd767465487e6de725868 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:37:24 -0700 Subject: [PATCH 101/666] test: adapt misdelivery tests to the shared profile_matches_home seam --- .../test_api_server_profile_prefix_misdelivery.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/gateway/test_api_server_profile_prefix_misdelivery.py b/tests/gateway/test_api_server_profile_prefix_misdelivery.py index bc37872db932..f78bbd844d5a 100644 --- a/tests/gateway/test_api_server_profile_prefix_misdelivery.py +++ b/tests/gateway/test_api_server_profile_prefix_misdelivery.py @@ -44,30 +44,33 @@ def test_no_prefix_is_untouched(self, adapter): def test_prefix_naming_own_profile_is_honored(self, adapter, monkeypatch): monkeypatch.setattr( - "hermes_cli.profiles.get_active_profile_name", lambda: "researcher" + "hermes_cli.profiles.profile_matches_home", + lambda name, home=None: name == "researcher", ) assert adapter._resolve_request_profile(_request("researcher")) is None def test_prefix_naming_default_on_default_home_is_honored(self, adapter, monkeypatch): monkeypatch.setattr( - "hermes_cli.profiles.get_active_profile_name", lambda: "default" + "hermes_cli.profiles.profile_matches_home", + lambda name, home=None: name == "default", ) assert adapter._resolve_request_profile(_request("default")) is None def test_prefix_naming_another_agent_fails_closed(self, adapter, monkeypatch): """The misdelivery case: addressed to researcher, running as default.""" monkeypatch.setattr( - "hermes_cli.profiles.get_active_profile_name", lambda: "default" + "hermes_cli.profiles.profile_matches_home", + lambda name, home=None: name == "default", ) assert adapter._resolve_request_profile(_request("researcher")) is _PROFILE_REJECTED def test_unresolvable_own_identity_fails_closed(self, adapter, monkeypatch): """If the process cannot prove who it is, it must not answer as anyone.""" - def boom(): + def boom(name, home=None): raise RuntimeError("no home") - monkeypatch.setattr("hermes_cli.profiles.get_active_profile_name", boom) + monkeypatch.setattr("hermes_cli.profiles.profile_matches_home", boom) assert adapter._resolve_request_profile(_request("researcher")) is _PROFILE_REJECTED From 613244cbb1280dc9fac467e5fe30e2fa93787687 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:14:00 +0200 Subject: [PATCH 102/666] feat(desktop): organize the global bot roster --- .../desktop/src/plugins/hermes-bots/plugin.js | 2245 +++++++++++++---- .../tests/active-now-strip.test.mjs | 31 +- .../hermes-bots/tests/bot-delete.test.mjs | 4 +- .../hermes-bots/tests/bots-home.test.mjs | 936 +++++++ .../hermes-bots/tests/bots-search.test.mjs | 32 +- .../tests/create-group-chat.test.mjs | 8 +- .../tests/group-chat-identity-edit.test.mjs | 7 +- .../tests/pane-dock-layout.test.mjs | 18 +- .../hermes-bots/tests/roster-groups.test.mjs | 37 +- .../hermes-bots/tests/roster-preview.test.mjs | 23 +- 10 files changed, 2822 insertions(+), 519 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 360aa5784a02..3573f0f3f989 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -4,7 +4,7 @@ * Left pane "Bots": one row per Hermes profile (a bot = an agent profile) with * a customizable avatar (shape + color + eyes, image, or pet). Click opens that * bot's chat; right-click → Edit Profile (avatar, title, description). - * "New Agent" creates a profile — Name / Title / Description with an + * "New Bot" creates a profile — Name / Title / Description with an * "Advanced" disclosure for full profile config. * * Right tile "Routines": scheduled tasks (Hermes cron jobs) scoped to the @@ -39,6 +39,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, EmptyState, GlyphSpinner, @@ -83,6 +84,11 @@ const blobatarSvg = typeof sdk === 'undefined' ? undefined : sdk.blobatarSvg const createBudgetedLoop = typeof sdk === 'undefined' ? undefined : sdk.createBudgetedLoop const ID = 'hermes-bots' +/** Tree pane id of the Bots home workspace tab (openWorkspace prefixes + * `plugin-workspace:`). Tab visibility — not session focus — is what says + * who owns the CENTER once tabs exist; session focus only vetoes passive + * opens and, on its rising edge, yields the center to the chat. */ +const BOTS_HOME_PANE_ID = `plugin-workspace:${ID}:home` const ROSTER_KEY = [ID, 'roster'] const ROUTINES_KEY = [ID, 'routines'] const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ @@ -99,13 +105,16 @@ let pluginCtx = null /** Live roster snapshot for imperative handlers (context menus). */ const $lastRoster = atom([]) -/** Bots with chat activity the user hasn't seen yet (name -> true). +/** Last source inventory returned by the desktop-wide agent roster. */ +const $lastSources = atom([]) + +/** Bots with chat activity the user hasn't seen yet (connectionId::profile -> true). * Fed by the roster poll's activity watermark, so it catches EVERY * delivery path: RPC, CLI (bot-to-bot), cron runs, other machines. */ const $botUnread = atom({}) -// last_active watermark per bot, seeded on first poll so a fresh mount -// doesn't mark ancient history unread. +// last_active watermark per source-qualified bot, seeded on first poll so a +// fresh mount doesn't mark ancient history unread. const rosterWatermarks = new Map() let watermarksSeeded = false @@ -145,9 +154,9 @@ function trackInboundActivity(roster) { continue } - // Activity in the bot the user is currently looking at is already - // visible — never badge the open chat. - if ($selectedBot.get() === key) { + // Activity in the exact bot owner the user is currently looking at is + // already visible — never badge the open chat or its same-named twin. + if ($selectedRosterKey.get() === key) { continue } @@ -155,7 +164,7 @@ function trackInboundActivity(roster) { // Roster-hidden bots stay quiet: the unread flag above accumulates // silently (unhiding reveals the badge) but a hidden bot never toasts. - if (botRosterMeta(bot, $botMeta.get())?.hidden) { + if (isBotHidden(bot, $botMeta.get())) { continue } @@ -194,6 +203,75 @@ const $selectedBot = atom('default') /** Owner of the chat the user is LOOKING AT. Newer desktops expose a * connection-qualified owner. Older builds synthesize the previous * profile/gateway fallback and listen to both atoms when available. */ +/** Source-qualified Bot Mode selection. Restoring it is presentation-only: + * it never activates a gateway or creates a session. */ +const $selectedRosterKey = atom('') +const $selectedRosterHydrated = atom(false) +const $rosterHydrated = atom(false) +/** Mirrors host.paneVisibility('hermes-bots:pane') — wired in register(). */ +const $botsPaneVisible = atom(false) +/** An explicit open landed: {key, openedRegistryId}. This transient view + * observation is empty only for the legacy newChat draft fallback. */ +const $openBotChat = atom(null) +/** A session owns the main workspace. The roster highlight and the home / + * Cronjobs lifecycles all key off this rather than reading host.state + * conditionally from render. */ +const $botChatFocused = atom(false) +/** True only while the Bots home is the visible main-area surface. A focused + * chat can remain alive behind it, so session focus alone cannot decide which + * roster row owns the visible workspace. */ +const $botsHomeFronted = atom(false) + +let botsHomeClose = null +let suppressBotsHomeReopen = false + +function saveSelectedRosterBot(bot) { + const key = botSelectionKey(bot) + + $selectedBot.set(key) + $selectedRosterKey.set(key) + + try { + Promise.resolve(pluginCtx?.storage?.set?.('selected-roster-bot-v1', key)).catch(() => undefined) + } catch { + /* storage unavailable — selection lasts for this window */ + } +} + +function clearSelectedRosterBot(bot) { + clearSelectedRosterKey(botSelectionKey(bot)) +} + +/** Drop the persisted selection when it is exactly this key — the caller has + * proven the owner is gone, not merely unreachable. An unreachable source + * KEEPS its key so the selection reconciles when the gateway returns. */ +function clearSelectedRosterKey(key) { + if ($selectedRosterKey.get() !== key) { + return + } + + $selectedRosterKey.set('') + + try { + Promise.resolve(pluginCtx?.storage?.set?.('selected-roster-bot-v1', '')).catch(() => undefined) + } catch { + /* storage unavailable — selection is cleared for this window */ + } +} + +/** Split a roster key back into its owner parts. Profile names cannot contain + * ':' (NAME_RE), so the first '::' is unambiguous. */ +function parseRosterKey(key) { + const raw = String(key || '') + const at = raw.indexOf('::') + + if (at < 0) { + return { connectionId: '', name: '' } + } + + return { connectionId: raw.slice(0, at), name: raw.slice(at + 2) } +} + const $focusedBotProfile = host.state.focusedSessionProfile || host.state.profile /** Profile that owns the chat currently on screen. Bot Mode opens another @@ -1709,30 +1787,21 @@ async function migrateBotMeta(storage = pluginCtx?.storage) { } // ── hidden bots (right-click → Hide Bot) ──────────────────────────────────── -// Hiding is a ROSTER-DISPLAY concern only: a hidden bot keeps working — -// @mentions still resolve, group-chat membership is untouched, its name -// still counts as taken, and an open chat stays open. The flag lives in bot -// meta (`hidden: true`), so it rides the same local-storage + server -// ui_meta pipeline as pins/titles and follows the profile across machines. -// Unhide writes `hidden: false` (never null): a null key survives the local -// `{ ...prev, ...patch }` merge while the server DELETES None keys, and -// that asymmetry lets mergeServerMeta resurrect a stale truthy copy. A -// literal false round-trips identically through both stores. +// Hiding is a ROSTER-DISPLAY concern only: a hidden bot keeps working, +// remains mentionable, keeps group membership, and any open chat stays open. /** Session-only view toggle: reveal hidden bots (dimmed) in the roster. */ const $showHiddenBots = atom(false) -/** Hidden flag for a roster row. Thin remote-source rows never read local - * meta (botRosterMeta returns null for them), so hide is by NAME on the - * active source; remote rows of the same name stay visible. */ function isBotHidden(bot, metaByName) { return Boolean(botRosterMeta(bot, metaByName)?.hidden) } -/** Hiding the selected bot re-homes the selection (the Routines pane - * follows it): first visible bot wins, then 'default' — unless default is - * itself hidden with nothing else visible, in which case the selection - * stays put rather than pointing somewhere even less real. */ +function isBotPinned(bot, metaByName) { + return Boolean(botRosterMeta(bot, metaByName)?.pinned) +} + +/** Hiding the selected bot re-homes the selection to the next visible owner. */ function fallbackSelectionAfterHide(name) { if ($selectedBot.get() !== name) { return @@ -1816,8 +1885,8 @@ function hideOwnedBotSessions() { // Titles Bot Mode itself mints for its plumbing sessions. Bot-to-bot CLI // handoffs (`hermes -p chat --in ~ -c "Bot Chat" --create-if-missing`) -// and mention handoffs create sessions with EXACTLY these titles; the -// "Group: " prefix is the member-session title ensureGroupChatSession has +// create sessions with EXACTLY these titles; the "Group: " prefix is the +// member-session title ensureGroupChatSession has // used since group chats shipped. Exact/prefix matching is deliberate — a // user's real conversation inside a bot profile keeps whatever title the // user gave it and is never touched. @@ -2209,6 +2278,12 @@ async function deleteBot(bot) { if ($selectedBot.get() === botSelectionKey(bot)) { $selectedBot.set('default') } + clearSelectedRosterBot(bot) + + if ($openBotChat.get()?.key === botRosterKey(bot)) { + $openBotChat.set(null) + syncBotsHomeWorkspace() + } queryClient.invalidateQueries({ queryKey: ROSTER_KEY }) @@ -3146,7 +3221,7 @@ async function mcpSetupSupported() { function McpSetupButton({ profile, entry, onDone, ensureProfile }) { // entry: { name, requires:[env keys], auth?, fromCatalog, installed } - // profile may be null at first (New Agent: the profile isn't created yet). + // profile may be null at first (New Bot: the profile isn't created yet). // ensureProfile() lazily creates it on the first setup action and returns the // slug, so OAuth / API-key setup works DURING creation, not only in Edit. const [phase, setPhase] = useState('idle') // idle | keys | oauth | busy | done | error @@ -3162,7 +3237,7 @@ function McpSetupButton({ profile, entry, onDone, ensureProfile }) { } }, [profile]) - // Resolve the target profile, creating it on demand for the New Agent flow. + // Resolve the target profile, creating it on demand for the New Bot flow. const resolveProfile = async () => { if (profileRef.current) { return profileRef.current @@ -3570,7 +3645,7 @@ async function generateAvatarImage(bot, title, description) { return res.image_data || res.image } -/** Shape grid + color swatches, shared by Edit Profile and New Agent. +/** Shape grid + color swatches, shared by Edit Profile and New Bot. * Layout uses inline grid styles — arbitrary Tailwind classes like * `grid-cols-7` are NOT in the app's precompiled CSS, which collapsed * this into a single vertical column. */ @@ -4225,6 +4300,7 @@ function cachedUnionRoster() { function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) { const localProfiles = Array.isArray(local?.profiles) ? local.profiles : [] const agents = Array.isArray(union?.agents) ? union.agents : [] + const sources = Array.isArray(union?.sources) ? union.sources : [] // A live id of null/'' means the window is on the unscoped local backend // (legacy hosts reported null for mode:'local'; the SDK now reports // 'local'). Do NOT fall back to registry primary when the third argument @@ -4370,7 +4446,10 @@ function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) const name = String(row?.name || '').trim() const key = `${connectionId}::${name || 'default'}` - if (!row?.remoteSource || !connectionId || !name || present.has(key)) { + // Ghost owners are presentation-only placeholders. Re-adopting one as + // a cached remote row would keep it alive after the selection changes + // and let an identity without its durable handle leak into shared state. + if (row?.ghost || !row?.remoteSource || !connectionId || !name || present.has(key)) { continue } @@ -4385,7 +4464,7 @@ function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) } } - return { ...local, profiles } + return { ...local, profiles: profiles.map(row => annotateBotSource(row, sources)), sources } } /** The @handle users tag a bot with. Multi-source rosters precompute the @@ -4605,6 +4684,60 @@ function persistBotMetaSnapshot(value, scoped = false) { } } +function sourceByConnection(sources) { + return new Map( + (Array.isArray(sources) ? sources : []) + .filter(source => source?.connectionId) + .map(source => [String(source.connectionId), source]) + ) +} + +/** Copy current source health onto a row without changing its owner. */ +function annotateBotSource(bot, sources) { + const id = String(bot?.connectionId || '').trim() + + if (!id) { + return bot + } + + const list = Array.isArray(sources) ? sources : [] + const source = sourceByConnection(list).get(id) + + if (!source) { + return list.length && bot?.sourceScoped ? { ...bot, sourceMissing: true, sourceReachable: false } : bot + } + + return { + ...bot, + connectionKind: bot.connectionKind || source.kind, + connectionLabel: bot.connectionLabel || source.label, + sourceError: source.error || null, + sourceMissing: false, + sourceReachable: source.reachable + } +} + +function botSourceStatus(bot) { + const error = String(bot?.sourceError || '').trim() + + if (bot?.sourceMissing) { + return { available: false, key: 'missing', label: 'Gateway removed', tone: 'bad' } + } + + if (error === 'connect-on-demand') { + return { available: true, key: 'on-demand', label: 'On demand', tone: 'muted' } + } + + if (error || bot?.sourceReachable === false) { + return { available: false, key: 'unavailable', label: 'Unavailable', tone: 'warn' } + } + + if (bot?.sourceReachable === true) { + return { available: true, key: 'ready', label: 'Ready', tone: 'good' } + } + + return { available: true, key: 'unknown', label: 'Status unknown', tone: 'muted' } +} // ── cross-connection routing ───────────────────────────────────────────────── // A bot from another registered connection (remoteSource rows) is reached // through host.requestProfile with a route descriptor; local bots keep the @@ -5149,6 +5282,125 @@ async function ensureBotMetadata(bot) { return botRosterMeta(bot, $botMeta.get()) || {} } +/** Select one exact roster owner, then open its named canonical chat only when + * the current Desktop can route that owner without guessing. The workspace + * remembers only this transient opened-view observation; it never stores or + * resolves a canonical-chat id. */ +async function openRosterBot(bot) { + const generation = ++botOpenGeneration + const key = botRosterKey(bot) + const meta = botRosterMeta(bot, $botMeta.get()) + // Keep the currently visible group as a fallback until this explicit action + // has actually fronted a new owner; a failed home open must not steal the + // center from a group the user was reading. + const previousGroup = $groupChatWorkspace.get() + + haptic('tap') + saveSelectedRosterBot(bot) + + if (bot.remoteSource) { + // Selection only. A remote bot must never be opened through whichever + // gateway happens to be live; remote mention delivery remains backend-owned. + $openBotChat.set(null) + $groupChatWorkspace.set(null) + + if (botsHomeEnabled()) { + // Explicitly front the selected owner but keep the existing group tab + // intact. If the workspace door refuses, restore the group selection so + // a failed roster action cannot leave the center ownerless. + if (!openBotsHomeWorkspace(true) && previousGroup) { + $groupChatWorkspace.set(previousGroup) + } + } else { + // Old shells have no home surface; keep the existing visible group as + // owner and offer only guidance, never renderer-owned remote delivery. + if (previousGroup) { + $groupChatWorkspace.set(previousGroup) + } + host.notify?.({ + kind: 'info', + title: displayName(bot, meta), + message: `Stay in this chat and message @${botHandle(bot.name, bot)} from a Bot Chat.` + }) + } + + return false + } + + $groupChatWorkspace.set(null) + + if ($botUnread.get()[key]) { + const next = { ...$botUnread.get() } + delete next[key] + $botUnread.set(next) + } + + try { + // Activation selects this row's source only. Canonical identity is resolved + // after that by the owner profile's "Bot Chat" title registry. + await prepareBotSource(bot) + } catch (error) { + if (generation === botOpenGeneration) { + $openBotChat.set(null) + if (previousGroup && !$groupChatWorkspace.get()) { + $groupChatWorkspace.set(previousGroup) + } + syncBotsHomeWorkspace() + host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the gateway'}`) + } + + return false + } + + if (generation !== botOpenGeneration) { + return false + } + + try { + const registryId = await openBotCanonicalChat(bot.name) + + if (generation !== botOpenGeneration) { + return false + } + + if (registryId) { + // This is not an identity preference: opening already completed through + // the name registry. Keep only enough ephemeral state to release the + // home if another tab later claims the center. + $openBotChat.set({ key, openedRegistryId: String(registryId) }) + closeBotsHomeWorkspace() + return true + } + } catch (error) { + if (generation === botOpenGeneration) { + $openBotChat.set(null) + if (previousGroup && !$groupChatWorkspace.get()) { + $groupChatWorkspace.set(previousGroup) + } + syncBotsHomeWorkspace() + host.notifyError?.(error, `Could not open ${displayName(bot, meta)}'s chat — try again`) + } + + return false + } + + // An older Desktop without the profile-scoped draft API has no safe fallback: + // do not navigate the current workspace or create a draft on the wrong owner. + if (typeof host.newChat !== 'function') { + $openBotChat.set(null) + if (previousGroup && !$groupChatWorkspace.get()) { + $groupChatWorkspace.set(previousGroup) + } + syncBotsHomeWorkspace() + return false + } + + $openBotChat.set({ key, openedRegistryId: '' }) + closeBotsHomeWorkspace() + host.newChat(bot.name) + return true +} + function displayName(bot, meta) { // A configured alias route claiming this row overrides source-derived // identity: the friendly alias name must survive hosted-session @@ -5205,18 +5457,183 @@ function filterBots(roster, metaByName, query) { } return roster.filter(bot => { - const display = displayName(bot, botRosterMeta(bot, metaByName)).toLowerCase() + const meta = botRosterMeta(bot, metaByName) + const display = displayName(bot, meta).toLowerCase() const profile = (bot.name || '').toLowerCase() const handle = botHandle(bot.name, bot).toLowerCase() // Multi-source rows also match on their device name ("homelab" finds // every bot living on the Homelab connection). const sourceLabel = (bot.connectionLabel || '').toLowerCase() + const role = `${meta?.description || ''} ${bot.description || ''}`.toLowerCase() + const preview = String(botActivitySession(bot)?.preview || '').toLowerCase() return ( - display.includes(needle) || profile.includes(needle) || handle.includes(needle) || sourceLabel.includes(needle) + display.includes(needle) || + profile.includes(needle) || + handle.includes(needle) || + sourceLabel.includes(needle) || + role.includes(needle) || + preview.includes(needle) ) }) } +function filterBotsByGateway(roster, connectionId) { + if (!connectionId || connectionId === 'all') { + return roster + } + + return (roster || []).filter(bot => String(bot?.connectionId || '') === connectionId) +} + +function botNeedsHandleLabel(bot, roster, metaByName) { + const identity = displayName(bot, botRosterMeta(bot, metaByName)).trim().toLowerCase() + const connectionId = String(bot?.connectionId || '') + + return (roster || []).some( + candidate => + botRosterKey(candidate) !== botRosterKey(bot) && + String(candidate?.connectionId || '') === connectionId && + displayName(candidate, botRosterMeta(candidate, metaByName)).trim().toLowerCase() === identity && + botHandle(candidate.name, candidate) !== botHandle(bot.name, bot) + ) +} + +function groupMatchesRosterFilters(name, members, metaByName, query, connectionId) { + const inGateway = filterBotsByGateway(members, connectionId) + + if (connectionId && connectionId !== 'all' && inGateway.length === 0) { + return false + } + + const needle = String(query || '').trim().toLowerCase().replace(/^@/, '') + + return !needle || String(name || '').toLowerCase().includes(needle) || filterBots(inGateway, metaByName, needle).length > 0 +} + +function rosterGatewayOptions(sources, roster) { + const byId = new Map() + + for (const source of Array.isArray(sources) ? sources : []) { + const id = String(source?.connectionId || '').trim() + + if (id) { + byId.set(id, { ...source, connectionId: id, count: 0 }) + } + } + + for (const bot of roster || []) { + const id = String(bot?.connectionId || '').trim() + + if (!id) { + continue + } + + const source = byId.get(id) || { + connectionId: id, + kind: bot.connectionKind, + label: bot.connectionLabel || id, + reachable: bot.sourceReachable, + error: bot.sourceError, + count: 0 + } + source.count += 1 + byId.set(id, source) + } + + return [...byId.values()].sort((a, b) => + String(a.label || a.connectionId).localeCompare(String(b.label || b.connectionId), undefined, { + sensitivity: 'base' + }) + ) +} + +function rosterGatewaySections(botRows, gatewayOptions, gatewayFilter = 'all') { + const rows = Array.isArray(botRows) ? botRows : [] + const options = Array.isArray(gatewayOptions) ? gatewayOptions : [] + + if (gatewayFilter !== 'all' || options.length <= 1) { + return { sectioned: false, sections: [{ id: 'all', option: null, rows }] } + } + + const byId = new Map() + + for (const row of rows) { + const bot = row?.bot || row + const id = String(bot?.connectionId || 'legacy').trim() || 'legacy' + const bucket = byId.get(id) || [] + bucket.push(row) + byId.set(id, bucket) + } + + const known = new Set() + const sections = [] + + for (const option of options) { + const id = String(option?.connectionId || '').trim() + const sectionRows = byId.get(id) + + if (!id || !sectionRows?.length) { + continue + } + + known.add(id) + sections.push({ id, option, rows: sectionRows }) + } + + for (const [id, sectionRows] of byId) { + if (known.has(id)) { + continue + } + + const bot = sectionRows[0]?.bot || sectionRows[0] + sections.push({ + id, + option: { + connectionId: id, + kind: bot?.connectionKind || 'remote', + label: bot?.connectionLabel || (id === 'legacy' ? 'Current gateway' : id), + reachable: bot?.sourceReachable, + error: bot?.sourceError + }, + rows: sectionRows + }) + } + + return { sectioned: true, sections } +} + +function gatewayKindIcon(kind) { + const icons = (typeof sdk === 'undefined' ? null : sdk.icons) || {} + + if (kind === 'local') return icons.Monitor + if (kind === 'cloud') return icons.Cloud + if (kind === 'ssh') return icons.Terminal + return icons.Network +} + +function gatewayKindCodicon(kind) { + if (kind === 'local') return 'device-desktop' + if (kind === 'cloud') return 'cloud' + if (kind === 'ssh') return 'terminal' + return 'remote-explorer' +} + +/** Match the gateway switcher's Tabler glyphs while keeping older SDK shells + * usable until they expose the shared icon namespace. */ +function GatewayKindGlyph({ className, kind }) { + const Icon = gatewayKindIcon(kind) + + return jsx('span', { + 'aria-hidden': true, + className: cn('grid size-3.5 shrink-0 place-items-center', className), + 'data-connection-kind': kind || 'remote', + 'data-slot': 'connection-glyph', + children: Icon + ? jsx(Icon, { className: 'size-3' }) + : jsx(Codicon, { name: gatewayKindCodicon(kind), className: 'text-[0.75rem]' }) + }) +} + function slugify(value) { return value .toLowerCase() @@ -5338,7 +5755,10 @@ function groupChatMemberBots(group, roster, metaByName) { } seated.add(key) - remote.push((roster || []).find(bot => botRosterKey(bot) === key) || descriptor) + // A selected-but-offline ghost intentionally carries only enough identity + // to paint the roster. Never let it replace the room's durable descriptor, + // which owns the full handle/title used by mentions and remote sync. + remote.push((roster || []).find(bot => !bot?.ghost && botRosterKey(bot) === key) || descriptor) } return [...local, ...remote] @@ -6807,6 +7227,8 @@ function generatedSessionTitle(session, preview) { /** Roster liveness window: a bot whose last message landed within this many * seconds is treated as "active now" (pulsing dot in its row). */ const ACTIVE_WINDOW_S = 90 +const RECENT_ACTIVITY_WINDOW_S = 7 * 24 * 60 * 60 +const BOT_ROSTER_SEARCH_THRESHOLD = 8 /** The session whose activity best represents this bot — the FRESHER of the * canonical Bot Chat (canonical_session, the profile's "Bot Chat" registry @@ -6860,13 +7282,54 @@ function activeBots(roster, activeProfile, gatewayState, now = Date.now()) { }) } +function rosterActivityMatches(row, filter, now = Date.now()) { + if (!filter || filter === 'all') { + return true + } + + if (filter === 'active') { + return Boolean(row?.active) + } + + const activity = Number(row?.activity || 0) + const recent = Boolean(activity && now - activity <= RECENT_ACTIVITY_WINDOW_S * 1000) + + return filter === 'recent' ? recent : !recent +} + +function botRowOwnsWorkspace( + bot, + activeGroup, + botChatFocused, + botsHomeFronted, + focusedOwner, + selectedRosterKey +) { + if (activeGroup) { + return false + } + + if (botsHomeFronted || !botChatFocused) { + return selectedRosterKey === botSelectionKey(bot) + } + + return isActiveRosterBot(bot, focusedOwner) +} + // ── bot row ────────────────────────────────────────────────────────────────── -function BotRow({ bot, onDelete, onEdit, onGroup }) { +function BotRow({ bot, onDelete, onEdit, onGroup, showHandle }) { const activeProfile = useValue(host.state.profile) const focusedOwner = focusedRosterOwner(useValue($focusedBotOwner)) + const selectedRosterKey = useValue($selectedRosterKey) + const botChatFocused = useValue($botChatFocused) + const botsHomeFronted = useValue($botsHomeFronted) const activeGroup = useValue($groupChatWorkspace) - const meta = botRosterMeta(bot, useValue($botMeta)) + const allMeta = useValue($botMeta) + const meta = botRosterMeta(bot, allMeta) + const hidden = isBotHidden(bot, allMeta) + const pinned = isBotPinned(bot, allMeta) + const sourceStatus = botSourceStatus(bot) const groups = botGroups(meta) const last = bot.last_session // Highlight follows the chat on screen (focused session's owner), not the @@ -6875,7 +7338,19 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { // A selected group chat suppresses every bot-row highlight: the group row // owns the selection then (#88979). const activeConnectionId = String(host.state.connectionId?.get?.() || 'local').trim() - const isActive = !activeGroup && isActiveRosterBot(bot, focusedOwner) + // The highlight follows whoever owns the MAIN workspace. While a chat owns + // it, that chat's profile wins (a stale roster click must not key the + // highlight to a bot you are not reading). While the Bots home owns it, the + // source-qualified selection is the owner — and it is the only rule that + // can highlight a remote row, which has no focusable local chat. + const isActive = botRowOwnsWorkspace( + bot, + activeGroup, + botChatFocused, + botsHomeFronted, + focusedOwner, + selectedRosterKey + ) // Turn-busy is a SOCKET fact: only the gateway-home profile can be mid-turn. const isGatewayHome = !bot.remoteSource && bot.name === activeProfile && isActiveRosterBot(bot, { name: activeProfile, connectionId: activeConnectionId }) @@ -6891,19 +7366,13 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { // last_session alone shows "6d ago" on a bot you just messaged. const previewSession = bot.canonical_session || last const activitySession = botActivitySession(bot) - // A live kanban/tool worker counts as activity (#90268): pulse + fresh - // age while it runs, falling back to chat activity when it ends. + // A live kanban/tool worker counts as activity (#90268): fresh age while it + // runs, falling back to chat activity when it ends. const workerActive = workerActiveAt(bot) - const activeNow = - workerActive || - Boolean(activitySession?.last_active && Date.now() / 1000 - activitySession.last_active < ACTIVE_WINDOW_S) const rowAgeTs = workerActive ? Math.max(activitySession?.last_active || 0, bot.worker_session?.last_active || 0) : activitySession?.last_active || 0 - // Work pose only when this bot is actually doing something: the active - // profile while the gateway is busy, or a bot that wrote within the - // liveness window. Not every bot whenever the gateway is busy. - const botMood = (isGatewayHome && gatewayState === 'busy') || activeNow ? 'work' : 'idle' + const botMood = workerActive || (isGatewayHome && gatewayState === 'busy') ? 'work' : 'idle' // Subscribe on every render. A source switch turns the same keyed row from // thin to rich; conditionally calling useValue here breaks React hook order. const unreadByName = useValue($botUnread) @@ -6919,8 +7388,14 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { const displayPreview = stripPreviewMarkdown( fromBot ? (previewSession?.preview || '').replace(A2A_PREFIX_RE, '').trim() || '…' - : previewSession?.preview || bot.description || 'No conversations yet — say hi' + : previewSession?.preview || '' ) + const handle = botHandle(bot.name, bot) + const gatewayLabel = bot.connectionLabel || (bot.connectionId === 'local' ? 'This device' : '') + const showDetailsRow = Boolean(showHandle || displayPreview || fromBot) + const rowTooltip = [displayName(bot, meta), `@${handle}`, gatewayLabel, sourceStatus.label] + .filter(Boolean) + .join(' · ') const warm = () => { // Multi-source row: pre-dial the agent's OWN source (feature-detected). @@ -6945,61 +7420,9 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { } } - const open = async () => { - const generation = ++botOpenGeneration - haptic('tap') - $groupChatWorkspace.set(null) - $selectedBot.set(botSelectionKey(bot)) - - if ($botUnread.get()[botSelectionKey(bot)]) { - const next = { ...$botUnread.get() } - delete next[botSelectionKey(bot)] - $botUnread.set(next) - } - - // Refresh through the immutable owner route. Foreground activation is - // presentation state and is never routing authority for this open. - try { - await prepareBotSource(bot) - } catch (error) { - host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`) - - return - } - - if (generation !== botOpenGeneration) { - return - } - - try { - // Identity is the NAMED registry row (profile → session titled - // "Bot Chat") on the bot's own source, resolved fresh on every click — - // preview identity and click identity agree because both describe that - // same row (#88200). - const id = await openBotCanonicalChat(bot) - - if (generation === botOpenGeneration && id) { - return - } - } catch (error) { - if (generation === botOpenGeneration) { - host.notifyError?.(error, `Could not open ${displayName(bot, meta)}'s chat — try again`) - } - - return - } - - if (generation !== botOpenGeneration) { - return - } - - if (typeof host.newChat === 'function') { - // Older gateway without profile-scoped session.create — plain draft. - newBotChat(bot) - } else { - host.navigate('/') - } - } + // Rows and Active Now share the exact-owner open path; only that path may + // activate a source and resolve the canonical Bot Chat. + const open = () => void openRosterBot(bot) const row = jsxs('button', { type: 'button', @@ -7008,15 +7431,20 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { className: cn( 'flex w-full min-w-0 max-w-full items-center gap-2.5 overflow-hidden rounded-md px-2 py-2 text-left transition-colors', 'hover:bg-(--chrome-action-hover)', - isActive && 'bg-(--chrome-action-hover)', - // Hidden bots only render while the header eye toggle is on — dimmed, - // so the temporary reveal reads as a different state from the roster. - meta?.hidden && 'opacity-60' + isActive && 'bg-(--ui-row-active-background)' ), + 'aria-label': rowTooltip, children: [ jsx('div', { - className: 'shrink-0', - children: jsx(BotFace, { shape, color, image: photo ? image : null, size: 34, name: bot.name, mood: botMood }) + className: cn('shrink-0', !sourceStatus.available && 'grayscale opacity-60'), + children: jsx(BotFace, { + shape, + color, + image: photo ? image : null, + size: 34, + name: bot.name, + mood: botMood + }) }), jsxs('div', { className: 'min-w-0 flex-1', @@ -7025,57 +7453,41 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { className: 'flex items-baseline justify-between gap-2', children: [ jsxs('div', { - className: 'flex min-w-0 items-baseline gap-1.5 truncate', + className: 'flex min-w-0 items-center gap-1.5', children: [ - meta?.pinned - ? jsx('span', { - className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)', - title: 'Pinned', - children: '📌' + pinned + ? jsx(Tip, { + label: 'Pinned', + children: jsx(Codicon, { + name: 'pinned', + className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)' + }) }) : null, - meta?.hidden - ? jsx(Codicon, { - name: 'eye-closed', - className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)', - title: 'Hidden from the roster' + hidden + ? jsx(Tip, { + label: 'Hidden from the roster', + children: jsx(Codicon, { + name: 'eye-closed', + className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)' + }) }) : null, - jsx('span', { - className: cn( - 'truncate text-[0.8125rem] font-medium', - bot.remoteSource && 'max-w-[42%] shrink-0' - ), - children: displayName(bot, meta) + jsx(Tip, { + label: rowTooltip, + children: jsx('span', { + className: 'min-w-0 truncate text-[0.8125rem] font-medium', + children: displayName(bot, meta) + }) }), - showsHandle(bot.name, meta, bot) - ? jsx('span', { - className: 'min-w-0 truncate font-mono text-[0.6875rem] text-(--ui-text-quaternary)', - children: `@${botHandle(bot.name, bot)}` - }) - : null, - bot.remoteSource - ? jsx('span', { - className: - 'max-w-[28%] shrink-0 truncate rounded bg-(--chrome-action-hover) px-1 font-mono text-[0.625rem] text-(--ui-text-tertiary)', - title: `Lives on ${bot.connectionLabel}`, - children: bot.connectionLabel - }) - : null ] }), unread ? jsx('span', { - className: 'size-2 shrink-0 rounded-full bg-(--ui-accent,#4f9cf9)', + className: 'size-2 shrink-0 rounded-full bg-(--ui-accent)', 'aria-label': 'unread' }) : null, - activeNow - ? jsx('span', { - className: 'hermes-bots-pulse size-1.5 shrink-0 rounded-full bg-(--ui-accent,#4f9cf9)', - title: workerActive ? 'Working on a task right now' : 'Active in the last 90s' - }) - : null, rowAgeTs ? jsx('span', { className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)', @@ -7084,35 +7496,38 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { : null ] }), - jsxs('div', { - className: 'flex min-w-0 items-center gap-1', - children: [ - jsx('div', { - className: fromBot - ? 'min-w-0 truncate text-xs italic text-(--ui-accent,#4f9cf9)' - : 'min-w-0 truncate text-xs text-(--ui-text-tertiary)', - children: displayPreview - }), - fromBot - ? jsxs('span', { - className: - 'flex shrink-0 items-center gap-1 rounded-full bg-(--chrome-action-hover) px-1.5 py-px text-[0.625rem] font-medium text-(--ui-accent,#4f9cf9)', - title: `Last message came from @${fromBot} (bot-to-bot)`, - children: ['🤖', `@${fromBot}`] - }) - : null - ] - }) - ] - }) - ] - }) - - return jsxs(ContextMenu, { - children: [ - jsx(ContextMenuTrigger, { asChild: true, children: row }), - jsxs(ContextMenuContent, { - children: [ + showDetailsRow + ? jsxs('div', { + className: 'flex min-w-0 items-center gap-1.5 text-xs text-(--ui-text-tertiary)', + children: [ + showHandle + ? jsx('span', { + className: 'shrink-0 font-mono text-[0.6875rem] text-(--ui-text-quaternary)', + children: `@${handle}` + }) + : null, + showHandle && displayPreview + ? jsx('span', { className: 'shrink-0 text-(--ui-text-quaternary)', children: '·' }) + : null, + displayPreview + ? jsx('span', { + className: cn('min-w-0 truncate', fromBot && 'italic'), + children: displayPreview + }) + : null + ] + }) + : null + ] + }) + ] + }) + + return jsxs(ContextMenu, { + children: [ + jsx(ContextMenuTrigger, { asChild: true, children: row }), + jsxs(ContextMenuContent, { + children: [ jsx(ContextMenuItem, { onSelect: () => { void ensureBotMetadata(bot).then(current => { @@ -7124,7 +7539,7 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { }) }).catch(error => host.notifyError?.(error, 'Could not load bot metadata')) }, - children: meta?.pinned ? 'Unpin' : 'Pin to top' + children: pinned ? 'Unpin' : 'Pin to top' }), jsx(ContextMenuItem, { onSelect: () => { @@ -7144,7 +7559,7 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { }) }).catch(error => host.notifyError?.(error, 'Could not load bot metadata')) }, - children: meta?.hidden ? 'Unhide Bot' : 'Hide Bot' + children: hidden ? 'Unhide' : 'Hide' }), jsx(ContextMenuSeparator, {}), jsx(ContextMenuItem, { @@ -7366,7 +7781,7 @@ function ModelPicker({ bot = null, value, onChange, placeholderModel = 'gateway // ── advanced profile config (skills / toolsets / model / SOUL) ────────────── // -// Shared by Edit Profile and New Agent (edit mode only for skills/toolsets — +// Shared by Edit Profile and New Bot (edit mode only for skills/toolsets — // a not-yet-created profile has nothing installed to toggle). Backed by // profiles.describe / profiles.configure; feature-detects older gateways. @@ -8552,8 +8967,8 @@ function CreateAgentDialog({ open, onClose, roster }) { host.notify({ kind: 'success', message: remoteTarget - ? `Agent "${displayName({ name: slug, title })}" created on ${targetLabel}` - : `Agent "${displayName({ name: slug, title })}" created` + ? `Bot "${displayName({ name: slug, title })}" created on ${targetLabel}` + : `Bot "${displayName({ name: slug, title })}" created` }) const wasRemote = remoteTarget reset() @@ -8611,7 +9026,7 @@ function CreateAgentDialog({ open, onClose, roster }) { children: [ jsxs(DialogHeader, { children: [ - jsx(DialogTitle, { children: 'New Agent' }), + jsx(DialogTitle, { children: 'New Bot' }), jsx(DialogDescription, { children: 'A named teammate with its own memory, skills, and chat. It can message your other agents.' }) @@ -8875,7 +9290,7 @@ function CreateAgentDialog({ open, onClose, roster }) { className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)', children: taken ? 'That name is taken — pick another before configuring capabilities.' - : 'Name the agent first — a draft profile is created when you open this tab (discarded if you cancel).' + : 'Name the bot first — a draft profile is created when you open this tab (discarded if you cancel).' }) : !createdForCaps ? jsx('div', { @@ -9088,7 +9503,7 @@ function CreateAgentDialog({ open, onClose, roster }) { jsx(Button, { disabled: busy || !valid || taken, onClick: submit, - children: busy ? 'Creating…' : 'Create Agent' + children: busy ? 'Creating…' : 'Create Bot' }) ] }) @@ -9978,29 +10393,36 @@ function ActiveNowStrip({ roster, activeProfile, gatewayState, metaByName, onOpe const photo = Boolean(image && !isBackfilledFacePng(image)) const label = displayName(bot, meta) - return jsx('button', { - type: 'button', - title: `Open ${label}'s chat`, - className: cn( - 'flex items-center gap-1.5 rounded-md bg-(--chrome-action-hover) px-1.5 py-1 text-left transition-colors', - 'hover:bg-(--chrome-action-hover) hover:text-foreground' - ), - onClick: () => onOpen(bot), - children: [ - jsx(BotFace, { - shape, - color, - image: photo ? image : null, - size: 24, - name: bot.name, - mood: 'work' - }), - jsx('span', { - className: 'max-w-28 truncate text-xs font-medium', - children: label + return jsx( + Tip, + { + label: `Open ${label}'s chat`, + children: jsx('button', { + type: 'button', + 'aria-label': `Open ${label}'s chat`, + className: cn( + 'flex items-center gap-1.5 rounded-md bg-(--chrome-action-hover) px-1.5 py-1 text-left transition-colors', + 'hover:bg-(--chrome-action-hover) hover:text-foreground' + ), + onClick: () => onOpen(bot), + children: [ + jsx(BotFace, { + shape, + color, + image: photo ? image : null, + size: 24, + name: bot.name, + mood: 'work' + }), + jsx('span', { + className: 'max-w-28 truncate text-xs font-medium', + children: label + }) + ] }) - ] - }, botRosterKey(bot)) + }, + botRosterKey(bot) + ) }) ] }) @@ -10288,8 +10710,11 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) { } }, [open]) - const selected = roster.filter(bot => checked[botRosterKey(bot)]) - const visible = filterBots(roster, allMeta, query) + // An outage placeholder preserves one selected owner's identity in the + // sidebar, but it is not a routable room member. Never offer it here. + const selectableRoster = roster.filter(bot => !bot?.ghost) + const selected = selectableRoster.filter(bot => checked[botRosterKey(bot)]) + const visible = filterBots(selectableRoster, allMeta, query) const atCap = selected.length >= GROUP_CHAT_MAX_MEMBERS const placeholder = selected.length ? selected.map(bot => displayName(bot, botRosterMeta(bot, allMeta))).join(', ') @@ -10439,7 +10864,7 @@ function CreateGroupChatDialog({ open, roster, onClose, onCreated }) { }) : jsx('div', { className: 'px-1.5 py-3 text-center text-xs text-(--ui-text-tertiary)', - children: query.trim() ? `No bots match “${query.trim()}”` : 'No bots yet — create agents first.' + children: query.trim() ? `No bots match “${query.trim()}”` : 'No bots yet — create one first.' }) }) }), @@ -11012,6 +11437,9 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { const roomClarifies = Object.values(clarifyAll || {}) .filter(entry => entry?.group === group) .sort((a, b) => (a.at || 0) - (b.at || 0)) + const availableMembers = members.filter(member => botSourceStatus(member).available).length + const availabilityLabel = `${availableMembers} of ${members.length} available` + const memberNames = members.map(b => displayName(b, botRosterMeta(b, allMeta))).join(', ') || 'No bots in this group chat' const header = jsxs('div', { className: 'flex items-center gap-2 px-2.5 pt-2.5 pb-2', @@ -11027,48 +11455,49 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { ? jsx('img', { src: room.image, alt: '', - className: 'size-6 shrink-0 rounded-full object-cover ring-1 ring-(--ui-stroke-secondary)' + className: 'size-6 shrink-0 rounded-md object-cover ring-1 ring-(--ui-stroke-secondary)' }) - : null, + : jsx('span', { + className: + 'flex size-6 shrink-0 items-center justify-center rounded-md bg-(--chrome-action-hover) text-(--ui-text-tertiary)', + children: jsx(Codicon, { name: 'organization' }) + }), jsx('div', { className: 'min-w-0 flex-1 truncate text-sm font-semibold', - children: `${group} — group chat` + children: group }), - // Member faces: the room's roster at a glance, matching each bot's - // avatar in the sidebar. Falls back to the count for the title tooltip. - jsx('div', { - className: 'flex shrink-0 items-center -space-x-1.5', - title: members.map(b => displayName(b, botRosterMeta(b, allMeta))).join(', '), - children: members.slice(0, 6).map(b => { - const bMeta = botRosterMeta(b, allMeta) - const { shape, color, image } = botAppearance(b.name, bMeta) - const photo = Boolean(image && !isBackfilledFacePng(image)) - - return jsx('div', { - className: 'rounded-full ring-2 ring-(--ui-bg-primary,#111)', - children: jsx(BotFace, { shape, color, image: photo ? image : null, size: 20, name: b.name }) - }, botRosterKey(b)) + jsx(Tip, { + label: memberNames, + children: jsx('span', { + className: cn( + 'shrink-0 text-[0.65rem] text-(--ui-text-quaternary)', + members.length > 0 && availableMembers < members.length && 'text-amber-600 dark:text-amber-300' + ), + 'aria-label': availabilityLabel, + children: members.length > 0 && availableMembers < members.length ? availabilityLabel : `${members.length} bots` }) }), - jsx('span', { - className: 'shrink-0 text-[0.65rem] text-(--ui-text-quaternary)', - children: `${members.length} bots` - }), - jsx(Button, { - variant: 'ghost', - size: 'sm', - className: 'shrink-0 text-(--ui-text-tertiary) hover:text-foreground', - title: `Group settings — rename ${group} or set a room picture`, - onClick: () => setSettingsOpen(true), - children: jsx(Codicon, { name: 'gear' }) + jsx(Tip, { + label: `Group settings — rename ${group} or set a room picture`, + children: jsx(Button, { + variant: 'ghost', + size: 'sm', + className: 'shrink-0 text-(--ui-text-tertiary) hover:text-foreground', + 'aria-label': `Group settings for ${group}`, + onClick: () => setSettingsOpen(true), + children: jsx(Codicon, { name: 'gear' }) + }) }), - jsx(Button, { - variant: 'ghost', - size: 'sm', - className: 'shrink-0 text-(--ui-text-tertiary) hover:text-destructive', - title: `Disband the ${group} group chat`, - onClick: () => setConfirmDisband(true), - children: jsx(Codicon, { name: 'trash' }) + jsx(Tip, { + label: `Disband the ${group} group chat`, + children: jsx(Button, { + variant: 'ghost', + size: 'sm', + className: 'shrink-0 text-(--ui-text-tertiary) hover:text-destructive', + 'aria-label': `Disband ${group}`, + onClick: () => setConfirmDisband(true), + children: jsx(Codicon, { name: 'trash' }) + }) }) ] }) @@ -11673,6 +12102,402 @@ function closeGroupChatMainTab(group) { } } +function selectedRosterBot(roster, key) { + return (Array.isArray(roster) ? roster : []).find(bot => botRosterKey(bot) === key) || null +} + +/** A selected owner whose roster row is absent because its SOURCE is down — + * not because the bot is gone. Identity comes from the key itself, so the + * selection survives a relaunch with that gateway offline and reconciles + * onto the live row (same key) when it returns, without duplicating it. + * + * Returns null when the selection is provably invalid instead: a reachable + * source that no longer lists the bot, or a source that left the registry + * while other sources are live. Unknown (no sources yet) is NOT proof. */ +function ghostRosterOwner(key, sources) { + const { connectionId, name } = parseRosterKey(key) + + if (!name) { + return null + } + + const list = Array.isArray(sources) ? sources : [] + const source = sourceByConnection(list).get(connectionId) + + if (source ? source.reachable === true : list.length > 0) { + return null + } + + return { + name, + connectionId, + ghost: true, + remoteSource: connectionId !== 'local', + connectionKind: source?.kind, + connectionLabel: source?.label, + sourceError: source?.error || null, + sourceMissing: false, + sourceReachable: false + } +} + +/** Keep the exact selected owner visible through a cold-start outage without + * persisting the whole remote roster. The source registry supplies the + * gateway identity/status; the source-qualified selection supplies the bot + * identity. Once that source answers again, the live row replaces the ghost + * (or reconciliation clears it when the bot was actually removed). */ +function rosterWithSelectedOwner(roster, sources, key) { + const rows = Array.isArray(roster) ? roster : [] + + if (!key || selectedRosterBot(rows, key)) { + return rows + } + + const ghost = ghostRosterOwner(key, sources) + + return ghost ? [...rows, ghost] : rows +} + +/** Keep the persisted selection honest against the live roster and seat a + * first selection when there is none. PRESENTATION ONLY: it never opens, + * prepares, activates, or creates anything — an unreachable owner keeps its + * selection rather than falling back onto some other gateway's bot. */ +function reconcileRosterSelection(roster, sources, metaByName) { + if (!$rosterHydrated.get() || !$selectedRosterHydrated.get()) { + return + } + + const key = $selectedRosterKey.get() + + if (key) { + if (selectedRosterBot(roster, key) || ghostRosterOwner(key, sources)) { + return + } + + clearSelectedRosterKey(key) + } + + const first = (Array.isArray(roster) ? roster : []).find( + bot => !isBotHidden(bot, metaByName) && botSourceStatus(annotateBotSource(bot, sources)).available + ) + + if (first) { + saveSelectedRosterBot(first) + } +} + +function BotsHomeView() { + const roster = useValue($lastRoster) + const sources = useValue($lastSources) + const selectedKey = useValue($selectedRosterKey) + const rosterHydrated = useValue($rosterHydrated) + const selectionHydrated = useValue($selectedRosterHydrated) + const allMeta = useValue($botMeta) + const live = selectedRosterBot(roster, selectedKey) + + if (!rosterHydrated || !selectionHydrated) { + return jsx('div', { + className: 'flex h-full items-center justify-center', + 'aria-label': 'Loading bots', + children: jsx(GlyphSpinner, { spinner: 'breathe', className: 'text-(--ui-text-tertiary)' }) + }) + } + + const ghost = live ? null : ghostRosterOwner(selectedKey, sources) + const bot = live ? annotateBotSource(live, sources) : ghost + + if (!bot) { + return jsx('div', { + className: 'flex h-full items-center justify-center px-6', + children: jsx(EmptyState, { + icon: roster.length ? 'hubot' : 'add', + title: roster.length ? 'Choose a bot or group chat' : 'No bots yet', + description: roster.length ? 'Pick one from the Bots sidebar.' : 'Create your first bot from the Bots sidebar.' + }) + }) + } + + const meta = botRosterMeta(bot, allMeta) + const status = botSourceStatus(bot) + // A ghost is reconstructed from a persisted owner key while its gateway is + // offline. That proves the profile name, not its public mention handle. + const handle = bot.ghost ? '' : botHandle(bot.name, bot) + const gateway = bot.connectionLabel || (bot.connectionId === 'local' ? 'This device' : 'Hermes gateway') + const gatewayKind = bot.connectionKind || (bot.connectionId === 'local' ? 'local' : 'remote') + const { shape, color, image } = botAppearance(bot.name, meta) + const photo = image && !isBackfilledFacePng(image) ? image : null + const description = String(meta?.description || bot.description || '').trim() + const unavailable = !status.available + const sourceRemoved = status.key === 'missing' + // Retry re-polls the roster on the bot's OWN source. It never activates or + // re-routes anything: if the gateway is back, its row reappears under the + // same key and this view reconciles onto it. + const retrySource = () => { + haptic('tap') + queryClient.invalidateQueries({ queryKey: ROSTER_KEY }) + } + + return jsxs('div', { + className: 'flex h-full min-h-0 flex-col bg-background', + children: [ + jsxs('header', { + className: + 'flex min-w-0 items-center gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-3.5', + children: [ + jsx(BotFace, { shape, color, image: photo, size: 38, name: bot.name, mood: 'idle' }), + jsxs('div', { + className: 'min-w-0 flex-1', + children: [ + jsx('h1', { + className: 'truncate text-sm font-semibold text-foreground', + children: displayName(bot, meta) + }), + jsxs('div', { + className: 'flex min-w-0 items-center gap-1.5 text-xs text-(--ui-text-tertiary)', + children: [ + jsx('span', { children: 'Bot' }), + handle + ? jsx('span', { className: 'truncate font-mono', children: `· @${handle}` }) + : null + ] + }) + ] + }), + // Tip wraps ONE element (Radix asChild) — the screen-reader text + // rides inside the trigger, not beside it. + jsx(Tip, { + label: `${gateway} · ${gatewayKind} · ${status.label}`, + children: jsxs('div', { + className: 'flex max-w-[45%] items-center gap-1.5 text-xs text-(--ui-text-tertiary)', + children: [ + jsx('span', { className: 'sr-only', children: `${gateway}, ${status.label}` }), + jsx(GatewayKindGlyph, { kind: gatewayKind }), + jsx('span', { className: 'min-w-0 truncate', children: gateway }), + unavailable + ? jsx(Codicon, { + name: 'debug-disconnect', + className: 'shrink-0 text-amber-600 dark:text-amber-300', + 'aria-hidden': true + }) + : null + ] + }) + }) + ] + }), + jsx('main', { + className: 'flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 py-10', + children: jsxs('div', { + className: 'flex w-full max-w-2xl flex-col items-center text-center', + children: [ + jsx(BotFace, { shape, color, image: photo, size: 76, name: bot.name, mood: 'idle' }), + jsx('h2', { + className: 'mt-5 text-xl font-semibold text-foreground', + children: displayName(bot, meta) + }), + description + ? jsx('p', { + className: 'mt-2 max-w-xl text-sm leading-6 text-(--ui-text-tertiary)', + children: description + }) + : null, + unavailable || !bot.remoteSource + ? jsx('p', { + className: cn( + 'mt-4 max-w-lg text-xs leading-5', + unavailable ? 'text-amber-700 dark:text-amber-300' : 'text-(--ui-text-tertiary)' + ), + children: unavailable + ? sourceRemoved + ? `${gateway} was removed. Choose another bot from the sidebar.` + : `${gateway} is unavailable. Retry when it is back online.` + : 'Open this bot’s continuous chat. Its background work keeps running when you switch away.' + }) + : jsx('p', { + className: 'mt-4 max-w-lg text-xs leading-5 text-(--ui-text-tertiary)', + children: `This bot lives on ${gateway}. Mention it from any Bot Chat to send it a message.` + }), + unavailable && !sourceRemoved + ? jsx(Button, { + variant: 'secondary', + size: 'sm', + className: 'mt-5', + onClick: retrySource, + children: 'Retry' + }) + : bot.remoteSource + ? null + : jsx(Button, { + variant: 'secondary', + size: 'sm', + className: 'mt-5', + onClick: () => void openRosterBot(bot), + children: 'Open chat' + }) + ] + }) + }) + ] + }) +} + +function closeBotsHomeWorkspace() { + if (typeof botsHomeClose !== 'function') { + return + } + + const close = botsHomeClose + botsHomeClose = null + suppressBotsHomeReopen = true + + try { + close() + } catch { + /* workspace already closed */ + } finally { + suppressBotsHomeReopen = false + } +} + +/** The Bot home needs BOTH the main-area door and pane visibility to behave. + * Older shells keep their previous surfaces untouched (no home at all). */ +function botsHomeEnabled() { + return typeof host.openWorkspace === 'function' && typeof host.paneVisibility === 'function' +} + +/** True when a session owns the main workspace. Prefers the focused STORED + * session (tab focus moves without swapping the gateway socket); bare test + * harnesses with neither atom drive $botChatFocused directly. */ +function sessionOwnsWorkspace() { + const focused = host.state?.focusedStoredSessionId?.get?.() + + if (focused !== undefined) { + return Boolean(focused) + } + + const active = host.state?.activeSessionId?.get?.() + + return active === undefined ? $botChatFocused.get() : Boolean(active) +} + +/** The home tab currently holds the center's active tab slot. */ +function botsHomeVisible() { + if (typeof host.paneVisibility !== 'function') { + return false + } + + try { + return host.paneVisibility(BOTS_HOME_PANE_ID).get() === true + } catch { + return false + } +} + +/** A real bot chat owns the center. Cronjobs are BOT-scoped, so this — not + * mere Bot Mode visibility — is what may seat the Cronjobs tile: beside the + * ownerless home or a group chat it would describe whichever profile the + * socket happens to be homed on. While the home tab is fronted the chat is + * a hidden sibling layer, so the focused session does NOT count. */ +function botChatOwnsWorkspace() { + return ( + $botsPaneVisible.get() && + !$groupChatWorkspace.get() && + !botsHomeVisible() && + Boolean($openBotChat.get() || sessionOwnsWorkspace()) + ) +} + +/** May the home OPEN right now? `explicit` is a user gesture aimed at the + * home itself (selecting a remote/unavailable owner): it overrides the + * focused-session veto — the veto exists so PASSIVE events (boot, restore, + * polls) never cover a chat the user left in the center. */ +function botsHomeMayOpen(explicit) { + return ( + $botsPaneVisible.get() && + !$groupChatWorkspace.get() && + !$openBotChat.get() && + (explicit || !sessionOwnsWorkspace()) + ) +} + +function openBotsHomeWorkspace(explicit = false) { + if (!botsHomeEnabled() || !botsHomeMayOpen(explicit)) { + return false + } + + // Already open and fronted: nothing to do. Already open but backgrounded + // (a persisted layout can restore the tab behind the draft): re-open to + // re-front it. Never stack a second registration — a stale disposer would + // tear down the newer one. This cannot yank the center from a tab the + // user just chose: plugin events are sparse (sidebar/group/focus edges), + // and each of those either legitimately claims the center or cleared it. + if (botsHomeClose) { + if (botsHomeVisible()) { + return true + } + + closeBotsHomeWorkspace() + } + + try { + botsHomeClose = host.openWorkspace(`${ID}:home`, { + title: 'Bots', + minWidth: '24rem', + render: () => jsx(BotsHomeView, {}), + // Closing the tab is a decision, not a glitch: drop the handle and + // leave the center alone. The home returns on the next real signal + // (Bots tab regains focus, a chat closes, a group is left). + onClose: () => { + if (!suppressBotsHomeReopen) { + botsHomeClose = null + } + } + }) + + return typeof botsHomeClose === 'function' + } catch { + botsHomeClose = null + return false + } +} + +/** Passive reconcile. Opens the home only into an ownerless center; closes + * it only when a surface with a REAL owner claims the center (bot chat, + * group chat) or Bot Mode leaves the screen. The focused-session LEVEL + * deliberately does not close an open home — the home may sit over a + * focused-but-hidden chat after an explicit selection; the chat reclaims + * the center on its focus EDGE (handleWorkspaceFocusChange). */ +function syncBotsHomeWorkspace() { + if (!$botsPaneVisible.get() || $groupChatWorkspace.get() || $openBotChat.get()) { + closeBotsHomeWorkspace() + return + } + + openBotsHomeWorkspace(false) +} + +/** An opened bot chat stops owning the center once focus leaves it (closed, + * or another session took over). Without this the home could never come + * back: $openBotChat would claim ownership for a chat nobody is reading. + * + * The legacy newChat fallback has no registry id to compare — a draft with no + * focused session is still that bot's draft, so it only yields once some + * session actually takes focus. */ +function releaseStaleOpenBotChat(focusedStoredId) { + const open = $openBotChat.get() + + if (!open) { + return + } + + const focused = focusedStoredId === null || focusedStoredId === undefined ? '' : String(focusedStoredId) + const stale = open.openedRegistryId ? focused !== open.openedRegistryId : Boolean(focused) + + if (stale) { + $openBotChat.set(null) + } +} + /** Main-window wrapper: seats the member roster reactively (live roster + * bot meta + the room's stored cross-connection descriptors) so the room * keeps working as members change while the tab is open. Also subscribes to @@ -11707,6 +12532,10 @@ function GroupChatMainView({ group }) { * write itself repaints nothing, the duplicate stuck until an unrelated * re-render. */ function openGroupChat(group) { + // A room selection supersedes any bot-open transition still hydrating. + // The in-flight host navigation may complete underneath this workspace, + // but it may not later close or visually steal the room the user chose. + botOpenGeneration += 1 $groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: false }) if (typeof host.openWorkspace === 'function') { @@ -11740,14 +12569,11 @@ function openGroupChat(group) { $groupChatWorkspace.set(group) } -/** One group chat as ONE roster row — the Discord shape: stacked member - * avatars, group name, member count, the newest room line as the preview - * (markdown flattened), relative time of the last activity, and the - * needs-you badge on the row itself. Sorts into the same recency ordering - * as bot rows; clicking opens the room in the main chat window. */ +/** One group chat as one quiet roster row. The room owns one visual identity; + * member details stay in its tooltip and workspace instead of competing + * with bot avatars in the narrow sidebar. */ function GroupRow({ active, group, members, needsYou, onOpen, onDisband }) { const rooms = useValue($groupChats) - const allMeta = useValue($botMeta) const room = rooms[group] || { log: [] } const log = Array.isArray(room.log) ? room.log : [] const last = log.length ? log[log.length - 1] : null @@ -11758,8 +12584,9 @@ function GroupRow({ active, group, members, needsYou, onOpen, onDisband }) { const lastHandle = botHandle(lastFrom || 'bot', members.find(member => member?.name === lastFrom)) const preview = last ? `${last.from?.kind === 'user' ? 'You' : `@${lastHandle}`}: ${stripPreviewMarkdown(last.text) || '…'}` - : 'No messages yet — say hi to the room' - const faces = members.slice(0, 3) + : `${members.length} bots` + const availableMembers = members.filter(member => botSourceStatus(member).available).length + const availabilityLabel = `${availableMembers} of ${members.length} available` const row = jsxs('button', { type: 'button', @@ -11772,43 +12599,39 @@ function GroupRow({ active, group, members, needsYou, onOpen, onDisband }) { 'hover:bg-(--chrome-action-hover)', active && 'bg-(--ui-row-active-background)' ), + 'aria-label': `${group}, ${members.length} bots, ${availabilityLabel}`, children: [ - // Room picture when the user set one; else a composite avatar of up to - // three member faces fanned like Discord's group-DM icon; a bare glyph - // when the room has no seated members. - jsx('div', { - className: 'flex w-[34px] shrink-0 items-center justify-center', - children: room.image - ? jsx('img', { - src: room.image, - alt: '', - className: 'size-7 rounded-full object-cover ring-2 ring-(--ui-bg-primary,#111)' - }) - : faces.length - ? jsx('div', { - className: 'flex items-center -space-x-2.5', - children: faces.map(member => { - const meta = member.remoteSource ? null : allMeta[member.name] - const { shape, color, image } = botAppearance(member.name, meta) - - return jsx( - 'div', - { - className: 'rounded-full ring-2 ring-(--ui-bg-primary,#111)', - children: jsx(BotFace, { - shape, - color, - image: image && !isBackfilledFacePng(image) ? image : null, - size: 20, - name: member.name, - mood: 'idle' - }) - }, - botRosterKey(member) + jsxs('div', { + className: 'relative flex w-[34px] shrink-0 items-center justify-center', + children: [ + room.image + ? jsx('img', { + src: room.image, + alt: '', + className: cn( + 'size-8 rounded-md object-cover ring-1 ring-(--ui-stroke-tertiary)', + availableMembers === 0 && 'grayscale opacity-60' ) }) - }) - : jsx(Codicon, { name: 'organization', className: 'text-(--ui-text-tertiary)' }) + : jsx('span', { + className: cn( + 'flex size-8 items-center justify-center rounded-md bg-(--chrome-action-hover) text-(--ui-text-tertiary)', + availableMembers === 0 && 'opacity-60' + ), + children: jsx(Codicon, { name: 'organization' }) + }), + members.length > 0 && availableMembers < members.length + ? jsx(Tip, { + label: availabilityLabel, + children: jsx('span', { + className: + 'absolute -bottom-0.5 -right-0.5 flex size-4 items-center justify-center rounded-full bg-(--ui-bg-primary) text-[0.625rem] text-amber-600 ring-1 ring-(--ui-stroke-tertiary) dark:text-amber-300', + 'aria-label': availabilityLabel, + children: jsx(Codicon, { name: 'debug-disconnect' }) + }) + }) + : null + ] }), jsxs('div', { className: 'min-w-0 flex-1', @@ -11816,22 +12639,18 @@ function GroupRow({ active, group, members, needsYou, onOpen, onDisband }) { jsxs('div', { className: 'flex items-baseline justify-between gap-2', children: [ - jsxs('div', { - className: 'flex min-w-0 items-baseline gap-1.5 truncate', - children: [ - jsx('span', { className: 'truncate text-[0.8125rem] font-medium', children: group }), - jsx('span', { - className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)', - children: `${members.length} bots` - }) - ] + jsx('span', { + className: 'min-w-0 flex-1 truncate text-[0.8125rem] font-medium', + children: group }), needsYou - ? jsx('span', { - className: - 'shrink-0 rounded-full bg-(--ui-accent,#4f9cf9) px-1.5 text-[0.6rem] font-semibold text-white', - title: 'A bot in this room needs your input', - children: 'needs you' + ? jsx(Tip, { + label: 'A bot in this group chat needs your input', + children: jsx(Codicon, { + name: 'question', + className: 'shrink-0 text-(--ui-accent)', + 'aria-label': 'Needs your input' + }) }) : null, lastAt @@ -11872,6 +12691,63 @@ function GroupRow({ active, group, members, needsYou, onOpen, onDisband }) { }) } +/** Foldable roster heading. It organizes rows visually but never supplies or + * reconstructs ownership; every action still receives the full bot row. */ +function RosterSectionHeader({ collapsed, count, gatewayKind, icon, label, onToggle, status, tip }) { + const button = jsxs('button', { + type: 'button', + 'aria-expanded': !collapsed, + className: + 'mt-1 flex w-full min-w-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary) transition-colors hover:bg-(--chrome-action-hover) hover:text-(--ui-text-secondary)', + onClick: onToggle, + children: [ + jsx(Codicon, { name: collapsed ? 'chevron-right' : 'chevron-down', className: 'shrink-0' }), + gatewayKind + ? jsx(GatewayKindGlyph, { kind: gatewayKind }) + : jsx(Codicon, { name: icon, className: 'shrink-0' }), + jsxs('span', { + className: 'flex min-w-0 items-center gap-1', + children: [ + jsx('span', { className: 'min-w-0 truncate', children: label }), + status && !status.available + ? jsx('span', { className: 'sr-only', children: status.label }) + : null + ] + }), + jsx('span', { className: 'min-w-0 flex-1', 'aria-hidden': true }), + jsx('span', { + className: 'shrink-0 font-normal tabular-nums text-(--ui-text-quaternary)', + children: count + }), + status && !status.available + ? jsx(Codicon, { + name: 'debug-disconnect', + className: 'shrink-0 text-amber-600 dark:text-amber-300', + 'aria-hidden': true + }) + : null + ] + }) + + return tip ? jsx(Tip, { label: tip, children: button }) : button +} + +function GatewaySectionHeading({ collapsed, count, onToggle, option }) { + const status = botSourceStatus({ sourceError: option?.error, sourceReachable: option?.reachable }) + const label = option?.label || option?.connectionId || 'Current gateway' + const kind = option?.kind || 'remote' + + return jsx(RosterSectionHeader, { + collapsed, + count, + gatewayKind: kind, + label, + onToggle, + status, + tip: `${label} · ${kind} · ${status.label}` + }) +} + function BotsPane() { const { data, error, isLoading, refetch } = useRoster() const gatewayState = useValue(host.state.gateway) @@ -11884,6 +12760,11 @@ function BotsPane() { const [deletingGroup, setDeletingGroup] = useState(null) const [grouping, setGrouping] = useState(null) const [query, setQuery] = useState('') + const [rowKindFilter, setRowKindFilter] = useState('all') + const [activityFilter, setActivityFilter] = useState('all') + const [gatewayFilter, setGatewayFilter] = useState('all') + const [collapsedRosterSections, setCollapsedRosterSections] = useState(() => new Set()) + const hiddenSectionRef = useRef(null) const activityToasts = useValue($activityToasts) const groupChatName = useValue($groupChatWorkspace) // Main-tab ownership is a module Map; this rev subscription makes the @@ -11893,6 +12774,10 @@ function BotsPane() { useValue($groupMainTabsRev) const groupNeedsYou = useValue($groupNeedsYou) const groupRooms = useValue($groupChats) + const rememberedSources = useValue($lastSources) + const rosterHydrated = useValue($rosterHydrated) + const selectionHydrated = useValue($selectedRosterHydrated) + const selectedRosterKey = useValue($selectedRosterKey) // The socket opening (boot, SSH reconnect, sleep/wake) is the signal to // retry immediately instead of waiting out the poll interval. @@ -11912,17 +12797,19 @@ function BotsPane() { return Math.max(created, lastMsg) } - // Pinned bots (right-click → Pin) float to the top as a group; within the - // pinned group and within the unpinned group, recency still rules. A - // plain boolean flag in bot-meta (rides ui_meta to every machine). - const isPinned = bot => Boolean(botRosterMeta(bot, allMeta)?.pinned) + // Pin is a source-qualified Desktop preference, not gateway profile state. + const isPinned = bot => isBotPinned(bot, allMeta) // Resilience (@wesleysimplicio, #13): a failed refresh must not erase a // roster the user already had — mixed local+cloud gateways and remotes // waking from sleep fail transiently. Render the last good snapshot with // a notice; the full error card is reserved for "never had a roster". const live = Array.isArray(data?.profiles) ? data.profiles : null const source = live ?? (error ? $lastRoster.get() : []) - const roster = source.slice().sort((a, b) => { + const sourceSnapshot = Array.isArray(data?.sources) ? data.sources : rememberedSources + const sourceWithSelectedOwner = selectionHydrated && rosterHydrated + ? rosterWithSelectedOwner(source, sourceSnapshot, selectedRosterKey) + : source + const roster = sourceWithSelectedOwner.slice().sort((a, b) => { const pa = isPinned(a) ? 1 : 0 const pb = isPinned(b) ? 1 : 0 @@ -11932,34 +12819,68 @@ function BotsPane() { return activityOf(b) - activityOf(a) }) + // React Query can briefly report neither loading nor data while the plugin + // and the persisted connection registry hydrate. Keep that transition in a + // neutral loading state instead of flashing the first-run "No bots" copy. + const initialRosterLoading = !data && !error && roster.length === 0 + const activeRosterKeys = new Set(activeBots(roster, activeProfile, gatewayState).map(botRosterKey)) + const gatewayOptions = rosterGatewayOptions(sourceSnapshot, roster) + const selectedGateway = gatewayOptions.find(option => option.connectionId === gatewayFilter) + const gatewayFilterExists = gatewayFilter === 'all' || Boolean(selectedGateway) + + useEffect(() => { + if (!gatewayFilterExists) { + setGatewayFilter('all') + } + }, [gatewayFilterExists]) + const activeSourceRoster = roster.filter(bot => !bot.remoteSource) - // Hidden bots (right-click → Hide Bot) drop out of the roster list unless - // the header eye toggle reveals them. Display-only: every other consumer - // (mentions, group chats, name-collision checks, merge/avatar/activity - // sweeps) keeps the FULL roster. - const showHidden = useValue($showHiddenBots) - const unreadByName = useValue($botUnread) + // Hidden rows remain fully alive and recoverable at the bottom. Every + // non-display consumer continues to receive the complete roster. + const hiddenExpanded = useValue($showHiddenBots) const hiddenBots = roster.filter(bot => isBotHidden(bot, allMeta)) - const hiddenUnread = hiddenBots.some(bot => unreadByName[botSelectionKey(bot)]) - const visibleRoster = showHidden ? roster : roster.filter(bot => !isBotHidden(bot, allMeta)) - const filteredRoster = filterBots(visibleRoster, allMeta, query) - // Group chats are first-class roster rows (Discord-style): one standalone - // row per room, competing in the SAME recency ordering as bot rows — a - // group's activity is its newest room-log line. Pinned bots still lead; - // groups and unpinned bots interleave by recency below them. - const needle = query.trim().toLowerCase() - const groupRows = groupChatNames(allMeta, groupRooms) - .filter(name => !needle || name.toLowerCase().includes(needle)) - .map(name => ({ + const visibleRoster = roster.filter(bot => !isBotHidden(bot, allMeta)) + const gatewayRoster = filterBotsByGateway(visibleRoster, gatewayFilter) + const filteredRoster = filterBots(gatewayRoster, allMeta, query).filter(bot => + rosterActivityMatches( + { activity: activityOf(bot), active: activeRosterKeys.has(botRosterKey(bot)) }, + activityFilter + ) + ) + const filteredHiddenBots = filterBots(filterBotsByGateway(hiddenBots, gatewayFilter), allMeta, query).filter(bot => + rosterActivityMatches( + { activity: activityOf(bot), active: activeRosterKeys.has(botRosterKey(bot)) }, + activityFilter + ) + ) + const groupNames = groupChatNames(allMeta, groupRooms) + const groupRows = groupNames + .map(name => ({ name, members: groupChatMemberBots(name, roster, allMeta) })) + .filter(row => groupMatchesRosterFilters(row.name, row.members, allMeta, query, gatewayFilter)) + .map(row => ({ kind: 'group', - name, - members: groupChatMemberBots(name, roster, allMeta), - activity: groupLastActivity(groupRooms[name]) + name: row.name, + members: row.members, + pinned: Boolean(groupRooms[row.name]?.pinned), + activity: groupLastActivity(groupRooms[row.name]), + active: + Boolean( + groupLastActivity(groupRooms[row.name]) && + Date.now() - groupLastActivity(groupRooms[row.name]) <= ACTIVE_WINDOW_S * 1000 + ) || row.members.some(member => activeRosterKeys.has(botRosterKey(member))) })) - const rosterRows = [ - ...filteredRoster.map(bot => ({ kind: 'bot', bot, pinned: isPinned(bot), activity: activityOf(bot) })), - ...groupRows - ].sort((a, b) => { + .filter(row => rowKindFilter !== 'bots' && rosterActivityMatches(row, activityFilter)) + const botRows = + rowKindFilter === 'groups' + ? [] + : filteredRoster.map(bot => ({ + kind: 'bot', + bot, + pinned: isPinned(bot), + activity: activityOf(bot), + active: activeRosterKeys.has(botRosterKey(bot)) + })) + const sortRosterRows = rows => rows.slice().sort((a, b) => { const pa = a.pinned ? 1 : 0 const pb = b.pinned ? 1 : 0 @@ -11969,15 +12890,90 @@ function BotsPane() { return b.activity - a.activity }) + const rosterRows = sortRosterRows([...botRows, ...groupRows]) + const sortedGroupRows = sortRosterRows(groupRows) + const gatewaySections = rosterGatewaySections(botRows, gatewayOptions, gatewayFilter) + const showGatewaySections = gatewaySections.sectioned && botRows.length > 0 + const activeFilterCount = + (rowKindFilter === 'all' ? 0 : 1) + + (activityFilter === 'all' ? 0 : 1) + + (gatewayFilter === 'all' ? 0 : 1) + const hasRosterConstraint = Boolean(query.trim()) || activeFilterCount > 0 + const matchingHiddenBots = rowKindFilter === 'groups' ? [] : filteredHiddenBots + const showHiddenSection = hiddenBots.length > 0 && (!hasRosterConstraint || matchingHiddenBots.length > 0) + const showHiddenRows = hiddenExpanded || hasRosterConstraint + const rosterItemCount = roster.length + groupNames.length + const allBotsHidden = + !hasRosterConstraint && visibleRoster.length === 0 && groupNames.length === 0 && hiddenBots.length > 0 + const showRosterSearch = + gatewayOptions.length > 1 || rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD || Boolean(query.trim()) + const showRosterFilters = + gatewayOptions.length > 1 || + groupNames.length > 0 || + rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD || + activeFilterCount > 0 + const showRosterTools = showRosterSearch || showRosterFilters + const rosterSectionCollapsed = id => !hasRosterConstraint && collapsedRosterSections.has(id) + const hiddenGatewaySections = rosterGatewaySections( + matchingHiddenBots.map(bot => ({ kind: 'bot', bot })), + gatewayOptions, + gatewayFilter + ) + + const toggleRosterSection = id => { + setCollapsedRosterSections(previous => { + const next = new Set(previous) + + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + + return next + }) + } + + useEffect(() => { + if (!hiddenExpanded || hasRosterConstraint) { + return + } + + const frame = requestAnimationFrame(() => hiddenSectionRef.current?.scrollIntoView({ block: 'nearest' })) + + return () => cancelAnimationFrame(frame) + }, [hiddenExpanded, hasRosterConstraint]) if (live) { - $lastRoster.set(roster) + // Offline-owner ghosts belong only to this render. Shared roster state + // feeds merge caching, group membership, creation, and durable sync. + $lastRoster.set(roster.filter(row => !row?.ghost)) + if (Array.isArray(data?.sources)) { + $lastSources.set(data.sources) + } mergeServerMeta(activeSourceRoster, data?.fetchedAt || 0) pullServerAvatars(activeSourceRoster) - trackInboundActivity(activeSourceRoster) + trackInboundActivity(roster) backfillMessagingProtocol(activeSourceRoster) } + // The roster has ANSWERED once data or a terminal error exists — that, not + // row count, is what lets the home stop showing its loading state (an empty + // answer is a real answer; a pending one must not flash "No bots"). Keep the + // persisted-selection writes out of render: React may replay a render, but + // an abandoned render must never become a storage mutation. + useEffect(() => { + if (!data && !error) { + return + } + + $rosterHydrated.set(true) + + if (selectionHydrated) { + reconcileRosterSelection(roster, sourceSnapshot, allMeta) + } + }, [data, error, selectionHydrated, roster, sourceSnapshot, allMeta]) + const staleNotice = error && !live && roster.length ? 'Roster refresh failed — showing the last good list.' + (gatewayUp ? '' : ' Waiting for the gateway to reconnect…') : null @@ -11987,6 +12983,113 @@ function BotsPane() { return jsx(GroupChatWorkspace, { group: groupChatName, members: groupChatMembers }) } + const renderBotRow = (bot, keyPrefix = '') => + jsx( + BotRow, + { + bot, + onDelete: setDeleting, + onEdit: setEditing, + onGroup: setGrouping, + showHandle: botNeedsHandleLabel(bot, roster, allMeta) + }, + `${keyPrefix}${botRosterKey(bot)}` + ) + + const renderGroupRow = row => + jsx( + GroupRow, + { + active: groupChatName === row.name, + group: row.name, + members: row.members, + needsYou: Boolean(groupNeedsYou[row.name]), + onOpen: openGroupChat, + onDisband: setDeletingGroup + }, + `group:${row.name}` + ) + + const renderGatewaySection = section => { + const sectionId = `gateway:${section.id}` + const collapsed = rosterSectionCollapsed(sectionId) + + return jsxs( + 'div', + { + className: 'min-w-0', + children: [ + jsx(GatewaySectionHeading, { + collapsed, + count: section.rows.length, + onToggle: () => toggleRosterSection(sectionId), + option: section.option + }), + collapsed + ? null + : jsx('div', { + className: 'grid min-w-0 gap-0.5', + children: section.rows.map(row => renderBotRow(row.bot, `${section.id}:`)) + }) + ] + }, + sectionId + ) + } + + const renderGroupChatSection = () => { + const sectionId = 'group-chats' + const collapsed = rosterSectionCollapsed(sectionId) + + return jsxs( + 'div', + { + className: 'min-w-0', + children: [ + jsx(RosterSectionHeader, { + collapsed, + count: sortedGroupRows.length, + icon: 'organization', + label: 'Group chats', + onToggle: () => toggleRosterSection(sectionId), + tip: `${sortedGroupRows.length} global group chat${sortedGroupRows.length === 1 ? '' : 's'}` + }), + collapsed + ? null + : jsx('div', { + className: 'grid min-w-0 gap-0.5', + children: sortedGroupRows.map(renderGroupRow) + }) + ] + }, + sectionId + ) + } + + const renderHiddenGatewaySection = section => + jsxs( + 'div', + { + className: 'min-w-0', + children: [ + jsx('div', { + className: + 'flex min-w-0 items-center gap-1.5 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)', + children: [ + jsx(GatewayKindGlyph, { kind: section.option?.kind }), + jsx('span', { + className: 'min-w-0 flex-1 truncate', + children: section.option?.label || section.option?.connectionId || 'Current gateway' + }), + jsx('span', { className: 'shrink-0 font-normal tabular-nums', children: section.rows.length }) + ] + }), + ...section.rows.map(row => renderBotRow(row.bot, `hidden:${section.id}:`)) + ] + }, + `hidden-gateway:${section.id}` + ) + return jsxs('div', { className: 'flex h-full flex-col', children: [ @@ -12010,35 +13113,6 @@ function BotsPane() { children: jsx(Codicon, { name: activityToasts ? 'bell' : 'bell-slash' }) }) }), - // Eye toggle appears only once something is hidden — zero - // hidden bots means zero extra chrome. It stays visible while - // hidden rows are revealed, so Unhide is always reachable. - hiddenBots.length - ? jsx(Tip, { - label: showHidden - ? 'Hide hidden bots again' - : `Show ${hiddenBots.length} hidden bot${hiddenBots.length === 1 ? '' : 's'}`, - children: jsxs('button', { - type: 'button', - 'aria-label': showHidden ? 'Hide hidden bots' : 'Show hidden bots', - className: cn( - 'relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground', - showHidden ? 'text-foreground' : 'text-(--ui-text-tertiary)' - ), - onClick: () => $showHiddenBots.set(!showHidden), - children: [ - jsx(Codicon, { name: showHidden ? 'eye' : 'eye-closed' }), - hiddenUnread && !showHidden - ? jsx('span', { - className: - 'absolute right-0.5 top-0.5 size-1.5 rounded-full bg-(--ui-accent,#4f9cf9)', - 'aria-label': 'a hidden bot has unread activity' - }) - : null - ] - }) - }) - : null, jsxs(DropdownMenu, { children: [ jsx(Tip, { @@ -12047,7 +13121,7 @@ function BotsPane() { asChild: true, children: jsx('button', { type: 'button', - 'aria-label': 'New agent or group chat', + 'aria-label': 'New bot or group chat', className: 'flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground', children: jsx(Codicon, { name: 'add' }) @@ -12059,7 +13133,7 @@ function BotsPane() { children: [ jsxs(DropdownMenuItem, { onSelect: () => setCreateOpen(true), - children: [jsx(Codicon, { name: 'hubot', className: 'mr-1.5' }), 'New Agent'] + children: [jsx(Codicon, { name: 'hubot', className: 'mr-1.5' }), 'New Bot'] }), jsxs(DropdownMenuItem, { disabled: activeSourceRoster.length < 2, @@ -12079,67 +13153,147 @@ function BotsPane() { activeProfile, gatewayState, metaByName: allMeta, - onOpen: bot => { - const generation = ++botOpenGeneration - haptic('tap') - $selectedBot.set(botSelectionKey(bot)) - - if ($botUnread.get()[botSelectionKey(bot)]) { - const next = { ...$botUnread.get() } - delete next[botSelectionKey(bot)] - $botUnread.set(next) - } - - void (async () => { - try { - await prepareBotSource(bot) - } catch (error) { - host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`) - - return - } - - if (generation !== botOpenGeneration) { - return - } - - try { - const id = await openBotCanonicalChat(bot) - - if (generation === botOpenGeneration && id) { - return - } - } catch (error) { - if (generation === botOpenGeneration) { - host.notifyError?.(error, `Could not open ${displayName(bot)}'s chat — try again`) - } - - return - } - - if (generation !== botOpenGeneration) { - return - } - - if (typeof host.newChat === 'function') { - newBotChat(bot) - } else { - host.navigate('/') - } - })() - } + // Keep the Active Now strip and sidebar rows on the same exact-owner + // route: source activation first, then canonical name-registry open. + onOpen: bot => void openRosterBot(bot) }), - roster.length + showRosterTools ? jsx('div', { - className: 'px-2.5 pb-1.5', - children: jsx(SearchField, { - 'aria-label': 'Search bots', - containerClassName: 'w-full', - inputClassName: 'w-full', - placeholder: 'Search bots…', - value: query, - onChange: setQuery - }) + className: 'flex min-w-0 items-center gap-1 px-2.5 pb-1.5', + children: [ + showRosterSearch + ? jsx(SearchField, { + 'aria-label': 'Search bots and group chats', + containerClassName: cn( + 'min-w-0 flex-1', + query ? 'opacity-100!' : 'opacity-50 focus-within:opacity-100' + ), + inputClassName: + 'w-full text-[0.75rem] placeholder:text-(--ui-text-tertiary)', + placeholder: 'Search bots and group chats…', + value: query, + onChange: setQuery + }) + : jsx('span', { className: 'min-w-0 flex-1' }), + showRosterFilters + ? jsxs(DropdownMenu, { + children: [ + jsx(Tip, { + label: activeFilterCount ? `Filters (${activeFilterCount} active)` : 'Filter roster', + children: jsx(DropdownMenuTrigger, { + asChild: true, + children: jsx('button', { + type: 'button', + 'aria-label': activeFilterCount ? `Filter roster, ${activeFilterCount} active` : 'Filter roster', + className: cn( + 'flex size-7 shrink-0 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground', + activeFilterCount && 'text-(--ui-accent)' + ), + children: jsx(Codicon, { name: 'list-filter' }) + }) + }) + }), + jsxs(DropdownMenuContent, { + align: 'end', + children: [ + ...[ + ['all', 'Bots and group chats'], + ['bots', 'Bots only'], + ['groups', 'Group chats only'] + ].map(([value, label]) => + jsxs( + DropdownMenuItem, + { + onSelect: () => setRowKindFilter(value), + children: [ + jsx('span', { className: 'min-w-0 flex-1', children: label }), + rowKindFilter === value ? jsx(Codicon, { name: 'check' }) : null + ] + }, + `kind:${value}` + ) + ), + jsx(DropdownMenuSeparator, {}), + ...[ + ['all', 'Any activity'], + ['active', 'Active now'], + ['recent', 'Recently active'], + ['older', 'Older'] + ].map(([value, label]) => + jsxs( + DropdownMenuItem, + { + onSelect: () => setActivityFilter(value), + children: [ + jsx('span', { className: 'min-w-0 flex-1', children: label }), + activityFilter === value ? jsx(Codicon, { name: 'check' }) : null + ] + }, + `activity:${value}` + ) + ), + gatewayOptions.length > 1 ? jsx(DropdownMenuSeparator, {}) : null, + gatewayOptions.length > 1 + ? jsxs(DropdownMenuItem, { + onSelect: () => setGatewayFilter('all'), + children: [ + jsx(Codicon, { name: 'globe', className: 'mr-1.5' }), + jsx('span', { className: 'min-w-0 flex-1', children: 'All gateways' }), + gatewayFilter === 'all' ? jsx(Codicon, { name: 'check' }) : null + ] + }) + : null, + ...(gatewayOptions.length > 1 + ? gatewayOptions.map(option => { + const status = botSourceStatus({ + sourceError: option.error, + sourceReachable: option.reachable + }) + + return jsxs( + DropdownMenuItem, + { + onSelect: () => setGatewayFilter(option.connectionId), + children: [ + jsx(GatewayKindGlyph, { + kind: option.kind, + className: cn( + 'mr-1.5', + !status.available && 'text-amber-600 dark:text-amber-300' + ) + }), + jsx('span', { + className: 'min-w-0 flex-1 truncate', + children: option.label || option.connectionId + }), + jsx('span', { + className: 'text-[0.625rem] tabular-nums text-(--ui-text-quaternary)', + children: option.count + }), + gatewayFilter === option.connectionId ? jsx(Codicon, { name: 'check' }) : null + ] + }, + option.connectionId + ) + }) + : []), + activeFilterCount ? jsx(DropdownMenuSeparator, {}) : null, + activeFilterCount + ? jsx(DropdownMenuItem, { + onSelect: () => { + setRowKindFilter('all') + setActivityFilter('all') + setGatewayFilter('all') + }, + children: 'Clear filters' + }) + : null + ] + }) + ] + }) + : null + ] }) : null, staleNotice @@ -12148,7 +13302,7 @@ function BotsPane() { children: staleNotice }) : null, - isLoading && !roster.length + (isLoading || initialRosterLoading) && !roster.length ? jsx('div', { className: 'flex flex-1 items-center justify-center', children: jsx(GlyphSpinner, { spinner: 'breathe', className: 'text-(--ui-text-tertiary)' }) @@ -12174,56 +13328,104 @@ function BotsPane() { : roster.length === 0 ? jsx(EmptyState, { icon: 'hubot', - title: 'No agents yet', - description: 'Create your first teammate.' + title: 'No bots yet', + description: 'Create your first bot.' }) - : filteredRoster.length === 0 && rosterRows.length === 0 - ? jsx('div', { - 'aria-live': 'polite', - className: - 'flex flex-1 items-center justify-center px-4 text-center text-xs text-(--ui-text-tertiary)', - role: 'status', - children: query.trim() - ? `No bots match “${query.trim()}”` - : 'All bots are hidden — use the eye button above to show them.' + : allBotsHidden && !hiddenExpanded + ? jsxs('div', { + className: 'grid content-start gap-2 px-3 py-4 text-xs text-(--ui-text-tertiary)', + children: [ + jsxs('div', { + className: 'flex items-center gap-1.5 font-medium text-(--ui-text-secondary)', + children: [ + jsx(Codicon, { name: 'eye-closed', className: 'text-(--ui-text-quaternary)' }), + 'All bots are hidden' + ] + }), + jsx('p', { className: 'leading-relaxed', children: 'They keep working and retain their history.' }), + jsx(Button, { + variant: 'secondary', + size: 'sm', + className: 'justify-self-start', + onClick: () => $showHiddenBots.set(true), + children: 'Show hidden bots' + }) + ] }) - : jsx(ScrollArea, { - className: 'hermes-bots-roster min-h-0 flex-1', - children: jsx('div', { - className: 'grid w-full min-w-0 gap-0.5 px-1.5 pb-2', - // Flat, Discord-style list: bot rows and group rows - // interleaved by recency — no section headers. - children: rosterRows.map(row => - row.kind === 'group' - ? jsx( - GroupRow, - { - active: groupChatName === row.name, - group: row.name, - members: row.members, - needsYou: Boolean(groupNeedsYou[row.name]), - onOpen: openGroupChat, - onDisband: setDeletingGroup - }, - `group:${row.name}` - ) - : jsx( - BotRow, - { bot: row.bot, onDelete: setDeleting, onEdit: setEditing, onGroup: setGrouping }, - botRosterKey(row.bot) - ) - ) + : rosterRows.length === 0 && matchingHiddenBots.length === 0 + ? jsx('div', { + 'aria-live': 'polite', + className: + 'flex flex-1 items-center justify-center px-4 text-center text-xs text-(--ui-text-tertiary)', + role: 'status', + children: query.trim() + ? `No bots or group chats match “${query.trim()}”${selectedGateway ? ` on ${selectedGateway.label}` : ''}` + : selectedGateway + ? `No bots or group chats match these filters on ${selectedGateway.label}` + : 'No bots or group chats match these filters.' }) - }), - jsx('div', { - className: 'border-t border-(--ui-stroke-secondary) p-2', - children: jsxs(Button, { - className: 'w-full justify-center gap-1.5', - variant: 'secondary', - onClick: () => setCreateOpen(true), - children: [jsx(Codicon, { name: 'add' }), 'New Agent'] - }) - }), + : jsx(ScrollArea, { + className: 'hermes-bots-roster min-h-0 flex-1', + children: jsx('div', { + className: 'grid w-full min-w-0 gap-0.5 px-1.5 pb-2', + children: [ + ...(showGatewaySections + ? [ + sortedGroupRows.length ? renderGroupChatSection() : null, + ...gatewaySections.sections.map(renderGatewaySection) + ].filter(Boolean) + : rosterRows.map(row => + row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot) + )), + showHiddenSection + ? jsxs('div', { + ref: hiddenSectionRef, + className: 'mt-1 border-t border-(--ui-stroke-tertiary) pt-1', + children: [ + hasRosterConstraint + ? jsxs('div', { + className: + 'flex w-full items-center gap-1 px-2 py-1.5 text-[0.6875rem] font-medium text-(--ui-text-tertiary)', + children: [ + jsx(Codicon, { name: 'eye-closed' }), + jsx('span', { children: 'Hidden' }), + jsx('span', { + className: 'text-(--ui-text-quaternary)', + children: matchingHiddenBots.length + }) + ] + }) + : jsxs('button', { + type: 'button', + 'aria-expanded': hiddenExpanded, + className: + 'flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground', + onClick: () => $showHiddenBots.set(!hiddenExpanded), + children: [ + jsx(Codicon, { name: hiddenExpanded ? 'chevron-down' : 'chevron-right' }), + jsx('span', { children: 'Hidden' }), + jsx('span', { + className: 'text-(--ui-text-quaternary)', + children: hiddenBots.length + }) + ] + }), + showHiddenRows + ? matchingHiddenBots.length + ? hiddenGatewaySections.sectioned + ? hiddenGatewaySections.sections.map(renderHiddenGatewaySection) + : matchingHiddenBots.map(bot => renderBotRow(bot, 'hidden:')) + : jsx('div', { + className: 'px-2 py-2 text-xs text-(--ui-text-quaternary)', + children: 'No hidden bots match these filters.' + }) + : null + ] + }) + : null + ] + }) + }), jsx(CreateAgentDialog, { open: createOpen, onClose: () => { @@ -12394,6 +13596,25 @@ export default { // one-version rollback. void migrateBotMeta(ctx.storage).catch(() => undefined) + // The last selected bot, source-qualified. Restoring it is PRESENTATION + // ONLY: it paints the Bots home and the roster highlight, and never + // activates a gateway, opens a chat, or creates a session. The hydrated + // flag must flip on every settle path — the home holds a loading state + // until it does, and a storage quirk must not strand it there. + try { + Promise.resolve(ctx.storage?.get?.('selected-roster-bot-v1')) + .then(value => { + if (typeof value === 'string' && value.trim()) { + $selectedRosterKey.set(value.trim()) + } + }) + .catch(() => undefined) + .finally(() => $selectedRosterHydrated.set(true)) + } catch { + /* no storage — this window starts with no restored selection */ + $selectedRosterHydrated.set(true) + } + // Bot Mode sessions are always hidden now — the old "hide Bot Chats" // pref is gone (its stored key is simply ignored). The reconciliation // sweep below hides any rows born visible under the old pref. @@ -12553,11 +13774,11 @@ export default { if (typeof host.paneVisibility === 'function') { // The contribution-scoped pane id (`register` prefixes `${ID}:`). - const $botsPaneVisible = host.paneVisibility(`${ID}:pane`) + const $sidebarVisible = host.paneVisibility(`${ID}:pane`) let unregisterRoutines = null - const syncRoutinesPane = visible => { - if (visible) { + const syncRoutinesPane = () => { + if (botChatOwnsWorkspace()) { unregisterRoutines ??= registerRoutinesPane() } else if (unregisterRoutines) { unregisterRoutines() @@ -12565,13 +13786,83 @@ export default { } } - const stopRoutinesSync = $botsPaneVisible.listen(syncRoutinesPane) - syncRoutinesPane($botsPaneVisible.get()) + // One recompute for both main-area surfaces: they answer the same + // question (who owns the center) from the same three signals. + const syncWorkspaceSurfaces = () => { + syncBotsHomeWorkspace() + syncRoutinesPane() + } + + const stopSidebarSync = $sidebarVisible.listen(visible => { + $botsPaneVisible.set(Boolean(visible)) + // A generic composer has no stored-session owner, so passive sync + // replaces it with the Bot home. A real restored chat keeps the + // center until the user explicitly selects a Bot owner. + syncWorkspaceSurfaces() + }) + const stopGroupSync = $groupChatWorkspace.listen(syncWorkspaceSurfaces) + // The home tab's visibility flips are the ONLY signal for two real + // transitions: layout hydration re-asserting a persisted active tab + // over the home after boot, and the user swapping between the home tab + // and a chat tab. React on the NEXT tick — the notification arrives + // mid-layout-mutation, and registering/unregistering panes from inside + // it would re-enter the tree store. + const scheduleSurfaceSync = () => { + try { + setTimeout(syncWorkspaceSurfaces, 0) + } catch { + syncWorkspaceSurfaces() + } + } + const homeVisibleStore = host.paneVisibility(BOTS_HOME_PANE_ID) + const stopHomeVisibleSync = homeVisibleStore.listen(visible => { + // Update selection ownership immediately; the deferred pass below may + // mutate registrations, but the visible row must never lag a frame. + $botsHomeFronted.set(Boolean(visible)) + scheduleSurfaceSync() + }) + // Tab focus moves without swapping the gateway socket, so the focused + // STORED session is the truth about session focus; older shells fall + // back to the active session id. A RISING edge means a session just + // claimed the center (opened or refocused): the home yields then — and + // only then, so an explicitly selected owner can hold the center over + // a focused-but-hidden chat without the next poll snatching it back. + const focusStore = host.state.focusedStoredSessionId || host.state.activeSessionId + const stopFocusSync = + typeof focusStore?.listen === 'function' + ? focusStore.listen(id => { + $botChatFocused.set(Boolean(id)) + releaseStaleOpenBotChat(id) + + if (id) { + closeBotsHomeWorkspace() + } + + syncWorkspaceSurfaces() + }) + : null + + $botsPaneVisible.set(Boolean($sidebarVisible.get())) + $botChatFocused.set(sessionOwnsWorkspace()) + $botsHomeFronted.set(Boolean(homeVisibleStore.get())) + // A persisted layout can boot directly into Bot Mode while restoring + // the generic Sessions workspace as the active sibling. Reconcile now, + // then once more after the layout mutation finishes: the deferred pass + // remains passive, so a real restored chat is never covered. + syncWorkspaceSurfaces() + scheduleSurfaceSync() if (typeof ctx.onDispose === 'function') { // The registration disposer is already tracked by ctx.register; only - // the listener needs explicit teardown or it survives plugin disable. - ctx.onDispose(stopRoutinesSync) + // the listeners need explicit teardown or they survive plugin disable. + ctx.onDispose(() => { + stopSidebarSync() + stopGroupSync() + stopHomeVisibleSync() + stopFocusSync?.() + $botsHomeFronted.set(false) + closeBotsHomeWorkspace() + }) } } else { registerRoutinesPane() @@ -12582,10 +13873,10 @@ export default { area: PALETTE_AREA, data: { id: `${ID}.new-agent`, - label: 'New Agent…', + label: 'New Bot…', keywords: ['bot', 'agent', 'profile', 'teammate', 'create'], run: () => { - host.notify({ kind: 'info', message: 'Open the Bots pane and hit “New Agent”.' }) + host.notify({ kind: 'info', message: 'Open the Bots pane and hit “New Bot”.' }) } } }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs index 6d921aa75c01..6734e3fed7e4 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs @@ -104,10 +104,10 @@ test('botActivitySession keeps last_session when it is the fresher one', () => { assert.equal(botActivitySession(bot).id, 'scratch') }) -test('botActivitySession degrades to whichever side exists (older gateways / no pin)', () => { +test('botActivitySession degrades to whichever session summary exists on an older gateway', () => { const botActivitySession = loadBotActivitySession() assert.equal(botActivitySession({ last_session: { id: 'only', last_active: 1 } }).id, 'only') - assert.equal(botActivitySession({ canonical_session: { id: 'pin', last_active: 1 } }).id, 'pin') + assert.equal(botActivitySession({ canonical_session: { id: 'canonical', last_active: 1 } }).id, 'canonical') assert.equal(botActivitySession({}), null) assert.equal(botActivitySession(null), null) }) @@ -164,7 +164,7 @@ test('activeBots ignores a finished worker outside the liveness window', () => { test('ActiveNowStrip renders above the roster, is a live region, and is click-accessible', () => { // Strip is placed between the pane header and the search field. const headerEnd = source.indexOf("children: 'Bots'") - const searchField = source.indexOf("placeholder: 'Search bots…'") + const searchField = source.indexOf("placeholder: 'Search bots and group chats…'") assert.ok(headerEnd >= 0 && searchField > headerEnd) const stripStart = source.indexOf('jsx(ActiveNowStrip') @@ -172,13 +172,22 @@ test('ActiveNowStrip renders above the roster, is a live region, and is click-ac // Live region announces membership changes politely. assert.match(source, /'aria-live': 'polite'/) - // Chips are real buttons (keyboard/click accessible), reuse BotFace, and - // open the canonical chat via the same path as roster rows. - assert.match(source, /jsx\('button', \{\s*type: 'button',\s*title: `Open \$\{label\}'s chat`/) - // The key rides as jsx()'s third argument — the ONLY form React treats as - // a list key; a `key:` prop leaves chips unkeyed (index identity). - assert.match(source, /\}, botRosterKey\(bot\)\)\s*\}\)\s*\]\s*\}\)\s*\}\s*\/\*\* Assign a bot to a group/s) + // Chips use the shared Tip component, remain keyboard/click accessible, + // and open the canonical chat via the same path as roster rows. + assert.match(source, /label: `Open \$\{label\}'s chat`/) + assert.match(source, /'aria-label': `Open \$\{label\}'s chat`/) + // The key rides as jsx()'s third argument so React keeps chip identity. + assert.match(source, /botRosterKey\(bot\)\s*\)\s*\}\)\s*\]\s*\}\)\s*\}\s*\/\*\* Assign a bot to a group/s) assert.match(source, /jsx\(BotFace,\s*\{[\s\S]*?mood: 'work'/) - assert.match(source, /await prepareBotSource\(bot\)/) - assert.match(source, /bot\.canonical_session \|\| last/) + // Chips share the exact-owner route with roster rows. That route activates the + // owner source, then resolves the profile's canonical name registry; no + // renderer pointer or preview-derived session id participates. + assert.match(source, /onOpen: bot => void openRosterBot\(bot\)/) + + const openStart = source.indexOf('async function openRosterBot(') + assert.ok(openStart >= 0) + const open = source.slice(openStart, openStart + 3200) + + assert.match(open, /await prepareBotSource\(bot\)/) + assert.match(open, /await openBotCanonicalChat\(bot\.name\)/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs index 63c58f0d14a4..b7f699a1ef94 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs @@ -204,13 +204,13 @@ test('unit: older desktop without host.deleteProfile falls back to the non-inter test('integration: a deleted bot is removed from plugin-local state and the roster is refreshed', async () => { const { context, invalidations, stored } = load() context.__delete.$botMeta.set({ researcher: { title: 'Research' }, writer: { title: 'Writer' } }) - context.__delete.$botUnread.set({ researcher: true, writer: true }) + context.__delete.$botUnread.set({ 'legacy::researcher': true, 'legacy::writer': true }) context.__delete.$selectedBot.set('researcher') await context.__delete.deleteBot({ name: 'researcher' }) assert.equal(context.__delete.$botMeta.get().researcher, undefined) - assert.equal(context.__delete.$botUnread.get().researcher, undefined) + assert.equal(context.__delete.$botUnread.get()['legacy::researcher'], undefined) assert.equal(context.__delete.$selectedBot.get(), 'default') assert.equal(stored.at(-1).key, 'bot-meta') assert.equal(stored.at(-1).value.researcher, undefined) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs new file mode 100644 index 000000000000..054491e38971 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs @@ -0,0 +1,936 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import vm from 'node:vm' + +// Bot Mode's main workspace always has ONE clear owner: a bot chat, a group +// chat, or the Bots home. The home exists so the Bots tab never falls through +// to the ownerless Sessions composer. +// +// The invariant under test is #90149's: an existing resource carries its exact +// owner. Selecting or RESTORING a bot is presentation only — it must never +// activate a gateway, open a chat, create a session, or route a remote bot +// through whatever connection happens to be live. + +const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +function load({ focusedStoredSessionId = null, paneVisibility = true, openWorkspace = true } = {}) { + const values = new Map() + const atom = initial => { + const slot = { + get: () => values.get(slot), + set: value => { + values.set(slot, value) + for (const fn of slot.__listeners || []) { + fn(value) + } + }, + listen: fn => { + slot.__listeners = [...(slot.__listeners || []), fn] + return () => { + slot.__listeners = (slot.__listeners || []).filter(entry => entry !== fn) + } + } + } + values.set(slot, initial) + return slot + } + + const opened = [] + const closed = [] + const notifications = [] + const requests = [] + const invalidations = [] + const paneVisible = new Map() + const focused = atom(focusedStoredSessionId) + + const host = { + state: { + profile: { get: () => 'default', listen: () => undefined }, + gateway: { get: () => 'open', listen: () => undefined }, + focusedStoredSessionId: focused + }, + request: (method, params) => { + requests.push({ method, params }) + return Promise.resolve({}) + }, + notify: params => notifications.push(params), + notifyError: (error, fallback) => notifications.push({ kind: 'error', message: fallback, error }), + ensureAgent: async () => undefined, + activeConnectionId: () => 'local' + } + + if (openWorkspace) { + host.openWorkspace = (id, options) => { + const entry = { id, options, disposed: false } + opened.push(entry) + paneVisible.set(`plugin-workspace:${id}`, true) + + return () => { + entry.disposed = true + closed.push(entry) + paneVisible.set(`plugin-workspace:${id}`, false) + options.onClose?.() + } + } + } + + if (paneVisibility) { + host.paneVisibility = id => ({ + get: () => paneVisible.get(id) ?? false, + listen: () => () => undefined + }) + } + + const context = { + atom, + haptic: () => undefined, + PALETTE_AREA: 'palette', + COMPOSER_AREAS: { middleware: 'middleware' }, + document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } }, + host, + queryClient: { invalidateQueries: params => invalidations.push(params) }, + navigator: { clipboard: { writeText: async () => undefined } }, + sdk: new Proxy({}, { get: () => undefined }) + } + + const source = pluginSource + .replace(/^import\s+\*\s+as\s+sdk\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^const \{ McpTab, ToolsetConfigPanel \} = sdk\r?\n/m, '') + .replace(/^import .* from 'react'\r?\n/m, '') + .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') + .replace('export default {', 'globalThis.plugin = {') + .concat(` +globalThis.__home = { + botRosterKey, + parseRosterKey, + ghostRosterOwner, + rosterWithSelectedOwner, + reconcileRosterSelection, + saveRosterPreference, + saveSelectedRosterBot, + clearSelectedRosterBot, + clearSelectedRosterKey, + releaseStaleOpenBotChat, + syncBotsHomeWorkspace, + openBotsHomeWorkspace, + closeBotsHomeWorkspace, + botsHomeMayOpen, + botsHomeVisible, + botChatOwnsWorkspace, + sessionOwnsWorkspace, + openRosterBot, + openGroupChat, + closeGroupChatMainTab, + prepareBotSource, + $botMeta, + $botsPaneVisible, + $botChatFocused, + $botsHomeFronted, + $groupChatWorkspace, + $lastRoster, + $lastSources, + $openBotChat, + $rosterHydrated, + $selectedBot, + $selectedRosterHydrated, + $selectedRosterKey, + setPluginCtx: value => { pluginCtx = value } +}; +`) + + vm.runInNewContext(source, context, { filename: 'plugin.js' }) + + return { ...context.__home, closed, focused, host, invalidations, notifications, opened, paneVisible, requests } +} + +/** Every door that would create, activate, or route something. A passive + * selection must touch none of them. */ +function assertNothingRouted(t, label) { + assert.deepEqual(t.requests, [], `${label}: no gateway RPC`) + assert.deepEqual(t.opened.filter(entry => entry.id !== 'hermes-bots:home'), [], `${label}: no chat surface opened`) +} + +// ── selection is source-qualified and presentation-only ───────────────────── + +test('selection persists the source-qualified key, not the bare profile name', () => { + const t = load() + const writes = [] + t.setPluginCtx({ storage: { set: (key, value) => writes.push({ key, value }) } }) + + t.saveSelectedRosterBot({ connectionId: 'work-vps', name: 'researcher', remoteSource: true }) + + assert.equal(t.$selectedRosterKey.get(), 'work-vps::researcher') + assert.deepEqual(writes.at(-1), { key: 'selected-roster-bot-v1', value: 'work-vps::researcher' }) + assertNothingRouted(t, 'saving a selection') +}) + +test('a remote selection never redirects the bare-name consumers at a local twin', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + + // Same profile name on two gateways — the exact-owner class from #90149. + t.saveSelectedRosterBot({ connectionId: 'local', name: 'default' }) + assert.equal(t.$selectedBot.get(), 'default') + + t.saveSelectedRosterBot({ connectionId: 'mac-mini', name: 'default', remoteSource: true }) + + assert.equal(t.$selectedRosterKey.get(), 'mac-mini::default') + assert.equal( + t.$selectedBot.get(), + 'default', + 'the Cronjobs/slash-guard name tracker keeps its LOCAL owner — a remote row must not claim it' + ) +}) + +test('clearing only fires for the exact selected owner', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.saveSelectedRosterBot({ connectionId: 'local', name: 'writer' }) + + t.clearSelectedRosterBot({ connectionId: 'mac-mini', name: 'writer' }) + assert.equal(t.$selectedRosterKey.get(), 'local::writer', 'a same-named bot elsewhere must not clear this one') + + t.clearSelectedRosterBot({ connectionId: 'local', name: 'writer' }) + assert.equal(t.$selectedRosterKey.get(), '') +}) + +test('roster keys round-trip through parseRosterKey', () => { + const t = load() + const parsed = key => ({ ...t.parseRosterKey(key) }) + + assert.deepEqual(parsed('work-vps::researcher'), { connectionId: 'work-vps', name: 'researcher' }) + assert.deepEqual(parsed('local::default'), { connectionId: 'local', name: 'default' }) + assert.deepEqual(parsed(''), { connectionId: '', name: '' }) + // A key always round-trips from the identity that produced it. + const bot = { connectionId: 'work-vps', name: 'researcher' } + assert.deepEqual(parsed(t.botRosterKey(bot)), { connectionId: 'work-vps', name: 'researcher' }) +}) + +// ── hydration ─────────────────────────────────────────────────────────────── + +test('hydration restores the stored key and flips the hydrated flag', async () => { + const t = load() + t.$selectedRosterHydrated.set(false) + + const ctx = { + storage: { get: key => (key === 'selected-roster-bot-v1' ? 'work-vps::researcher' : undefined), set: () => undefined }, + register: () => () => undefined, + onDispose: () => undefined + } + + // Only the selection hydrate is under test; run it the way register() does. + await Promise.resolve(ctx.storage.get('selected-roster-bot-v1')).then(value => { + if (typeof value === 'string' && value.trim()) { + t.$selectedRosterKey.set(value.trim()) + } + }) + t.$selectedRosterHydrated.set(true) + + assert.equal(t.$selectedRosterKey.get(), 'work-vps::researcher') + assert.equal(t.$selectedRosterHydrated.get(), true) + assertNothingRouted(t, 'restoring a selection') +}) + +test('source contract: every hydrate settle path flips the flag, and none of them opens anything', () => { + assert.match( + pluginSource, + /Promise\.resolve\(ctx\.storage\?\.get\?\.\('selected-roster-bot-v1'\)\)[\s\S]{0,400}?\.finally\(\(\) => \$selectedRosterHydrated\.set\(true\)\)/, + 'a storage quirk must not strand the home in its loading state' + ) + assert.match(pluginSource, /\} catch \{\s*\n\s*\/\* no storage[^\n]*\n\s*\$selectedRosterHydrated\.set\(true\)/) +}) + +// ── first selection + reconciliation ──────────────────────────────────────── + +test('the first selection picks a reachable visible bot and creates nothing', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$rosterHydrated.set(true) + t.$selectedRosterHydrated.set(true) + + const sources = [{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }] + const roster = [{ connectionId: 'local', name: 'writer' }, { connectionId: 'local', name: 'coder' }] + + t.reconcileRosterSelection(roster, sources, {}) + + assert.equal(t.$selectedRosterKey.get(), 'local::writer') + assert.equal(t.$openBotChat.get(), null, 'selection is not an open') + assertNothingRouted(t, 'seating the first selection') +}) + +test('an unreachable bot is never auto-selected', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$rosterHydrated.set(true) + t.$selectedRosterHydrated.set(true) + + const sources = [ + { connectionId: 'work-vps', kind: 'remote', label: 'Work', reachable: false }, + { connectionId: 'local', kind: 'local', label: 'This device', reachable: true } + ] + const roster = [ + { connectionId: 'work-vps', name: 'researcher', remoteSource: true, sourceScoped: true }, + { connectionId: 'local', name: 'writer' } + ] + + t.reconcileRosterSelection(roster, sources, {}) + + assert.equal(t.$selectedRosterKey.get(), 'local::writer') +}) + +test('nothing is selected before the roster has answered', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$rosterHydrated.set(false) + t.$selectedRosterHydrated.set(true) + + t.reconcileRosterSelection([{ connectionId: 'local', name: 'writer' }], [], {}) + + assert.equal(t.$selectedRosterKey.get(), '', 'a pending roster must not seat a selection it may have to revoke') +}) + +test('a hidden bot is not auto-selected', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$rosterHydrated.set(true) + t.$selectedRosterHydrated.set(true) + + const sources = [{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }] + const roster = [{ connectionId: 'local', name: 'hidden-one' }, { connectionId: 'local', name: 'writer' }] + + // Hidden is a source-qualified Desktop preference, so hide the exact row. + t.saveRosterPreference(roster[0], 'hidden', true) + + t.reconcileRosterSelection(roster, sources, {}) + + assert.equal( + t.$selectedRosterKey.get(), + 'local::writer', + 'the home must not open onto a bot the user removed from the roster' + ) +}) + +// ── offline owner survives; deleted owner does not ────────────────────────── + +test('an offline gateway keeps the selection and its identity (relaunch case)', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$rosterHydrated.set(true) + t.$selectedRosterHydrated.set(true) + t.$selectedRosterKey.set('work-vps::researcher') + + // The gateway is registered but down, so it contributes no roster rows. + const sources = [ + { connectionId: 'work-vps', kind: 'remote', label: 'Work', reachable: false, error: 'ECONNREFUSED' }, + { connectionId: 'local', kind: 'local', label: 'This device', reachable: true } + ] + const roster = [{ connectionId: 'local', name: 'writer' }] + + t.reconcileRosterSelection(roster, sources, {}) + + assert.equal( + t.$selectedRosterKey.get(), + 'work-vps::researcher', + 'an unreachable owner is cached, not replaced — falling back would re-own the bot on another gateway' + ) + + const ghost = t.ghostRosterOwner('work-vps::researcher', sources) + assert.equal(ghost.name, 'researcher') + assert.equal(ghost.connectionId, 'work-vps') + assert.equal(ghost.connectionLabel, 'Work') + assert.equal(ghost.remoteSource, true) + assert.equal(ghost.sourceReachable, false) + assertNothingRouted(t, 'rendering an offline owner') +}) + +test('cold-start outage keeps the selected owner in the visible roster without caching every bot', () => { + const t = load() + const sources = [ + { connectionId: 'work-vps', kind: 'remote', label: 'Work', reachable: false, error: 'ECONNREFUSED' }, + { connectionId: 'local', kind: 'local', label: 'This device', reachable: true } + ] + const localOnly = [{ connectionId: 'local', name: 'writer' }] + + const restored = t.rosterWithSelectedOwner(localOnly, sources, 'work-vps::researcher') + + assert.equal(restored.length, 2, 'only the exact selected owner is restored, not an invented remote roster') + assert.equal(t.botRosterKey(restored[1]), 'work-vps::researcher') + assert.equal(restored[1].connectionLabel, 'Work') + assert.equal(restored[1].sourceReachable, false) + assert.equal(t.rosterWithSelectedOwner(restored, sources, 'work-vps::researcher').length, 2, 'never duplicated') +}) + +test('a reachable source never receives a ghost row for a deleted bot', () => { + const t = load() + const roster = [{ connectionId: 'local', name: 'writer' }] + const sources = [ + { connectionId: 'work-vps', kind: 'remote', label: 'Work', reachable: true }, + { connectionId: 'local', kind: 'local', label: 'This device', reachable: true } + ] + + assert.equal(t.rosterWithSelectedOwner(roster, sources, 'work-vps::deleted').length, 1) +}) + +test('a reachable source that no longer lists the bot clears the selection', () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$rosterHydrated.set(true) + t.$selectedRosterHydrated.set(true) + t.$selectedRosterKey.set('local::deleted-bot') + + const sources = [{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }] + const roster = [{ connectionId: 'local', name: 'writer' }] + + assert.equal(t.ghostRosterOwner('local::deleted-bot', sources), null, 'a live source answering without it is proof') + + t.reconcileRosterSelection(roster, sources, {}) + + assert.equal(t.$selectedRosterKey.get(), 'local::writer', 'the invalid selection is replaced, not kept') +}) + +test('an unknown source list is not proof of deletion', () => { + const t = load() + + // Sources have not hydrated yet — the owner must keep its identity. + assert.ok(t.ghostRosterOwner('work-vps::researcher', [])) + assert.equal(t.ghostRosterOwner('work-vps::researcher', [{ connectionId: 'local', reachable: true }]), null) +}) + +// ── explicit open: local routes, remote never does ────────────────────────── + +test('opening a remote bot selects it and routes nothing', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + + const bot = { + connectionId: 'work-vps', + connectionLabel: 'Work', + name: 'researcher', + remoteSource: true, + sourceScoped: true + } + + const result = await t.openRosterBot(bot) + + assert.equal(result, false, 'a remote row does not open a chat') + assert.equal(t.$selectedRosterKey.get(), 'work-vps::researcher') + assert.equal(t.$openBotChat.get(), null) + assertNothingRouted(t, 'clicking a remote row') +}) + +test('a remote owner fronts the Bots home without closing the group tab', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.openGroupChat('Launch room') + const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room') + + const result = await t.openRosterBot({ + connectionId: 'work-vps', + connectionLabel: 'Work', + name: 'researcher', + remoteSource: true + }) + + assert.equal(result, false) + assert.equal(t.botsHomeVisible(), true) + assert.equal(t.$groupChatWorkspace.get(), null) + assert.equal(groupEntry.disposed, false, 'explicit selection must not close an unrelated group tab') +}) + +test('a remote owner preserves a group when the Bots home cannot open', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.openGroupChat('Launch room') + const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room') + t.host.openWorkspace = () => { + throw new Error('workspace unavailable') + } + + const result = await t.openRosterBot({ + connectionId: 'work-vps', + connectionLabel: 'Work', + name: 'researcher', + remoteSource: true + }) + + assert.equal(result, false) + assert.equal(t.$groupChatWorkspace.get(), 'Launch room') + assert.equal(groupEntry.disposed, false) +}) + +test('a failed local open leaves no phantom owner in the center', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'previous' }) + + // A source-scoped row on a desktop that cannot activate it: prepareBotSource + // refuses rather than letting the open fall through to the live gateway. + delete t.host.ensureAgent + const bot = { connectionId: 'work-vps', name: 'writer', sourceScoped: true } + + const result = await t.openRosterBot(bot) + + assert.equal(result, false) + assert.equal(t.$openBotChat.get(), null, 'a failed open must release the center back to the home') + assert.equal(t.notifications.at(-1).kind, 'error', 'and the failure is surfaced, not swallowed') + assertNothingRouted(t, 'a refused local open') +}) + +test('a missing profile-scoped draft API returns to the home without navigating', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.host.request = async method => { + if (method === 'session.list') return { sessions: [] } + if (method === 'session.create') return {} + return {} + } + + const result = await t.openRosterBot({ connectionId: 'local', name: 'writer' }) + + assert.equal(result, false) + assert.equal(t.$openBotChat.get(), null, 'no draft was opened without the owner-scoped API') + assert.ok(t.botsHomeVisible(), 'the owner home remains the visible recovery surface') +}) + +test('a bot chat opens from its canonical name-registry row without closing the prior group tab', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.host.openSession = async () => undefined + t.host.request = async method => { + if (method === 'session.list') { + return { sessions: [{ id: 'bot-chat', title: 'Bot Chat', message_count: 4 }] } + } + + return {} + } + + t.openGroupChat('Launch room') + const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room') + assert.ok(groupEntry) + assert.equal(t.$groupChatWorkspace.get(), 'Launch room') + + const result = await t.openRosterBot({ connectionId: 'local', name: 'writer' }) + + assert.equal(result, true) + assert.equal(t.$groupChatWorkspace.get(), null) + assert.equal(groupEntry.disposed, false, 'opening a canonical chat must not close an unrelated group tab') + assert.equal(t.$openBotChat.get()?.key, 'local::writer') + assert.equal(t.$openBotChat.get()?.openedRegistryId, 'bot-chat') +}) + +test('a failed canonical-chat open preserves the visible group owner', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.host.openSession = async () => { + throw new Error('gateway unavailable') + } + t.host.request = async method => { + if (method === 'session.list') { + return { sessions: [{ id: 'bot-chat', title: 'Bot Chat', message_count: 4 }] } + } + + return {} + } + + t.openGroupChat('Launch room') + const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room') + const result = await t.openRosterBot({ connectionId: 'local', name: 'writer' }) + + assert.equal(result, false) + assert.equal(t.$groupChatWorkspace.get(), 'Launch room') + assert.equal(groupEntry.disposed, false, 'a failed transition cannot retire the surface still on screen') + assert.equal(t.$openBotChat.get(), null) + assert.equal(t.notifications.at(-1).kind, 'error') +}) + +test('choosing a group prevents a stale canonical-chat open from closing it later', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + + let finishOpen + let markOpenStarted + const openStarted = new Promise(resolve => { + markOpenStarted = resolve + }) + t.host.openSession = () => + new Promise(resolve => { + finishOpen = resolve + markOpenStarted() + }) + t.host.request = async method => { + if (method === 'session.list') { + return { sessions: [{ id: 'bot-chat', title: 'Bot Chat', message_count: 4 }] } + } + + return {} + } + + const opening = t.openRosterBot({ connectionId: 'local', name: 'writer' }) + await openStarted + t.openGroupChat('Launch room') + const groupEntry = t.opened.find(entry => entry.id === 'hermes-bots:group:launch-room') + + finishOpen() + assert.equal(await opening, false) + assert.equal(t.$groupChatWorkspace.get(), 'Launch room') + assert.equal(groupEntry.disposed, false) + assert.equal(t.$openBotChat.get(), null) +}) + +// ── who owns the main workspace ───────────────────────────────────────────── + +test('ownership table: exactly one surface owns the center', () => { + const t = load() + + // Bot Mode not on screen: neither surface exists. + t.$botsPaneVisible.set(false) + assert.equal(t.botsHomeMayOpen(false), false) + assert.equal(t.botChatOwnsWorkspace(), false) + + // Bots visible, nothing else: the home owns it, Cronjobs stay away. + t.$botsPaneVisible.set(true) + assert.equal(t.botsHomeMayOpen(false), true) + assert.equal(t.botChatOwnsWorkspace(), false, 'no bot chat owns the center, so bot-scoped Cronjobs must not seat') + + // A group chat owns it: neither the home nor Cronjobs. + t.$groupChatWorkspace.set('Core') + assert.equal(t.botsHomeMayOpen(false), false) + assert.equal(t.botsHomeMayOpen(true), false, 'even an explicit gesture cannot cover a group chat') + assert.equal(t.botChatOwnsWorkspace(), false) + t.$groupChatWorkspace.set(null) + + // A bot chat owns it: the home yields and Cronjobs seat. + t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'chat-1' }) + assert.equal(t.botsHomeMayOpen(false), false) + assert.equal(t.botChatOwnsWorkspace(), true) + t.$openBotChat.set(null) + + // A focused session (restored at boot, or Sessions mode) vetoes PASSIVE + // opens but not an explicit gesture at the home. + t.focused.set('chat-9') + assert.equal(t.sessionOwnsWorkspace(), true) + assert.equal(t.botsHomeMayOpen(false), false) + assert.equal(t.botsHomeMayOpen(true), true) + assert.equal(t.botChatOwnsWorkspace(), true) +}) + +test('with the home tab fronted, the hidden chat does not seat Cronjobs', () => { + const t = load() + t.$botsPaneVisible.set(true) + + // Explicitly front the home over a focused chat. + t.focused.set('chat-1') + t.openBotsHomeWorkspace(true) + assert.equal(t.botsHomeVisible(), true) + assert.equal( + t.botChatOwnsWorkspace(), + false, + 'the chat is a hidden sibling layer while the home holds the tab slot' + ) + + // The user fronts the chat tab again (no focus change fires): the pane + // visibility flip alone must reseat Cronjobs. + t.closeBotsHomeWorkspace() + assert.equal(t.botsHomeVisible(), false) + assert.equal(t.botChatOwnsWorkspace(), true) +}) + +test('a persisted layout that restored the home behind the draft gets re-fronted', () => { + const t = load() + t.$botsPaneVisible.set(true) + t.syncBotsHomeWorkspace() + assert.equal(t.opened.length, 1) + + // The tree restored the tab BEHIND the core draft pane (adoption kept the + // persisted active slot): the ownerless composer would sit on top. + t.paneVisible.set('plugin-workspace:hermes-bots:home', false) + + t.syncBotsHomeWorkspace() + + assert.equal(t.opened.length, 2, 're-opened to reclaim the active slot') + assert.equal(t.closed.length, 1, 'the stale registration was closed first — never two live disposers') + assert.equal(t.botsHomeVisible(), true) +}) + +test('an explicit remote selection fronts the home over a focused chat', async () => { + const t = load({ focusedStoredSessionId: 'local-scout-chat' }) + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + + // Passive sync must not cover the chat… + t.syncBotsHomeWorkspace() + assert.deepEqual(t.opened, []) + + // …but clicking the same-named twin on another gateway is a gesture at + // that owner: the home fronts, the chat stays alive underneath. + await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'scout', remoteSource: true }) + + assert.equal(t.opened.length, 1) + assert.equal(t.$selectedRosterKey.get(), 'work-vps::scout') + assert.equal(t.$openBotChat.get(), null) + assertNothingRouted(t, 'explicit remote selection') + + // Browsing more remote owners reuses the fronted home instead of + // re-registering it (a stale disposer would tear down the newer one). + await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'relay', remoteSource: true }) + assert.equal(t.opened.length, 1) + assert.equal(t.$selectedRosterKey.get(), 'work-vps::relay') +}) + +test('an explicit Bots-home gesture fronts the selected owner over a Sessions composer', () => { + const t = load({ focusedStoredSessionId: 'sessions-chat' }) + t.$botsPaneVisible.set(true) + + t.syncBotsHomeWorkspace() + assert.deepEqual(t.opened, [], 'passive polling still leaves a focused session alone') + + t.openBotsHomeWorkspace(true) + assert.equal(t.opened.length, 1, 'the explicit gesture has an exact owner instead of the Sessions composer') + assert.equal(t.opened[0].id, 'hermes-bots:home') +}) + +test('source contract: sidebar entry and boot restore reconcile passively after layout hydration', () => { + assert.match(pluginSource, /const syncWorkspaceSurfaces = \(\) =>/) + assert.match(pluginSource, /stopSidebarSync = \$sidebarVisible\.listen\(visible => \{[\s\S]{0,450}?syncWorkspaceSurfaces\(\)/) + assert.doesNotMatch(pluginSource, /stopSidebarSync = \$sidebarVisible\.listen\(visible => \{[\s\S]{0,450}?syncWorkspaceSurfaces\(Boolean\(visible\)\)/) + assert.match( + pluginSource, + /\$botChatFocused\.set\(sessionOwnsWorkspace\(\)\)[\s\S]{0,500}?syncWorkspaceSurfaces\(\)[\s\S]{0,120}?scheduleSurfaceSync\(\)/ + ) + assert.match( + pluginSource, + /homeVisibleStore\.listen\(visible => \{[\s\S]{0,260}?\$botsHomeFronted\.set\(Boolean\(visible\)\)[\s\S]{0,120}?scheduleSurfaceSync\(\)/ + ) +}) + +test('an opened chat releases the center once focus leaves it', () => { + const t = load() + t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'chat-1' }) + + t.releaseStaleOpenBotChat('chat-1') + assert.deepEqual(t.$openBotChat.get(), { key: 'local::writer', openedRegistryId: 'chat-1' }, 'still the focused chat') + + t.releaseStaleOpenBotChat('chat-2') + assert.equal(t.$openBotChat.get(), null, 'another session took the center') + + t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'chat-1' }) + t.releaseStaleOpenBotChat(null) + assert.equal(t.$openBotChat.get(), null, 'the chat was closed — the home may come back') +}) + +test('a legacy draft keeps the center until a real session takes focus', () => { + const t = load() + + // The newChat fallback has no stored id to compare against. + t.$openBotChat.set({ key: 'local::writer', openedRegistryId: '' }) + + t.releaseStaleOpenBotChat(null) + assert.deepEqual(t.$openBotChat.get(), { key: 'local::writer', openedRegistryId: '' }, 'an unsent draft is still that bot’s') + + t.releaseStaleOpenBotChat('chat-7') + assert.equal(t.$openBotChat.get(), null) +}) + +// ── the home tab itself ───────────────────────────────────────────────────── + +test('the home opens once and is not re-fronted while it already owns the center', () => { + const t = load() + t.$botsPaneVisible.set(true) + + t.syncBotsHomeWorkspace() + assert.equal(t.opened.length, 1) + assert.equal(t.opened[0].id, 'hermes-bots:home') + assert.equal(t.opened[0].options.title, 'Bots') + + // Repeated signals (focus churn, roster polls) must not steal focus back + // or mint a second disposer whose stale predecessor could tear down the + // newer registration. + t.syncBotsHomeWorkspace() + t.syncBotsHomeWorkspace() + assert.equal(t.opened.length, 1) + assert.deepEqual(t.closed, []) +}) + +test('the home yields the center to a chat and returns when the chat closes', () => { + const t = load() + t.$botsPaneVisible.set(true) + + t.syncBotsHomeWorkspace() + assert.equal(t.opened.length, 1) + + t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'chat-1' }) + t.syncBotsHomeWorkspace() + assert.equal(t.closed.length, 1, 'the home closed for the chat') + + t.$openBotChat.set(null) + t.syncBotsHomeWorkspace() + assert.equal(t.opened.length, 2, 'and comes back when nothing else owns the center') +}) + +test('a restored session at boot keeps the home from covering it', () => { + const t = load({ focusedStoredSessionId: 'restored-chat' }) + t.$botsPaneVisible.set(true) + + t.syncBotsHomeWorkspace() + + assert.deepEqual(t.opened, [], 'the home must not steal the tab from a session the user left open') +}) + +test('closing the home tab does not resurrect it mid-close', () => { + const t = load() + t.$botsPaneVisible.set(true) + t.syncBotsHomeWorkspace() + + // The tab's own ✕ routes through the same disposer. + t.opened[0].options.onClose() + + assert.equal(t.opened.length, 1, 'onClose must not re-open the tab the user just closed') + + // It comes back only when something else actually happens. + t.syncBotsHomeWorkspace() + assert.equal(t.opened.length, 2) +}) + +test('the home never yanks the center back from a sibling tab the user chose', () => { + const t = load() + t.$botsPaneVisible.set(true) + t.syncBotsHomeWorkspace() + assert.equal(t.opened.length, 1) + + // The user tabs to the ordinary draft workspace: no session owns the + // center, so the home still "should" own it — but it is already open, and + // re-opening would front it over the tab the user just picked. + t.focused.set(null) + t.syncBotsHomeWorkspace() + t.syncBotsHomeWorkspace() + + assert.equal(t.opened.length, 1, 'focus churn must not re-front the home') + assert.deepEqual(t.closed, []) +}) + +test('older shells without the main-area door simply have no home', async () => { + const t = load({ openWorkspace: false, paneVisibility: false }) + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + + t.syncBotsHomeWorkspace() + assert.deepEqual(t.opened, []) + + // And the remote row keeps its previous guidance toast instead. + await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'researcher', remoteSource: true }) + + assert.equal(t.notifications.length, 1) + // Guidance is presentation only; remote mention delivery remains backend-owned. + assert.match(t.notifications[0].message, /message @researcher from a Bot Chat/) + assertNothingRouted(t, 'remote row on an older shell') +}) + +// ── view contracts ────────────────────────────────────────────────────────── + +test('the home Tip wraps exactly one element (Radix asChild)', () => { + const start = pluginSource.indexOf('function BotsHomeView(') + assert.ok(start >= 0) + const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace(')) + + assert.match(view, /jsx\(Tip, \{\s*\n\s*label:[^\n]*\n\s*children: jsxs\('div'/, 'one child element, not an array') + assert.doesNotMatch(view, /jsxs\(Tip, \{/, 'jsxs would pass multiple children and break the trigger') + // The screen-reader text rides INSIDE the trigger element. + assert.match(view, /className: 'sr-only'/) +}) + +test('the home shows a loading state rather than flashing “No bots”', () => { + const start = pluginSource.indexOf('function BotsHomeView(') + const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace(')) + + assert.match(view, /if \(!rosterHydrated \|\| !selectionHydrated\) \{[\s\S]{0,220}?GlyphSpinner/) + const spinnerAt = view.indexOf('GlyphSpinner') + const emptyAt = view.indexOf("title: roster.length ? 'Choose a bot or group chat' : 'No bots yet'") + assert.ok(spinnerAt >= 0 && emptyAt > spinnerAt, 'the empty state is only reachable after both hydrations') +}) + +test('the home uses the neutral workspace surface instead of a transient blue tint', () => { + const start = pluginSource.indexOf('function BotsHomeView(') + const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace(')) + + assert.match(view, /className: 'flex h-full min-h-0 flex-col bg-background'/) + assert.doesNotMatch(view, /bg-\(--ui-bg-primary\)/) +}) + +test('roster hydration and selection reconciliation run after render', () => { + const start = pluginSource.indexOf('function BotsPane(') + const pane = pluginSource.slice(start, pluginSource.indexOf('// ── registration')) + + assert.match( + pane, + /useEffect\(\(\) => \{[\s\S]{0,500}?\$rosterHydrated\.set\(true\)[\s\S]{0,300}?reconcileRosterSelection\(roster, sourceSnapshot, allMeta\)[\s\S]{0,220}?\}, \[data, error, selectionHydrated, roster, sourceSnapshot, allMeta\]\)/, + 'persisted roster ownership must reconcile from an effect, never from a replayable render' + ) + assert.equal( + (pane.match(/reconcileRosterSelection\(roster, sourceSnapshot, allMeta\)/g) || []).length, + 1, + 'BotsPane has one effect-bound reconciliation path' + ) + assert.match( + pane, + /sourceWithSelectedOwner = selectionHydrated && rosterHydrated[\s\S]{0,180}?rosterWithSelectedOwner/, + 'an unavailable-owner placeholder cannot bypass initial roster hydration' + ) + assert.match( + pane, + /\$lastRoster\.set\(roster\.filter\(row => !row\?\.ghost\)\)/, + 'presentation-only owner placeholders never enter shared roster state' + ) +}) + +test('an unavailable owner offers retry instead of a dead Open chat button', () => { + const start = pluginSource.indexOf('function BotsHomeView(') + const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace(')) + + assert.match(view, /unavailable && !sourceRemoved\s*\n\s*\? jsx\(Button, \{[\s\S]{0,200}?children: 'Retry'/) + // Retry re-polls the roster; it must not activate or route anything. + assert.match(view, /queryClient\.invalidateQueries\(\{ queryKey: ROSTER_KEY \}\)/) + assert.doesNotMatch(view, /ensureAgent|requestProfile|newChat/) + assert.match(view, /\$\{gateway\} is unavailable\. Retry when it is back online\./) + assert.match(view, /\$\{gateway\} was removed\. Choose another bot from the sidebar\./) + assert.doesNotMatch(view, /This bot remains selected/) + assert.doesNotMatch(view, /its work keeps running on that gateway/) +}) + +test('an available remote owner explains the supported Bot Chat path without a fake direct action', () => { + const start = pluginSource.indexOf('function BotsHomeView(') + const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace(')) + + assert.match(view, /unavailable \|\| !bot\.remoteSource/) + assert.match(view, /This bot lives on \$\{gateway\}\. Mention it from any Bot Chat to send it a message\./) + assert.doesNotMatch(view, /Copy @/) + assert.doesNotMatch(view, /remoteCopy/) + assert.doesNotMatch(view, /ensureAgent|requestProfile|newChat/) +}) + +test('an unavailable owner never presents a guessed mention handle', () => { + const start = pluginSource.indexOf('function BotsHomeView(') + const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace(')) + + assert.match(view, /const handle = bot\.ghost \? '' : botHandle\(bot\.name, bot\)/) + assert.match(view, /handle\s*\n\s*\? jsx\('span'/) +}) + +test('Bot Mode copy says bot, not agent', () => { + assert.doesNotMatch(pluginSource, /Name the agent first/) + assert.doesNotMatch(pluginSource, /create agents first/) + assert.doesNotMatch(pluginSource, /children: busy \? 'Creating…' : 'Create Agent'/) + assert.doesNotMatch(pluginSource, /`Agent "\$\{displayName\(\{ name: slug, title \}\)\}" created/) + assert.match(pluginSource, /Name the bot first/) + assert.match(pluginSource, /children: busy \? 'Creating…' : 'Create Bot'/) + assert.match(pluginSource, /`Bot "\$\{displayName\(\{ name: slug, title \}\)\}" created/) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bots-search.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bots-search.test.mjs index c0ae2c07dd97..b7269961f0f5 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bots-search.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bots-search.test.mjs @@ -11,7 +11,7 @@ function loadFilter() { assert.ok(start >= 0 && end > start, 'bot identity helper block must remain extractable') - const context = {} + const context = { botActivitySession: bot => bot?.canonical_session || bot?.last_session || null } vm.runInNewContext( `${source.slice(start, end)}\nglobalThis.__filterBots = filterBots;`, context @@ -56,6 +56,23 @@ test('bot search matches profile handles and preserves roster order', () => { ) }) +test('bot search also matches roles, message previews, and gateway labels', () => { + const filterBots = loadFilter() + const richerRoster = [ + { + name: 'reviewer', + connectionLabel: 'Work Studio', + description: 'Release quality and compliance', + last_session: { preview: 'Checked the deployment checklist' } + }, + { name: 'writer', description: 'Editorial support' } + ] + + assert.equal(filterBots(richerRoster, {}, 'compliance')[0].name, 'reviewer') + assert.equal(filterBots(richerRoster, {}, 'deployment checklist')[0].name, 'reviewer') + assert.equal(filterBots(richerRoster, {}, 'work studio')[0].name, 'reviewer') +}) + test('blank bot search returns the existing roster reference', () => { const filterBots = loadFilter() @@ -63,7 +80,16 @@ test('blank bot search returns the existing roster reference', () => { }) test('Bot pane renders the canonical search field and no-match state', () => { - assert.match(source, /jsx\(SearchField,\s*\{[\s\S]*?placeholder: 'Search bots…'/) + assert.match( + source, + /const showRosterSearch =\s*gatewayOptions\.length > 1 \|\| rosterItemCount >= BOT_ROSTER_SEARCH_THRESHOLD/ + ) + assert.match(source, /jsx\(SearchField,\s*\{[\s\S]*?placeholder: 'Search bots and group chats…'/) + assert.match(source, /query \? 'opacity-100!' : 'opacity-50 focus-within:opacity-100'/) + assert.match(source, /placeholder:text-\(--ui-text-tertiary\)/) + assert.match(source, /children: jsx\(Codicon, \{ name: 'list-filter' \}\)/) assert.match(source, /'aria-live': 'polite'/) - assert.match(source, /No bots match “\$\{query\.trim\(\)\}”/) + assert.match(source, /No bots or group chats match “\$\{query\.trim\(\)\}”/) + assert.match(source, /const initialRosterLoading = !data && !error && roster\.length === 0/) + assert.match(source, /\(isLoading \|\| initialRosterLoading\) && !roster\.length/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/create-group-chat.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/create-group-chat.test.mjs index 1eb1e1e57fa5..899a2a3e3688 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/create-group-chat.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/create-group-chat.test.mjs @@ -4,21 +4,23 @@ import test from 'node:test' const pluginSource = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') -// Discord-style creation flow: the header "+" is a dropdown (New Agent / +// Discord-style creation flow: the header "+" is a dropdown (New Bot / // New Group Chat), and New Group Chat opens a checkbox-picker modal with // search, a name input, and a Create button. Source-contract style, like // the other roster affordance tests. test('source contract: header + is a dropdown offering agent and group chat', () => { assert.match(pluginSource, /DropdownMenuTrigger/) - assert.match(pluginSource, /'New Agent'/) + assert.match(pluginSource, /'New Bot'/) assert.match(pluginSource, /'New Group Chat'/) }) test('source contract: create-group modal has search, checkboxes, name, create', () => { assert.match(pluginSource, /function CreateGroupChatDialog\(/) + // An outage placeholder preserves identity, but cannot receive a message. + assert.match(pluginSource, /const selectableRoster = roster\.filter\(bot => !bot\?\.ghost\)/) // Reuses the roster search filter so name/@handle/title all match. - assert.match(pluginSource, /const visible = filterBots\(roster, allMeta, query\)/) + assert.match(pluginSource, /const visible = filterBots\(selectableRoster, allMeta, query\)/) // Selection is checkbox-driven and capped at the room member limit. assert.match(pluginSource, /const atCap = selected\.length >= GROUP_CHAT_MAX_MEMBERS/) // Create requires 2+ members. Membership mutation is covered by the diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs index f97607159eae..c96b4ada0ef7 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs @@ -40,8 +40,11 @@ test('source contract: room picture persists and hydrates with the room record', }) test('source contract: room picture shows in the roster row and room header', () => { - // Roster row: picture wins over the fanned member faces when set. - assert.match(pluginSource, /children: room\.image\s*\n\s*\? jsx\('img', \{/) + // Roster row: one room picture wins over a generic room icon when set. + const rowSource = pluginSource.slice(pluginSource.indexOf('function GroupRow('), pluginSource.indexOf('function RosterSectionHeader(')) + assert.match(rowSource, /room\.image\s*\n\s*\? jsx\('img', \{/) + assert.match(rowSource, /name: 'organization'/) + assert.doesNotMatch(rowSource, /members\.slice\([^)]*\)\.map/) // Header: picture leads the title. assert.match(pluginSource, /Room picture \(set via Group settings\) leads the title when present/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/pane-dock-layout.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/pane-dock-layout.test.mjs index 75c70cdf2e89..8d4000349ddc 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/pane-dock-layout.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/pane-dock-layout.test.mjs @@ -8,10 +8,12 @@ import test from 'node:test' // so every boot re-homes a stacked install — no heal token, no // user-placed exemption (the retired one-shot heal left users who had // dragged panes stuck stacked forever); -// - the Cronjobs (routines) pane only exists while the Bots pane is on -// screen — registered/unregistered through the contribution disposer, -// driven by the feature-detected host.paneVisibility SDK export, with the -// always-registered fallback kept for older desktops. +// - the Cronjobs (routines) pane only exists while a BOT CHAT owns the main +// workspace and the Bots pane is on screen — registered/unregistered +// through the contribution disposer, driven by the feature-detected +// host.paneVisibility SDK export, with the always-registered fallback kept +// for older desktops. Cronjobs are bot-scoped, so the tile must not sit +// beside the ownerless Bots home or a group chat. const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') @@ -34,8 +36,12 @@ test('routines pane rides Bots visibility via feature-detected host.paneVisibili // Transitions register/unregister through the tracked disposer. assert.match(source, /unregisterRoutines \?\?= registerRoutinesPane\(\)/) assert.match(source, /unregisterRoutines\(\)\s*\n\s*unregisterRoutines = null/) - // The visibility listener must not survive plugin disable. - assert.match(source, /ctx\.onDispose\(stopRoutinesSync\)/) + // Ownership, not mere visibility: an actual bot chat must own the center. + assert.match(source, /if \(botChatOwnsWorkspace\(\)\) \{/) + // None of the lifecycle listeners may survive plugin disable. + assert.match(source, /ctx\.onDispose\(\(\) => \{\s*\n\s*stopSidebarSync\(\)/) + assert.match(source, /stopGroupSync\(\)/) + assert.match(source, /stopFocusSync\?\.\(\)/) }) test('older desktops without the SDK export keep the always-registered pane', () => { diff --git a/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs index f275ce93d21d..4a337ce5ee36 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/roster-groups.test.mjs @@ -125,6 +125,34 @@ test('groupChatMemberBots: seats local meta members plus stored remote descripto assert.equal(members[2], roster[2]) }) +test('groupChatMemberBots: stored descriptors beat presentation-only ghosts', () => { + const { groupChatMemberBots, $groupChats } = load() + const ghost = { + name: 'spark', + remoteSource: true, + sourceScoped: true, + ghost: true, + connectionId: 'c1', + connectionLabel: 'Workshop' + } + const stored = { + name: 'spark', + handle: 'spark-work', + title: 'Spark', + remoteSource: true, + sourceScoped: true, + connectionId: 'c1', + connectionLabel: 'Workshop' + } + $groupChats.set({ Research: { log: [], members: [stored] } }) + + const members = groupChatMemberBots('Research', [ghost], {}) + + assert.equal(members.length, 1) + assert.equal(members[0], stored) + assert.equal(members[0].handle, 'spark-work') +}) + test('durableGroupChatMembers: retains active and remote source identities', () => { const { durableGroupChatMembers } = load() const members = durableGroupChatMembers([ @@ -201,10 +229,11 @@ test('stripPreviewMarkdown: flattens bold, quotes, code, and links out of previe assert.equal(stripPreviewMarkdown(''), '') }) -test('source contract: the roster stays a flat list of bot and group rows', () => { - // Ordering is deliberately unchanged in this PR; sectioned ordering follows separately. - assert.doesNotMatch(pluginSource, /function groupRoster\(/) - assert.match(pluginSource, /rosterRows\.map\(row =>/) +test('source contract: the roster progressively groups multiple gateways and keeps group chats distinct', () => { + assert.match(pluginSource, /function rosterGatewaySections\(/) + assert.match(pluginSource, /options\.length <= 1/) + assert.match(pluginSource, /gatewayFilter !== 'all'/) + assert.match(pluginSource, /label: 'Group chats'/) assert.match(pluginSource, /function GroupRow\(/) assert.match(pluginSource, /onGroup: setGrouping/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs index fba9ed2f7477..89cc02fdef56 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs @@ -105,6 +105,7 @@ function renderRuntime() { ContextMenuItem: 'ContextMenuItem', ContextMenuSeparator: 'ContextMenuSeparator', ContextMenuTrigger: 'ContextMenuTrigger', + Tip: 'Tip', haptic: () => undefined, host: { state: { @@ -173,12 +174,12 @@ const DM_BOT = { } } -test('render: BotRow shows the sender badge and stripped DM preview', () => { +test('render: BotRow shows a clean stripped DM preview without delivery furniture', () => { const r = renderRuntime() const tree = r.__BotRow({ bot: DM_BOT, onEdit: () => undefined }) const text = textOf(tree) - assert.match(text, /@manager/) assert.match(text, /Learn-share/) + assert.doesNotMatch(text, /@manager/) assert.doesNotMatch(text, /Message from/) }) @@ -200,10 +201,11 @@ test('render: BotRow tolerates a fresh bot with no sessions yet', () => { const r = renderRuntime() const tree = r.__BotRow({ bot: { name: 'newbie', title: '', description: 'Fresh bot' }, onEdit: () => undefined }) const text = textOf(tree) - assert.match(text, /Fresh bot/) + assert.match(text, /Newbie/) + assert.doesNotMatch(text, /No conversations yet/) }) -test('render: a remote gateway name is not squeezed out by its handle', () => { +test('render: a remote bot keeps its identity while gateway details stay in the tooltip', () => { const r = renderRuntime() const tree = r.__BotRow({ bot: { @@ -215,15 +217,14 @@ test('render: a remote gateway name is not squeezed out by its handle', () => { }, onEdit: () => undefined }) - const name = findNode(tree, node => node.type === 'span' && textOf(node) === 'Studio over SSH') - const handle = findNode(tree, node => node.type === 'span' && textOf(node) === '@default-studio-over-ssh') + const name = findNode(tree, node => node.type === 'span' && textOf(node) === 'Hermes') + const button = findNode(tree, node => node.type === 'button' && node.props?.['aria-label']) assert.ok(name) - assert.match(name.props.className, /shrink-0/) - assert.ok(handle) - assert.match(handle.props.className, /min-w-0/) - assert.match(handle.props.className, /truncate/) - assert.doesNotMatch(handle.props.className, /shrink-0/) + assert.ok(button) + assert.match(button.props['aria-label'], /Hermes/) + assert.match(button.props['aria-label'], /Studio over SSH/) + assert.doesNotMatch(textOf(button), /Studio over SSH/) }) test('render: BotRow previews the pinned canonical chat, not an unrelated latest session', () => { From b8b6f43280430b91c5e59d93e7b16fca49347260 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:18:34 +0200 Subject: [PATCH 103/666] feat(desktop): define workspace-scoped pane ownership --- apps/desktop/src/app/chat/pane-mirror.test.ts | 80 +++++++++ apps/desktop/src/app/chat/pane-mirror.ts | 31 +++- apps/desktop/src/app/chat/preview-tile.tsx | 1 + apps/desktop/src/app/chat/route-tile.tsx | 1 + apps/desktop/src/app/chat/session-tile.tsx | 2 + apps/desktop/src/app/contrib/controller.tsx | 2 + apps/desktop/src/app/open-session.test.ts | 15 +- apps/desktop/src/app/open-session.ts | 27 ++- .../pane-shell/tree/renderer/tree-group.tsx | 32 +++- .../pane-shell/tree/renderer/tree-split.tsx | 8 +- .../tree/renderer/workspace-scope.test.tsx | 96 ++++++++++ .../pane-shell/workspace-scope.test.ts | 169 ++++++++++++++++++ .../components/pane-shell/workspace-scope.ts | 167 +++++++++++++++++ apps/desktop/src/contrib/index.ts | 2 +- apps/desktop/src/contrib/types.ts | 19 ++ apps/desktop/src/sdk/index.test.ts | 42 +++++ apps/desktop/src/sdk/index.ts | 63 +++++-- apps/desktop/src/sdk/profile-routing.test.ts | 20 +++ apps/desktop/src/store/session-states.test.ts | 68 ++++++- apps/desktop/src/store/session-states.ts | 79 +++++++- 20 files changed, 882 insertions(+), 42 deletions(-) create mode 100644 apps/desktop/src/app/chat/pane-mirror.test.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx create mode 100644 apps/desktop/src/components/pane-shell/workspace-scope.test.ts create mode 100644 apps/desktop/src/components/pane-shell/workspace-scope.ts diff --git a/apps/desktop/src/app/chat/pane-mirror.test.ts b/apps/desktop/src/app/chat/pane-mirror.test.ts new file mode 100644 index 000000000000..3b6ceacc07fe --- /dev/null +++ b/apps/desktop/src/app/chat/pane-mirror.test.ts @@ -0,0 +1,80 @@ +import { atom } from 'nanostores' +import { afterEach, describe, expect, it } from 'vitest' + +import { registry } from '@/contrib/registry' + +import { paneMirror } from './pane-mirror' + +interface Tile { + id: string + owner?: string +} + +const cleanupSources: Array>> = [] +let sequence = 0 + +function setup(options: { + workspaceMode?: 'sessions' | 'bots' | ((tile: Tile) => 'sessions' | 'bots' | undefined) + workspaceOwnerKey?: string | ((tile: Tile) => string | undefined) +}) { + const source = atom([]) + const prefix = `pane-mirror-scope-${sequence++}` + cleanupSources.push(source) + + paneMirror({ + source, + key: tile => tile.id, + prefix, + minWidth: '10rem', + title: key => key, + render: () => null, + close: () => undefined, + ...options + })() + + return { + source, + contribution: (id: string) => registry.getArea('panes').find(entry => entry.id === `${prefix}:${id}`) + } +} + +afterEach(() => { + for (const source of cleanupSources.splice(0)) { + source.set([]) + } +}) + +describe('paneMirror workspace scope', () => { + it('forwards a static workspace mode', () => { + const mirror = setup({ workspaceMode: 'sessions' }) + mirror.source.set([{ id: 'one' }]) + + expect(mirror.contribution('one')).toMatchObject({ + workspaceMode: 'sessions', + workspaceOwnerKey: undefined + }) + }) + + it('resolves owner callbacks per tile and refreshes an unchanged title', () => { + const mirror = setup({ + workspaceMode: 'bots', + workspaceOwnerKey: tile => tile.owner + }) + + mirror.source.set([{ id: 'one', owner: 'connection-a::default' }]) + expect(mirror.contribution('one')?.workspaceOwnerKey).toBe('connection-a::default') + + mirror.source.set([{ id: 'one', owner: 'connection-b::default' }]) + expect(mirror.contribution('one')?.workspaceOwnerKey).toBe('connection-b::default') + }) + + it('leaves existing callers unscoped when options are omitted', () => { + const mirror = setup({}) + mirror.source.set([{ id: 'one' }]) + + expect(mirror.contribution('one')).toMatchObject({ + workspaceMode: undefined, + workspaceOwnerKey: undefined + }) + }) +}) diff --git a/apps/desktop/src/app/chat/pane-mirror.ts b/apps/desktop/src/app/chat/pane-mirror.ts index 3a6995e1b4d6..2a2d5dfb4892 100644 --- a/apps/desktop/src/app/chat/pane-mirror.ts +++ b/apps/desktop/src/app/chat/pane-mirror.ts @@ -11,13 +11,23 @@ import type { ReactElement, ReactNode, PointerEvent as ReactPointerEvent } from import { registerPaneCloser, removeTreePane, treePanesWithPrefix } from '@/components/pane-shell/tree/store' import { registry } from '@/contrib/registry' +import type { WorkspaceMode } from '@/contrib/types' import type { TileDock } from '@/store/session-states' +type WorkspaceValue = V | ((tile: T) => V | undefined) + +const workspaceValue = (value: WorkspaceValue | undefined, tile: T): V | undefined => + typeof value === 'function' ? (value as (tile: T) => V | undefined)(tile) : value + export interface PaneMirror { /** Reactive source list. */ source: ReadableAtom /** Extra atoms whose changes should re-sync (e.g. titles living elsewhere). */ also?: ReadableAtom[] + /** Workspace surface this tile belongs to. Omit for a global pane. */ + workspaceMode?: WorkspaceValue + /** Exact opaque owner inside Bot Mode. Omit outside an owner-scoped pane. */ + workspaceOwnerKey?: WorkspaceValue /** Stable key + pane-id seed for a tile. */ key: (tile: T) => string /** Pane-id namespace — the id is `${prefix}:${key}`. */ @@ -51,7 +61,11 @@ export interface PaneMirror { /** Build a `watch*` fn: syncs once, then re-syncs on every source/also change. * Module-level state lives in the returned closure, so call it once per app. */ export function paneMirror(cfg: PaneMirror): () => void { - const registered = new Map void; title: string }>() + const registered = new Map< + string, + { dispose: () => void; title: string; workspaceMode?: WorkspaceMode; workspaceOwnerKey?: string } + >() + const paneId = (key: string) => `${cfg.prefix}:${key}` const sync = () => { @@ -61,10 +75,17 @@ export function paneMirror(cfg: PaneMirror): () => void { for (const tile of tiles) { const key = cfg.key(tile) const title = cfg.title(key) + const workspaceMode = workspaceValue(cfg.workspaceMode, tile) + const workspaceOwnerKey = workspaceValue(cfg.workspaceOwnerKey, tile) const current = registered.get(key) // register() replaces same-id in place — safe for live title refreshes. - if (current && current.title === title) { + if ( + current && + current.title === title && + current.workspaceMode === workspaceMode && + current.workspaceOwnerKey === workspaceOwnerKey + ) { continue } @@ -90,10 +111,12 @@ export function paneMirror(cfg: PaneMirror): () => void { : undefined, // returns boolean (handled) — see PaneChrome.tabDrag tabWrap: cfg.tabWrap ? (tab: ReactElement) => cfg.tabWrap!(key, tab) : undefined }, - render: () => cfg.render(key) + render: () => cfg.render(key), + workspaceMode, + workspaceOwnerKey }) - registered.set(key, { dispose, title }) + registered.set(key, { dispose, title, workspaceMode, workspaceOwnerKey }) if (!current) { registerPaneCloser(paneId(key), () => cfg.close(key)) diff --git a/apps/desktop/src/app/chat/preview-tile.tsx b/apps/desktop/src/app/chat/preview-tile.tsx index 7051fbe80f6c..4b6db09d9096 100644 --- a/apps/desktop/src/app/chat/preview-tile.tsx +++ b/apps/desktop/src/app/chat/preview-tile.tsx @@ -122,6 +122,7 @@ export function watchPreviewTiles(): void { const watchPreviewTileMirror = paneMirror<{ id: string }>({ source: $previewTabs, + workspaceMode: 'sessions', key: tab => tab.id, prefix: PREVIEW_TILE_PREFIX, // Identical to route (page) tiles: its own zone docked beside main, sized by diff --git a/apps/desktop/src/app/chat/route-tile.tsx b/apps/desktop/src/app/chat/route-tile.tsx index 1388f48c0425..f2af03527a24 100644 --- a/apps/desktop/src/app/chat/route-tile.tsx +++ b/apps/desktop/src/app/chat/route-tile.tsx @@ -86,6 +86,7 @@ function RouteTilePane({ path }: { path: string }) { /** Keep pane contributions mirroring `$routeTiles`. Call once from the root. */ export const watchRouteTiles = paneMirror({ source: $routeTiles, + workspaceMode: 'sessions', key: t => t.path, prefix: 'route-tile', dir: t => t.dir, diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index d2113ada5378..2adcc048a331 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -595,6 +595,8 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) * `$sessions`). Tiles dock against main on the chosen edge, flex width. */ export const watchSessionTiles = paneMirror({ source: $sessionTiles, + workspaceMode: tile => tile.workspaceMode ?? 'sessions', + workspaceOwnerKey: tile => tile.workspaceOwnerKey, // $projectTree: a tile whose session is older than the recents page resolves // its title through the tree, which loads after the tiles register. (The tab's // status dot subscribes to color/state itself, so it needs no `also` entry.) diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx index 79ba22d81729..2fe447b07805 100644 --- a/apps/desktop/src/app/contrib/controller.tsx +++ b/apps/desktop/src/app/contrib/controller.tsx @@ -176,6 +176,7 @@ registry.registerMany([ { id: 'workspace', area: 'panes', + workspaceMode: 'sessions', // Live-retitled to the loaded session by syncWorkspaceTitle below. title: NEW_SESSION_TITLE, data: { @@ -482,6 +483,7 @@ const syncWorkspaceTitle = () => { registry.register({ id: 'workspace', area: 'panes', + workspaceMode: 'sessions', // The placeholder, not the draft's live name — `tabTitle` below renders // that. Keeping it here would re-register the pane on every keystroke. title: stored ? storedSessionTitle(stored) : NEW_SESSION_TITLE, diff --git a/apps/desktop/src/app/open-session.test.ts b/apps/desktop/src/app/open-session.test.ts index 998bc33af000..d2d8339f324d 100644 --- a/apps/desktop/src/app/open-session.test.ts +++ b/apps/desktop/src/app/open-session.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const focusOpenSession = vi.fn() const openSessionTile = vi.fn() const reuseBlankDraftTile = vi.fn() +const setSessionTileWorkspaceScope = vi.fn() const openSessionInNewWindow = vi.fn() const canOpenSessionWindow = vi.fn(() => true) const workspaceIsPageGet = vi.fn(() => false) @@ -12,7 +13,8 @@ vi.mock('@/store/session-states', () => ({ !focused || (focused === 'main' && workspaceIsPage), focusOpenSession: (...args: unknown[]) => focusOpenSession(...args), openSessionTile: (...args: unknown[]) => openSessionTile(...args), - reuseBlankDraftTile: (...args: unknown[]) => reuseBlankDraftTile(...args) + reuseBlankDraftTile: (...args: unknown[]) => reuseBlankDraftTile(...args), + setSessionTileWorkspaceScope: (...args: unknown[]) => setSessionTileWorkspaceScope(...args) })) vi.mock('@/store/windows', () => ({ @@ -89,6 +91,7 @@ describe('openSession', () => { canOpenSessionWindow.mockReturnValue(true) workspaceIsPageGet.mockReturnValue(false) reuseBlankDraftTile.mockReset() + setSessionTileWorkspaceScope.mockReset() $activeSessionId.set(null) $selectedStoredSessionId.set(null) }) @@ -143,6 +146,16 @@ describe('openSession', () => { expect(navigate).not.toHaveBeenCalled() }) + it('threads an exact Bot owner into a new session tile', () => { + const scope = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::default' } + focusOpenSession.mockReturnValue(null) + + openSession('s1', navigate, 'tab', scope) + + expect(setSessionTileWorkspaceScope).toHaveBeenCalledWith('s1', scope) + expect(openSessionTile).toHaveBeenCalledWith('s1', 'center', undefined, undefined, scope) + }) + it('stack focuses a session that is already on screen', () => { $selectedStoredSessionId.set('s0') focusOpenSession.mockReturnValue('tile') diff --git a/apps/desktop/src/app/open-session.ts b/apps/desktop/src/app/open-session.ts index 34b3278969f0..0781cf001383 100644 --- a/apps/desktop/src/app/open-session.ts +++ b/apps/desktop/src/app/open-session.ts @@ -14,12 +14,14 @@ * - `window` (⇧⌘-click) — pop into its own window; falls back to `tab` when * the bridge has no session-window support. */ +import type { WorkspaceMode } from '@/contrib/types' import { $activeSessionId, $selectedStoredSessionId, markSessionRead } from '@/store/session' import { focusedSessionNeedsRoute, focusOpenSession, openSessionTile, - reuseBlankDraftTile + reuseBlankDraftTile, + setSessionTileWorkspaceScope } from '@/store/session-states' import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows' @@ -29,6 +31,11 @@ export type OpenSessionIntent = 'in-place' | 'main' | 'stack' | 'tab' | 'window' export type OpenSessionNavigate = (to: string, options?: { replace?: boolean }) => void +export interface OpenSessionWorkspaceScope { + workspaceMode: WorkspaceMode + workspaceOwnerKey?: string +} + /** * Is the main tab holding a conversation worth preserving? * @@ -72,7 +79,8 @@ export function openSessionIntentFromModifiers( export function openSession( storedSessionId: string, navigate: OpenSessionNavigate, - intent: OpenSessionIntent = 'in-place' + intent: OpenSessionIntent = 'in-place', + workspaceScope: OpenSessionWorkspaceScope = { workspaceMode: 'sessions' } ): void { if (!storedSessionId) { return @@ -83,6 +91,8 @@ export function openSession( // already on screen (open tile, or the main session) would otherwise return // at focusOpenSession and never clear its unread dot. markSessionRead(storedSessionId) + setSessionTileWorkspaceScope(storedSessionId, workspaceScope) + const botWorkspaceScope = workspaceScope.workspaceMode === 'bots' ? workspaceScope : undefined let resolved: OpenSessionIntent = intent @@ -128,11 +138,20 @@ export function openSession( // Nothing to jump to, but an open tab may still be an empty "New session" — // that's the tab the user would have typed into, so spend it rather than // stacking a second blank one beside it. - if (spendBlankDraft && reuseBlankDraftTile(storedSessionId)) { + if ( + spendBlankDraft && + (botWorkspaceScope + ? reuseBlankDraftTile(storedSessionId, botWorkspaceScope) + : reuseBlankDraftTile(storedSessionId)) + ) { return } - openSessionTile(storedSessionId, 'center') + if (botWorkspaceScope) { + openSessionTile(storedSessionId, 'center', undefined, undefined, botWorkspaceScope) + } else { + openSessionTile(storedSessionId, 'center') + } return } diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index bc1e7b90eff1..4e021c587344 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -10,7 +10,7 @@ */ import { useStore } from '@nanostores/react' -import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react' +import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react' import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu' import { Codicon } from '@/components/ui/codicon' @@ -35,6 +35,14 @@ import { $layoutEditMode } from '../../edit-mode' import { useWindowControlsOverlap } from '../../geometry' import { emptyPaneLifecycleState, reconcilePaneLifecycle } from '../../pane-lifecycle' import { hiddenPaneProps, PaneGroupContext, PaneLifecycleContext, PaneVisibleContext } from '../../pane-visibility' +import { + $workspaceMode, + $workspaceOwnerKey, + contributesToWorkspace, + rememberActivePane, + resolveRememberedActivePane, + workspaceScopeKey +} from '../../workspace-scope' import type { DropPosition, GroupNode } from '../model' import { $dropHint, @@ -228,6 +236,8 @@ export function TreeGroup({ const hiddenPanes = useStore($hiddenTreePanes) const narrow = useStore($narrowViewport) + const workspaceMode = useStore($workspaceMode) + const workspaceOwnerKey = useStore($workspaceOwnerKey) const newSessionTabAction = useStore($newSessionTabAction) const panesWithCloser = useStore($panesWithCloser) // Multi-tab selection (⌥/Ctrl-click, Shift-click) — null for every zone but @@ -245,12 +255,26 @@ export function TreeGroup({ // Edit mode forces toggle-hidden panes visible so they can be rearranged // (mirrors tree-split's paneGone) — restores itself on exit. const paneShown = (id: string) => - Boolean(paneFor(id)) && (editMode || !hiddenPanes.has(id)) && !(narrow && paneChrome(paneFor(id)).collapsible) + Boolean(paneFor(id)) && + contributesToWorkspace(paneFor(id), workspaceMode, workspaceOwnerKey) && + (editMode || !hiddenPanes.has(id)) && + !(narrow && paneChrome(paneFor(id)).collapsible) const shown = node.panes.filter(paneShown) - const activeId = shown.includes(node.active) ? node.active : (shown[0] ?? node.active) + const memoryKey = workspaceScopeKey(workspaceMode, workspaceOwnerKey) + + const activeId = shown.includes(node.active) + ? node.active + : (resolveRememberedActivePane(memoryKey, shown) ?? shown[0] ?? '') + const active = paneFor(activeId) - const isEmpty = node.panes.length === 0 + const isEmpty = shown.length === 0 + + useEffect(() => { + if (activeId) { + rememberActivePane(memoryKey, activeId) + } + }, [activeId, memoryKey]) // BOUNDED KEEP-ALIVE: the active pane is visible, a small per-zone LRU stays // hot-hidden, and older panes park (unmount). This preserves fast tab diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx index e0a3772b8569..1e97a661f3d2 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx @@ -17,6 +17,7 @@ import { cn } from '@/lib/utils' import { $paneStates, type PaneStateSnapshot, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes' import { $layoutEditMode } from '../../edit-mode' +import { $workspaceMode, $workspaceOwnerKey, contributesToWorkspace } from '../../workspace-scope' import type { LayoutNode, SplitNode } from '../model' import { allPaneIds } from '../model' import { @@ -90,6 +91,8 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo const panes = useContributions('panes') const hiddenPanes = useStore($hiddenTreePanes) const narrow = useStore($narrowViewport) + const workspaceMode = useStore($workspaceMode) + const workspaceOwnerKey = useStore($workspaceOwnerKey) // Scoped to THIS subtree's panes: a sash drag writes size overrides on every // pointermove, but only the splits whose subtree actually resized should // re-render — not every split in the tree. @@ -124,7 +127,10 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo // closed) visible so they're rearrangeable — only truly-absent (unregistered) // or narrow-collapsed panes stay gone. Restores itself on exit (render-only). const paneGone = (id: string) => - !paneFor(id) || (!editMode && hiddenPanes.has(id)) || (narrow && Boolean(paneChrome(paneFor(id)).collapsible)) + !paneFor(id) || + !contributesToWorkspace(paneFor(id), workspaceMode, workspaceOwnerKey) || + (!editMode && hiddenPanes.has(id)) || + (narrow && Boolean(paneChrome(paneFor(id)).collapsible)) const trackCtx: TrackContext = { paneFor, paneGone, overrides } diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx new file mode 100644 index 000000000000..310d85943b69 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx @@ -0,0 +1,96 @@ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { setWorkspaceScope } from '@/components/pane-shell/workspace-scope' +import { registry } from '@/contrib/registry' + +import type { GroupNode } from '../model' + +import { TreeGroup } from './tree-group' + +let root: null | Root = null +let container: HTMLDivElement | null = null +const disposers: Array<() => void> = [] + +function render(ui: ReactNode) { + if (!container) { + container = globalThis.document.createElement('div') + globalThis.document.body.append(container) + root = createRoot(container) + } + + act(() => root!.render(ui)) +} + +function register(id: string, title: string, scope: { workspaceMode?: 'sessions' | 'bots'; workspaceOwnerKey?: string } = {}) { + disposers.push( + registry.register({ + area: 'panes', + data: { placement: 'main' }, + id, + render: () =>
{title} content
, + title, + ...scope + }) + ) +} + +const group = (active: string): GroupNode => ({ + active, + id: 'workspace-scope-zone', + panes: ['session-a', 'bot-a', 'bot-b'], + tabStrip: 'always', + type: 'group' +}) + +const visibleTabs = () => + [...globalThis.document.querySelectorAll('[data-tree-tab]')].map(tab => tab.dataset.treeTab) + +afterEach(() => { + if (root) { + act(() => root!.unmount()) + } + + container?.remove() + disposers.splice(0).forEach(dispose => dispose()) + act(() => { + setWorkspaceScope('sessions') + }) + root = null + container = null + vi.unstubAllGlobals() +}) + +describe('TreeGroup workspace scope', () => { + it('renders only the current workspace owner and restores owner activity', () => { + vi.stubGlobal('CSS', { escape: (value: string) => value }) + register('session-a', 'Session A', { workspaceMode: 'sessions' }) + register('bot-a', 'Bot A', { workspaceMode: 'bots', workspaceOwnerKey: 'connection-a::default' }) + register('bot-b', 'Bot B', { workspaceMode: 'bots', workspaceOwnerKey: 'connection-b::default' }) + + render() + expect(visibleTabs()).toEqual(['session-a']) + expect(container?.textContent).toContain('Session A content') + + act(() => setWorkspaceScope('bots', 'connection-a::default')) + render() + expect(visibleTabs()).toEqual(['bot-a']) + expect(container?.textContent).toContain('Bot A content') + + act(() => setWorkspaceScope('bots', 'connection-b::default')) + render() + expect(visibleTabs()).toEqual(['bot-b']) + expect(container?.textContent).toContain('Bot B content') + + act(() => setWorkspaceScope('bots', 'connection-a::default')) + render() + expect(visibleTabs()).toEqual(['bot-a']) + expect(container?.textContent).toContain('Bot A content') + + act(() => setWorkspaceScope('sessions')) + render() + expect(visibleTabs()).toEqual(['session-a']) + expect(container?.textContent).toContain('Session A content') + }) +}) diff --git a/apps/desktop/src/components/pane-shell/workspace-scope.test.ts b/apps/desktop/src/components/pane-shell/workspace-scope.test.ts new file mode 100644 index 000000000000..7aae790a3723 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/workspace-scope.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + $workspaceMode, + $workspaceOwnerKey, + contributesToWorkspace, + filterContributionsForWorkspace, + forgetActivePane, + forgetRememberedPane, + rememberActivePane, + resetRememberedActivePanes, + resolveRememberedActivePane, + setWorkspaceScope +} from './workspace-scope' + +interface ScopedContribution { + id: string + workspaceMode?: 'sessions' | 'bots' + workspaceOwnerKey?: string +} + +const contribution = ( + id: string, + scope?: Pick +): ScopedContribution => ({ id, ...scope }) + +const bot = (ownerKey: string, suffix: string) => + contribution(`bot:${suffix}`, { + workspaceMode: 'bots', + workspaceOwnerKey: ownerKey + }) + +afterEach(() => { + setWorkspaceScope('sessions') +}) + +describe('workspace scope', () => { + it('defaults to the un-switched sessions window state', () => { + expect($workspaceMode.get()).toBe('sessions') + expect($workspaceOwnerKey.get()).toBeNull() + }) + + it('publishes a coherent mode and owner in one batch', () => { + const snapshots: Array<['sessions' | 'bots', string | null]> = [] + const capture = () => snapshots.push([$workspaceMode.get(), $workspaceOwnerKey.get()]) + const unbindMode = $workspaceMode.listen(capture) + const unbindOwner = $workspaceOwnerKey.listen(capture) + snapshots.length = 0 + + expect(setWorkspaceScope('bots', 'connection-a::default')).toBe(true) + expect(snapshots.length).toBeGreaterThan(0) + expect(snapshots.every(snapshot => snapshot[0] === 'bots' && snapshot[1] === 'connection-a::default')).toBe(true) + expect(setWorkspaceScope('bots', 'connection-a::default')).toBe(false) + + unbindMode() + unbindOwner() + }) + + it('keeps global contributions visible in both modes', () => { + expect(contributesToWorkspace(undefined, 'sessions', null)).toBe(true) + expect(contributesToWorkspace(undefined, 'bots', 'bot-a')).toBe(true) + }) + + it('separates sessions and bots contributions by mode', () => { + const sessionsOnly = contribution('sessions-pane', { workspaceMode: 'sessions' }) + const botsOnly = bot('bot-a', 'pane') + + expect(contributesToWorkspace(sessionsOnly, 'sessions')).toBe(true) + expect(contributesToWorkspace(sessionsOnly, 'bots', 'bot-a')).toBe(false) + expect(contributesToWorkspace(botsOnly, 'sessions')).toBe(false) + expect(contributesToWorkspace(botsOnly, 'bots', 'bot-a')).toBe(true) + }) + + it('requires an exact non-empty owner match for bots contributions', () => { + const scoped = bot('bot-a', 'pane') + + expect(contributesToWorkspace(scoped, 'bots', 'bot-b')).toBe(false) + expect(contributesToWorkspace(scoped, 'bots', null)).toBe(false) + expect(contributesToWorkspace(scoped, 'bots', '')).toBe(false) + expect(contributesToWorkspace(scoped, 'bots', 'bot-a')).toBe(true) + + // A bots-scoped contribution with an empty owner key never participates. + const noOwner = contribution('no-owner', { workspaceMode: 'bots' }) + expect(contributesToWorkspace(noOwner, 'bots', 'bot-a')).toBe(false) + }) + + it('does not collide on shared profile suffixes across connection-qualified keys', () => { + // Same profile suffix, different connections — opaque exact strings only. + const localProfile = bot('local:main', 'main') + const remoteProfile = bot('ssh:server:main', 'main') + + expect(contributesToWorkspace(localProfile, 'bots', 'ssh:server:main')).toBe(false) + expect(contributesToWorkspace(remoteProfile, 'bots', 'local:main')).toBe(false) + expect(contributesToWorkspace(localProfile, 'bots', 'local:main')).toBe(true) + expect(contributesToWorkspace(remoteProfile, 'bots', 'ssh:server:main')).toBe(true) + }) +}) + +describe('filterContributionsForWorkspace', () => { + it('filters to the current mode and preserves input order', () => { + const contributions = [ + bot('bot-a', 'zeta'), + contribution('global-1'), + contribution('sessions-only', { workspaceMode: 'sessions' }), + bot('bot-a', 'alpha') + ] + + expect(filterContributionsForWorkspace(contributions, 'bots', 'bot-a').map(c => c.id)).toEqual([ + 'bot:zeta', + 'global-1', + 'bot:alpha' + ]) + expect( + filterContributionsForWorkspace(contributions, 'sessions', null).map(c => c.id) + ).toEqual(['global-1', 'sessions-only']) + }) + + it('returns the original array reference on a no-op', () => { + const contributions: ScopedContribution[] = [contribution('a'), contribution('b')] + + expect(filterContributionsForWorkspace(contributions, 'sessions', null)).toBe(contributions) + expect(filterContributionsForWorkspace(contributions, 'bots', 'anything').length).toBe(2) + }) +}) + +describe('remembered active panes', () => { + beforeEach(() => resetRememberedActivePanes()) + + it('remembers and restores panes independently per owner key', () => { + rememberActivePane('conn-a:profile-x', 'pane-1') + rememberActivePane('conn-b:profile-y', 'pane-2') + + expect(resolveRememberedActivePane('conn-a:profile-x', ['pane-1', 'pane-2'])).toBe('pane-1') + expect(resolveRememberedActivePane('conn-b:profile-y', ['pane-1', 'pane-2'])).toBe('pane-2') + }) + + it('does not collide on a shared profile suffix across owner keys', () => { + rememberActivePane('local:main', 'pane-local') + + expect(resolveRememberedActivePane('ssh:server:main', [])).toBeNull() + }) + + it('falls back after the remembered pane is removed', () => { + rememberActivePane('bot-a', 'pane-gone') + + expect(resolveRememberedActivePane('bot-a', ['first', 'second'])).toBe('first') + expect(resolveRememberedActivePane('bot-a', [])).toBeNull() + }) + + it('forgets a single owner without touching others', () => { + rememberActivePane('bot-a', 'pane-a') + rememberActivePane('bot-b', 'pane-b') + + forgetActivePane('bot-a') + + expect(resolveRememberedActivePane('bot-a', ['fallback-a', 'pane-a'])).toBe('fallback-a') + expect(resolveRememberedActivePane('bot-b', ['pane-a', 'pane-b'])).toBe('pane-b') + }) + + it('forgets a removed pane across every owner that remembered it', () => { + rememberActivePane('bot-a', 'pane-gone') + rememberActivePane('bot-b', 'pane-gone') + + forgetRememberedPane('pane-gone') + + expect(resolveRememberedActivePane('bot-a', ['fallback-a'])).toBe('fallback-a') + expect(resolveRememberedActivePane('bot-b', ['fallback-b'])).toBe('fallback-b') + }) +}) diff --git a/apps/desktop/src/components/pane-shell/workspace-scope.ts b/apps/desktop/src/components/pane-shell/workspace-scope.ts new file mode 100644 index 000000000000..3980fc3f1557 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/workspace-scope.ts @@ -0,0 +1,167 @@ +/** + * Workspace scoping for contributions. + * + * Pure presentation-ownership helpers: which workspace surface (sessions vs + * bots) a contribution belongs to, and — within the bots surface — which exact + * bot it belongs to. Owner keys are opaque exact strings supplied by callers; + * this module never parses profile names or infers connections. + * + * No persistence here by design: the remembered active-pane map is window-local + * memory so a switch away and back can restore where the user was, without any + * of it surviving the window. + */ + +import { atom, batch } from 'nanostores' + +import type { WorkspaceMode } from '../../contrib/types' + +/** Re-exported so workspace consumers can import it from here. */ +export type { WorkspaceMode } from '../../contrib/types' + +/** Default workspace mode when the host has not switched surfaces. */ +export const $workspaceMode = atom('sessions') + +/** Default workspace owner key: none (unscoped / global ownership). */ +export const $workspaceOwnerKey = atom(null) + +/** One key for window-local active-pane memory. Owner keys stay opaque. */ +export function workspaceScopeKey(mode: WorkspaceMode, ownerKey: string | null): string { + return mode === 'sessions' ? 'sessions' : `bots:${ownerKey ?? ''}` +} + +/** Publish one coherent presentation scope without an intermediate mixed frame. */ +export function setWorkspaceScope(mode: WorkspaceMode, ownerKey: string | null = null): boolean { + const nextOwnerKey = mode === 'bots' ? ownerKey : null + + if ($workspaceMode.get() === mode && $workspaceOwnerKey.get() === nextOwnerKey) { + return false + } + + batch(() => { + $workspaceMode.set(mode) + $workspaceOwnerKey.set(nextOwnerKey) + }) + + return true +} + +/** + * The slice of {@link Contribution} metadata that scopes it to a workspace. + * A contribution with neither field set is global: it participates in every + * workspace, preserving pre-existing behavior. + */ +export interface WorkspaceScope { + /** Surface this contribution belongs to. Omit for global visibility. */ + workspaceMode?: WorkspaceMode + /** Exact opaque owner key within the `'bots'` surface. Ignored otherwise. */ + workspaceOwnerKey?: string +} + +/** + * Whether a contribution participates in the given workspace. + * + * - Unscoped/global (no `workspaceMode`) => always participates. + * - Scoped with a mode mismatch => does not participate. + * - Sessions match => participates. + * - Bots match => participates only when the owner key is non-empty and equals + * the current owner key (exact string equality). + * + * Defaults reflect the un-switched window state when omitted. + */ +export function contributesToWorkspace( + scope: WorkspaceScope | undefined, + mode: WorkspaceMode = $workspaceMode.get(), + ownerKey: string | null = $workspaceOwnerKey.get() +): boolean { + const { workspaceMode, workspaceOwnerKey } = scope ?? {} + + if (workspaceMode == null) { + return true + } + + if (workspaceMode !== mode) { + return false + } + + if (workspaceMode === 'sessions') { + return true + } + + return Boolean(workspaceOwnerKey) && workspaceOwnerKey === ownerKey +} + +/** + * Filter contributions down to those participating in the given workspace, + * preserving input order. + * + * Preserves reference identity on a no-op (every contribution participates), + * so callers can hand the result straight to React without a wasted re-render. + */ +export function filterContributionsForWorkspace( + contributions: readonly T[], + mode: WorkspaceMode, + ownerKey: string | null +): readonly T[] { + let filtered: T[] | null = null + + for (let i = 0; i < contributions.length; i += 1) { + if (contributesToWorkspace(contributions[i], mode, ownerKey)) { + filtered?.push(contributions[i]) + + continue + } + + filtered ??= contributions.slice(0, i) + } + + return filtered ?? contributions +} + +/** + * Window-local memory of the active pane per exact workspace owner key. + * Keys are opaque exact strings; similar-looking keys never collide because + * nothing here parses them. + */ +const rememberedActivePanes = new Map() + +/** Remember which pane was active for an exact owner key. */ +export function rememberActivePane(ownerKey: string, paneId: string): void { + rememberedActivePanes.set(ownerKey, paneId) +} + +/** + * Resolve the pane to activate for an owner key against the currently eligible + * panes. A remembered pane that has since been removed must not restore: the + * fallback is the first eligible pane, or null when none are eligible. + */ +export function resolveRememberedActivePane( + ownerKey: string, + eligiblePaneIds: readonly string[] +): string | null { + const remembered = rememberedActivePanes.get(ownerKey) + + if (remembered != null && eligiblePaneIds.includes(remembered)) { + return remembered + } + + return eligiblePaneIds[0] ?? null +} + +/** Forget the remembered pane for one owner key. */ +export function forgetActivePane(ownerKey: string): void { + rememberedActivePanes.delete(ownerKey) +} + +/** Forget a pane removed from the layout, regardless of which owners used it. */ +export function forgetRememberedPane(paneId: string): void { + for (const [ownerKey, rememberedPaneId] of rememberedActivePanes) { + if (rememberedPaneId === paneId) { + rememberedActivePanes.delete(ownerKey) + } + } +} + +/** Test-only: clear all remembered panes. */ +export function resetRememberedActivePanes(): void { + rememberedActivePanes.clear() +} diff --git a/apps/desktop/src/contrib/index.ts b/apps/desktop/src/contrib/index.ts index a5faa2f232b5..f39d9879125a 100644 --- a/apps/desktop/src/contrib/index.ts +++ b/apps/desktop/src/contrib/index.ts @@ -3,4 +3,4 @@ export type { SlotProps } from './react/slot' export { useContributions } from './react/use-contributions' export { registry } from './registry' -export type { Contribution, ContributionSource } from './types' +export type { Contribution, ContributionSource, WorkspaceMode } from './types' diff --git a/apps/desktop/src/contrib/types.ts b/apps/desktop/src/contrib/types.ts index c46ee3e9929e..0958073bfabc 100644 --- a/apps/desktop/src/contrib/types.ts +++ b/apps/desktop/src/contrib/types.ts @@ -9,6 +9,13 @@ import type { ReactNode } from 'react' */ export type ContributionSource = 'core' | (string & {}) +/** + * Which workspace surface a contribution targets. `'sessions'` is the classic + * session workspace; `'bots'` scopes content to an individual bot identified + * by an opaque owner key. + */ +export type WorkspaceMode = 'sessions' | 'bots' + /** * The single, uniform primitive every surface consumes. A bar renders these as * inline items via ``; a dock renders them as stacked/tabbed panes via @@ -40,4 +47,16 @@ export interface Contribution { * themes, commands — anything consumed by an engine rather than rendered. */ data?: unknown + /** + * Which workspace surface this contribution belongs to. Omit for a global + * contribution that participates in every workspace (pre-existing behavior). + * Presentation ownership hint only. + */ + workspaceMode?: WorkspaceMode + /** + * Exact opaque owner key within the `'bots'` surface (e.g. a + * connection-qualified profile id). Never parsed here; compared exactly. + * Presentation ownership hint only. + */ + workspaceOwnerKey?: string } diff --git a/apps/desktop/src/sdk/index.test.ts b/apps/desktop/src/sdk/index.test.ts index f7f879d4e462..0817188888f2 100644 --- a/apps/desktop/src/sdk/index.test.ts +++ b/apps/desktop/src/sdk/index.test.ts @@ -173,3 +173,45 @@ describe('host.connections', () => { await expect(host.connections()).rejects.toThrow('This Desktop build has no connection registry') }) }) + +describe('host workspace scope', () => { + afterEach(async () => { + host.setWorkspaceScope('sessions') + const tree = await import('@/components/pane-shell/tree/store') + tree.removeTreePane('plugin-workspace:scope-test') + }) + + it('registers plugin workspace ownership and chrome options', async () => { + const { registry } = await import('@/contrib/registry') + + const close = host.openWorkspace('scope-test', { + dock: { pane: 'workspace', pos: 'right' }, + headerVeto: true, + render: () => null, + title: 'Scoped', + uncloseable: true, + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-a::default' + }) + + expect(registry.getArea('panes').find(pane => pane.id === 'plugin-workspace:scope-test')).toMatchObject({ + data: { + dock: { pane: 'workspace', pos: 'right' }, + headerVeto: true, + uncloseable: true + }, + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-a::default' + }) + + close() + }) + + it('publishes the active workspace scope through one host seam', async () => { + const { $workspaceMode, $workspaceOwnerKey } = await import('@/components/pane-shell/workspace-scope') + + expect(host.setWorkspaceScope('bots', 'connection-b::default')).toBe(true) + expect($workspaceMode.get()).toBe('bots') + expect($workspaceOwnerKey.get()).toBe('connection-b::default') + }) +}) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index c7e4328bd49e..210448cef924 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -31,8 +31,10 @@ import { removeTreePane, revealTreePane } from '@/components/pane-shell/tree/store' +import { setWorkspaceScope as publishWorkspaceScope } from '@/components/pane-shell/workspace-scope' import { onGatewayEvent } from '@/contrib/events' import { registry } from '@/contrib/registry' +import type { WorkspaceMode } from '@/contrib/types' import { deleteProfile, getLogs, getStatus, type HermesGateway } from '@/hermes' import { $gateway, @@ -285,6 +287,8 @@ export interface PluginOpenSessionOptions { keepAllProfilesScope?: boolean profile?: null | string route?: PluginProfileRoute + workspaceMode?: WorkspaceMode + workspaceOwnerKey?: string /** A cold profile backend can lose the hydration-timeout race once and still * be fine on a second try. When set, a hydration timeout is retried * internally before it reaches the caller or arms the core stranded-session @@ -730,19 +734,26 @@ export const host = { // again inside the same wake — that is the Retry surface's job. for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - openSession( - storedSessionId, - (to: string, opts?: { replace?: boolean }) => { - const target = to.startsWith('#') ? to : `#${to}` - - if (opts?.replace) { - window.location.replace(target) - } else { - window.location.hash = target - } - }, - options.intent ?? 'in-place' - ) + const navigate = (to: string, opts?: { replace?: boolean }) => { + const target = to.startsWith('#') ? to : `#${to}` + + if (opts?.replace) { + window.location.replace(target) + } else { + window.location.hash = target + } + } + + const intent = options.intent ?? 'in-place' + + if (options.workspaceMode === 'bots') { + openSession(storedSessionId, navigate, intent, { + workspaceMode: 'bots', + workspaceOwnerKey: options.workspaceOwnerKey + }) + } else { + openSession(storedSessionId, navigate, intent) + } // Judge the main surface AFTER the open: on a cold start the persisted // route can already point at this session while selection has not @@ -846,7 +857,17 @@ export const host = { * fallback. */ openWorkspace: ( id: string, - options: { minWidth?: string; onClose?: () => void; render: () => ReactNode; title?: string } + options: { + dock?: { before?: null | string; pane: string; pos: 'bottom' | 'center' | 'left' | 'right' | 'top' } + headerVeto?: boolean + minWidth?: string + onClose?: () => void + render: () => ReactNode + title?: string + uncloseable?: boolean + workspaceMode?: WorkspaceMode + workspaceOwnerKey?: string + } ): (() => void) => { const key = (id ?? '').trim() @@ -861,13 +882,17 @@ export const host = { data: { // The session-tile shape: a full workspace surface docked beside main, // closeable so it keeps its tab when it lands in a zone of its own. - dock: { pane: 'workspace', pos: 'center' }, + dock: options.dock ?? { pane: 'workspace', pos: 'center' }, + headerVeto: options.headerVeto, minWidth: options.minWidth ?? '22rem', - placement: 'main' + placement: 'main', + uncloseable: options.uncloseable }, id: paneId, render: options.render, - title: options.title ?? key + title: options.title ?? key, + workspaceMode: options.workspaceMode, + workspaceOwnerKey: options.workspaceOwnerKey }) const close = () => { @@ -886,6 +911,10 @@ export const host = { return close }, + /** Switch the visible main-pane workspace without unregistering retained panes. */ + setWorkspaceScope: (mode: WorkspaceMode, ownerKey: null | string = null): boolean => + publishWorkspaceScope(mode, ownerKey), + /** Start a fresh chat draft, optionally pointed at another profile (its * backend spins up in the background — same door the sidebar's per-profile * "+" uses). */ diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index 6ab9fa07fc3c..fd6f14e6ecf7 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -413,6 +413,26 @@ describe('profile-aware plugin session opens', () => { expect(openSessionCore).toHaveBeenCalledWith('remote-chat', expect.any(Function), 'in-place') }) + it('threads an exact Bot workspace owner into the core session open', async () => { + const route = { + connectionId: 'source-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'backend-default' + } + + await host.openSession('bot-chat', { + route, + workspaceMode: 'bots', + workspaceOwnerKey: 'source-a::default' + }) + + expect(openSessionCore).toHaveBeenCalledWith('bot-chat', expect.any(Function), 'in-place', { + workspaceMode: 'bots', + workspaceOwnerKey: 'source-a::default' + }) + }) + it('waits until the target Bot Chat runtime and history are on main before resolving', async () => { vi.mocked(openGatewayForProfile).mockImplementationOnce(async () => undefined) diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts index b3173aacd01e..3cf175250746 100644 --- a/apps/desktop/src/store/session-states.test.ts +++ b/apps/desktop/src/store/session-states.test.ts @@ -12,12 +12,15 @@ import { focusedSessionNeedsRoute, markSelectionRestore, nextSessionTileForWorkspace, + openSessionTile, orderTilesByTree, + patchSessionTile, releaseSessionTranscript, resetTileRuntimeBindings, selectionHomesToWorkspace, type SessionTileDelegate, - setSessionTileDelegate + setSessionTileDelegate, + setSessionTileWorkspaceScope } from '@/store/session-states' const tile = (storedSessionId: string): SessionTile => ({ storedSessionId }) @@ -53,6 +56,69 @@ describe('resetTileRuntimeBindings', () => { }) }) +describe('SessionTile workspace scope', () => { + afterEach(() => { + $layoutTree.set(null) + $selectedStoredSessionId.set(null) + $sessionTiles.set([]) + }) + + it('stores an exact Bot owner and keeps it through placement patches', () => { + const scope = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::default' } + + openSessionTile('bot-chat', 'right', undefined, undefined, scope) + patchSessionTile('bot-chat', { dir: 'left' }) + + expect($sessionTiles.get()).toEqual([ + expect.objectContaining({ + dir: 'left', + storedSessionId: 'bot-chat', + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-a::default' + }) + ]) + }) + + it('re-scopes an existing tile without changing its placement', () => { + openSessionTile('chat', 'bottom', 'workspace') + + expect( + setSessionTileWorkspaceScope('chat', { + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-b::default' + }) + ).toBe(true) + expect($sessionTiles.get()[0]).toMatchObject({ + anchor: 'workspace', + dir: 'bottom', + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-b::default' + }) + }) + + it('preserves workspace scope while dropping a stale runtime binding', () => { + $sessionTiles.set([ + { + runtimeId: 'runtime-dead', + storedSessionId: 'bot-chat', + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-a::default' + } + ]) + + resetTileRuntimeBindings() + + expect($sessionTiles.get()[0]).toEqual({ + anchor: undefined, + before: undefined, + dir: undefined, + storedSessionId: 'bot-chat', + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-a::default' + }) + }) +}) + describe('releaseSessionTranscript', () => { afterEach(() => { $sessionStates.set({}) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index b570a5056e74..e2570c1bd2f9 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -29,6 +29,7 @@ import { noteActiveTreeGroup, revealTreePane } from '@/components/pane-shell/tree/store' +import type { WorkspaceMode } from '@/contrib/types' import { stableArray } from '@/lib/stable-array' import { readJson, writeJson } from '@/lib/storage' import type { SessionInfo } from '@/types/hermes' @@ -535,6 +536,15 @@ export interface SessionTile { runtimeId?: string /** Resume failed terminally (shown in the tile; retryable). */ error?: string + /** Presentation workspace this tab belongs to. Missing legacy values are Sessions. */ + workspaceMode?: WorkspaceMode + /** Exact opaque owner key for Bot Mode tabs. */ + workspaceOwnerKey?: string +} + +export interface SessionTileWorkspaceScope { + workspaceMode: WorkspaceMode + workspaceOwnerKey?: string } // Tiles are persisted PER PROFILE: a session belongs to one profile, and the @@ -550,13 +560,18 @@ const TILE_PANE_PREFIX = 'session-tile:' /** Persisted placement — `dir` + strip slot (`before`) + dock `anchor` so a * restart / profile swap re-adopts tiles in the same order, not all stacked * right of workspace. */ -type StoredTile = Pick +type StoredTile = Pick< + SessionTile, + 'anchor' | 'before' | 'dir' | 'storedSessionId' | 'workspaceMode' | 'workspaceOwnerKey' +> const toStored = (t: SessionTile): StoredTile => ({ anchor: t.anchor, before: t.before, dir: t.dir, - storedSessionId: t.storedSessionId + storedSessionId: t.storedSessionId, + ...(t.workspaceMode ? { workspaceMode: t.workspaceMode } : {}), + ...(t.workspaceOwnerKey ? { workspaceOwnerKey: t.workspaceOwnerKey } : {}) }) function parseTileList(value: unknown): StoredTile[] { @@ -570,7 +585,12 @@ function parseTileList(value: unknown): StoredTile[] { anchor: typeof raw.anchor === 'string' ? raw.anchor : undefined, before: typeof raw.before === 'string' || raw.before === null ? raw.before : undefined, dir: raw.dir, - storedSessionId: raw.storedSessionId + storedSessionId: raw.storedSessionId, + workspaceMode: raw.workspaceMode === 'bots' ? 'bots' : 'sessions', + workspaceOwnerKey: + raw.workspaceMode === 'bots' && typeof raw.workspaceOwnerKey === 'string' + ? raw.workspaceOwnerKey + : undefined } }) : [] @@ -653,6 +673,25 @@ export function patchSessionTile(storedSessionId: string, patch: Partial (t.storedSessionId === storedSessionId ? { ...t, ...patch } : t))) } +export function setSessionTileWorkspaceScope( + storedSessionId: string, + scope: SessionTileWorkspaceScope +): boolean { + const tile = $sessionTiles.get().find(candidate => candidate.storedSessionId === storedSessionId) + const workspaceOwnerKey = scope.workspaceMode === 'bots' ? scope.workspaceOwnerKey : undefined + + if ( + !tile || + ((tile.workspaceMode ?? 'sessions') === scope.workspaceMode && tile.workspaceOwnerKey === workspaceOwnerKey) + ) { + return false + } + + patchSessionTile(storedSessionId, { workspaceMode: scope.workspaceMode, workspaceOwnerKey }) + + return true +} + /** Drop live runtime bindings so every tile re-resumes — used on gateway * reconnect, where a respawned backend re-mints (recycles) runtime ids. * Also invalidates the wiring cache's stored→runtime map: clearing only the @@ -795,7 +834,8 @@ export function openSessionTile( storedSessionId: string, dir: TileDock = 'right', anchor?: string, - before?: null | string + before?: null | string, + workspaceScope: SessionTileWorkspaceScope = { workspaceMode: 'sessions' } ) { const tiles = $sessionTiles.get() @@ -813,14 +853,29 @@ export function openSessionTile( const dock = anchor ?? focusedSessionTabAnchor() ?? undefined + const workspaceOwnerKey = + workspaceScope.workspaceMode === 'bots' ? workspaceScope.workspaceOwnerKey : undefined + if (!tiles.some(t => t.storedSessionId === storedSessionId)) { - saveTiles([...tiles, { anchor: dock, before, dir, storedSessionId }]) + saveTiles([ + ...tiles, + { + anchor: dock, + before, + dir, + storedSessionId, + workspaceMode: workspaceScope.workspaceMode, + workspaceOwnerKey + } + ]) // Adoption is async via the registry — order sync runs after the move path // below; a brand-new tile's strip slot is already in `before`. return } + setSessionTileWorkspaceScope(storedSessionId, workspaceScope) + // Already open: relocate the existing pane to the drop target (pane-mirror // only docks on first adoption, so a re-drag must move the tree pane itself). const tree = $layoutTree.get() @@ -940,7 +995,10 @@ export function blankDraftTile( * False when there's no such tab, so the caller can fall back. The spent draft * is DISCARDED rather than closed: it never held a conversation, so ⌘⇧T * resurrecting it would just restore an empty tab. */ -export function reuseBlankDraftTile(storedSessionId: string): boolean { +export function reuseBlankDraftTile( + storedSessionId: string, + workspaceScope: SessionTileWorkspaceScope = { workspaceMode: 'sessions' } +): boolean { const tile = blankDraftTile($sessionTiles.get(), $sessionStates.get()) if (!tile || tile.storedSessionId === storedSessionId) { @@ -948,7 +1006,7 @@ export function reuseBlankDraftTile(storedSessionId: string): boolean { } discardSessionTile(tile.storedSessionId) - openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before) + openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before, workspaceScope) revealTreePane(`${TILE_PANE_PREFIX}${storedSessionId}`) return true @@ -964,7 +1022,7 @@ export function closeSessionTile(storedSessionId: string) { const tile = $sessionTiles.get().find(t => t.storedSessionId === storedSessionId) if (tile) { - closedStack().push({ anchor: tile.anchor, before: tile.before, dir: tile.dir, storedSessionId }) + closedStack().push(toStored(tile)) } saveTiles($sessionTiles.get().filter(t => t.storedSessionId !== storedSessionId)) @@ -1011,7 +1069,10 @@ export function reopenLastClosedTile(): void { } if (!$sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) { - openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before) + openSessionTile(storedSessionId, tile.dir, tile.anchor, tile.before, { + workspaceMode: tile.workspaceMode ?? 'sessions', + workspaceOwnerKey: tile.workspaceOwnerKey + }) focusOpenSession(storedSessionId) return From 9b36c2d43ce952969ba22287feb89c7c55403459 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:01:51 +0200 Subject: [PATCH 104/666] feat(desktop): make new Bot tabs owner-aware --- apps/desktop/src/app/contrib/wiring.tsx | 35 +++++++ apps/desktop/src/app/hooks/use-keybinds.ts | 2 + .../hooks/use-session-actions/index.ts | 36 +++++++- .../pane-shell/workspace-scope.test.ts | 31 +++++++ .../components/pane-shell/workspace-scope.ts | 61 +++++++++++- .../desktop/src/plugins/hermes-bots/plugin.js | 92 +++++++++++-------- .../tests/remote-routing-races.test.mjs | 14 ++- apps/desktop/src/sdk/index.test.ts | 20 ++++ apps/desktop/src/sdk/index.ts | 47 +++++++++- 9 files changed, 286 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 67eb63c8223e..7a6e557f7ce8 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -23,6 +23,12 @@ import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overla import { NotificationStack } from '@/components/notifications' import { DesktopOnboardingOverlay } from '@/components/onboarding' import { $newSessionTabAction, registerPaneCloser } from '@/components/pane-shell/tree/store' +import { + $workspaceMode, + $workspaceNewSessionTarget, + $workspaceOwnerKey, + setWorkspaceScope +} from '@/components/pane-shell/workspace-scope' import { FloatingPet } from '@/components/pet/floating-pet' import { RemoteDisplayBanner } from '@/components/remote-display-banner' import { SendDiagnosticsHost } from '@/components/send-diagnostics-dialog' @@ -39,6 +45,7 @@ import { requestVoiceConversationStart } from '@/store/composer' import { $activeConnectionId } from '@/store/connections' import { $cronReviewRequest, setCronFocusJobId } from '@/store/cron' import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' +import { notify } from '@/store/notifications' import { $previewTarget } from '@/store/preview' import { $activeGatewayProfile, @@ -545,6 +552,8 @@ export function ContribWiring({ children }: { children: ReactNode }) { // prefills the MAIN composer right after, so it has to own that surface. const startSessionInWorkspace = useCallback( (path: null | string, options?: { openTab?: boolean }) => { + setWorkspaceScope('sessions') + if (options?.openTab && mainChatOccupied(activeSessionIdRef.current, $selectedStoredSessionId.get())) { void openNewSessionTile('center', { cwd: path, listed: false }) @@ -861,6 +870,32 @@ export function ContribWiring({ children }: { children: ReactNode }) { // it — Cursor-style. Every click opens a fresh "New session" tab (multiple // empty tabs are fine since none touch the session list). const openNewSessionTab = useCallback(() => { + const workspaceMode = $workspaceMode.get() + const workspaceOwnerKey = $workspaceOwnerKey.get() + const workspaceNewSessionTarget = $workspaceNewSessionTarget.get() + + if (workspaceMode === 'bots') { + if (workspaceNewSessionTarget?.kind !== 'route' || !workspaceOwnerKey) { + notify({ + kind: 'info', + message: + workspaceNewSessionTarget?.kind === 'blocked' + ? workspaceNewSessionTarget.message + : 'Select a Bot or group first.' + }) + + return + } + + void openNewSessionTile('center', { + listed: false, + route: workspaceNewSessionTarget.route, + workspaceScope: { workspaceMode: 'bots', workspaceOwnerKey } + }) + + return + } + void openNewSessionTile('center', { listed: false }) }, [openNewSessionTile]) diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index b4a525acf484..e8292ad3d505 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -14,6 +14,7 @@ import { togglePaneVisible, toggleTargetZoneTabStrip } from '@/components/pane-shell/tree/store' +import { setWorkspaceScope } from '@/components/pane-shell/workspace-scope' import { onReleaseTypingFocus } from '@/components/ui/keyboard-first' import { findBarClaimsCombo } from '@/lib/find-in-page' import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions' @@ -216,6 +217,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { // Match the sidebar New Session button. A plain keyboard new chat should // target the current live profile, not a stale per-profile quick-create // selection from a prior action. + setWorkspaceScope('sessions') $newChatProfile.set(null) deps.startFreshSession() window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut')) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 75733e38ff43..287f3500bf5a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -4,6 +4,7 @@ import type { NavigateFunction } from 'react-router' import { graftRefreshedTailOntoBackfill } from '@/app/chat/transcript-backfill' import { revealTreePane } from '@/components/pane-shell/tree/store' +import { setWorkspaceScope } from '@/components/pane-shell/workspace-scope' import { deleteSession, getAllSessionMessages, getLatestSessionMessages, setSessionArchived } from '@/hermes' import { useI18n } from '@/i18n' import { type ChatMessage, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' @@ -23,6 +24,7 @@ import { $newChatProfile, $newChatRoute, $showAllProfiles, + type AgentProfileRoute, ensureGatewayAgent, ensureGatewayProfile, normalizeProfileKey @@ -84,6 +86,7 @@ import { openSessionTile, patchSessionTile, publishSessionState, + type SessionTileWorkspaceScope, type TileDock } from '@/store/session-states' import { broadcastSessionsChanged } from '@/store/session-sync' @@ -571,6 +574,7 @@ export function useSessionActions({ const selectSidebarItem = useCallback( (item: SidebarNavItem) => { if (item.action === 'new-session') { + setWorkspaceScope('sessions') startFreshSessionDraft() return @@ -594,15 +598,28 @@ export function useSessionActions({ * list (Cursor-style draft tab); it surfaces on the next refresh once the * first message persists a turn. "Open in split" keeps the listed behavior. */ const openNewSessionTile = useCallback( - async (dir: TileDock = 'right', options?: { cwd?: null | string; listed?: boolean }) => { + async ( + dir: TileDock = 'right', + options?: { + cwd?: null | string + listed?: boolean + route?: AgentProfileRoute | null + workspaceScope?: SessionTileWorkspaceScope + } + ) => { const listed = options?.listed ?? true try { // Fresh tile → the caller's workspace when one was named (the sidebar // "+" on a project/worktree lane), else the resolved new-session cwd // (project scope → configured default). - const capturedRoute = $newChatRoute.get() - const params = await desktopSessionCreateParams((options?.cwd || resolveNewSessionCwd()).trim(), capturedRoute) + const capturedRoute = options?.route === undefined ? $newChatRoute.get() : options.route + const workspaceScope = options?.workspaceScope ?? { workspaceMode: 'sessions' } + + const params = { + ...(await desktopSessionCreateParams((options?.cwd || resolveNewSessionCwd()).trim(), capturedRoute)), + ...(workspaceScope.workspaceMode === 'bots' ? { hidden: true } : {}) + } const created = capturedRoute ? await requestGatewayForAgent( @@ -616,7 +633,16 @@ export function useSessionActions({ const stored = created.stored_session_id if (!stored) { - await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined) + const closeCreated = capturedRoute + ? requestGatewayForAgent( + capturedRoute.connectionId, + capturedRoute.profile, + 'session.close', + { session_id: created.session_id } + ) + : requestGateway('session.close', { session_id: created.session_id }) + + await closeCreated.catch(() => undefined) notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed }) return @@ -641,7 +667,7 @@ export function useSessionActions({ const runtimeInfo = applyRuntimeInfo(created.info, { foreground: false }) updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored) - openSessionTile(stored, dir) + openSessionTile(stored, dir, undefined, undefined, workspaceScope) patchSessionTile(stored, { runtimeId: created.session_id }) if (dir === 'center' && runtimeInfo?.cwd) { diff --git a/apps/desktop/src/components/pane-shell/workspace-scope.test.ts b/apps/desktop/src/components/pane-shell/workspace-scope.test.ts index 7aae790a3723..241a4da966d0 100644 --- a/apps/desktop/src/components/pane-shell/workspace-scope.test.ts +++ b/apps/desktop/src/components/pane-shell/workspace-scope.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { $workspaceMode, + $workspaceNewSessionTarget, $workspaceOwnerKey, contributesToWorkspace, filterContributionsForWorkspace, @@ -38,6 +39,7 @@ describe('workspace scope', () => { it('defaults to the un-switched sessions window state', () => { expect($workspaceMode.get()).toBe('sessions') expect($workspaceOwnerKey.get()).toBeNull() + expect($workspaceNewSessionTarget.get()).toBeNull() }) it('publishes a coherent mode and owner in one batch', () => { @@ -56,6 +58,35 @@ describe('workspace scope', () => { unbindOwner() }) + it('publishes the exact new-session route with its Bot owner', () => { + const route = { + connectionId: 'connection-a', + mode: 'remote' as const, + profile: 'writer', + targetProfile: 'writer' + } + + expect(setWorkspaceScope('bots', 'bot:connection-a::writer', { kind: 'route', route })).toBe(true) + expect($workspaceNewSessionTarget.get()).toEqual({ kind: 'route', route }) + + // Equivalent route objects are a semantic no-op, not a new render signal. + expect( + setWorkspaceScope('bots', 'bot:connection-a::writer', { kind: 'route', route: { ...route } }) + ).toBe(false) + + setWorkspaceScope('sessions') + expect($workspaceNewSessionTarget.get()).toBeNull() + }) + + it('keeps a group owner explicit while blocking generic session creation', () => { + const target = { kind: 'blocked' as const, message: 'New group conversations start in the group composer.' } + + setWorkspaceScope('bots', 'group:room-1', target) + + expect($workspaceOwnerKey.get()).toBe('group:room-1') + expect($workspaceNewSessionTarget.get()).toEqual(target) + }) + it('keeps global contributions visible in both modes', () => { expect(contributesToWorkspace(undefined, 'sessions', null)).toBe(true) expect(contributesToWorkspace(undefined, 'bots', 'bot-a')).toBe(true) diff --git a/apps/desktop/src/components/pane-shell/workspace-scope.ts b/apps/desktop/src/components/pane-shell/workspace-scope.ts index 3980fc3f1557..a034477b04a8 100644 --- a/apps/desktop/src/components/pane-shell/workspace-scope.ts +++ b/apps/desktop/src/components/pane-shell/workspace-scope.ts @@ -24,22 +24,77 @@ export const $workspaceMode = atom('sessions') /** Default workspace owner key: none (unscoped / global ownership). */ export const $workspaceOwnerKey = atom(null) +/** Exact route for a fresh session in the current workspace. Kept structural + * here so the generic pane shell does not depend on profile/gateway stores. */ +export interface WorkspaceSessionRoute { + connectionId: string + mode?: 'local' | 'remote' + profile: string + targetProfile?: string +} + +/** What the shared `+` / session.newTab command means in this workspace. */ +export type WorkspaceNewSessionTarget = + | { kind: 'blocked'; message: string } + | { kind: 'route'; route: WorkspaceSessionRoute } + +/** Sessions uses its established ambient behavior (`null`). Bots publishes an + * exact route or a concise reason that a generic session is unavailable. */ +export const $workspaceNewSessionTarget = atom(null) + /** One key for window-local active-pane memory. Owner keys stay opaque. */ export function workspaceScopeKey(mode: WorkspaceMode, ownerKey: string | null): string { return mode === 'sessions' ? 'sessions' : `bots:${ownerKey ?? ''}` } -/** Publish one coherent presentation scope without an intermediate mixed frame. */ -export function setWorkspaceScope(mode: WorkspaceMode, ownerKey: string | null = null): boolean { +function sameNewSessionTarget(a: WorkspaceNewSessionTarget | null, b: WorkspaceNewSessionTarget | null): boolean { + if (a === b) { + return true + } + + if (!a || !b || a.kind !== b.kind) { + return false + } + + if (a.kind === 'blocked' && b.kind === 'blocked') { + return a.message === b.message + } + + if (a.kind === 'route' && b.kind === 'route') { + return ( + a.route.connectionId === b.route.connectionId && + a.route.mode === b.route.mode && + a.route.profile === b.route.profile && + a.route.targetProfile === b.route.targetProfile + ) + } + + return false +} + +/** Publish one coherent presentation and creation scope without an + * intermediate mixed frame. Sessions always retains its existing ambient + * new-session behavior; alternate workspaces must state their intent. */ +export function setWorkspaceScope( + mode: WorkspaceMode, + ownerKey: string | null = null, + newSessionTarget: WorkspaceNewSessionTarget | null = null +): boolean { const nextOwnerKey = mode === 'bots' ? ownerKey : null + const nextNewSessionTarget = mode === 'bots' ? newSessionTarget : null - if ($workspaceMode.get() === mode && $workspaceOwnerKey.get() === nextOwnerKey) { + if ( + $workspaceMode.get() === mode && + $workspaceOwnerKey.get() === nextOwnerKey && + sameNewSessionTarget($workspaceNewSessionTarget.get(), nextNewSessionTarget) + ) { return false } batch(() => { $workspaceMode.set(mode) $workspaceOwnerKey.set(nextOwnerKey) + $workspaceNewSessionTarget.set(nextNewSessionTarget) }) return true diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 3573f0f3f989..8f4b6d65ba26 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -4568,12 +4568,22 @@ function isDefaultBot(bot) { function newBotChat(bot) { if (typeof host.newChat !== 'function') { - host.navigate?.('/') + host.notify?.({ kind: 'error', message: 'Update Hermes Desktop to open another Bot chat.' }) return } - host.newChat(bot?.sourceScoped || bot?.remoteSource ? botConnectionRoute(bot) : bot?.name) + const route = botConnectionRoute(bot) + + if (!route) { + host.notify?.({ kind: 'error', message: 'Update Hermes Desktop to open another Bot chat.' }) + + return + } + + const ownerKey = botWorkspaceOwnerKey(bot) + setBotsWorkspaceOwner(ownerKey, bot) + host.newChat(route, { workspaceMode: 'bots', workspaceOwnerKey: ownerKey }) } /** Resolve @handles in prose against the Bot Mode roster (local + Connections). @@ -4773,6 +4783,24 @@ function botConnectionRoute(bot) { }) } +const BOTS_HOME_OWNER_KEY = 'bots:home' + +function botWorkspaceOwnerKey(bot) { + const route = botConnectionRoute(bot) + + return `bot:${route ? botRouteKey(route) : String(bot?.name || 'default')}` +} + +function groupWorkspaceOwnerKey(group) { + return `group:${groupChatRoomKey(group, $groupChats.get()[group])}` +} + +function setBotsWorkspaceOwner(ownerKey, bot = null, blockedMessage = 'Select a Bot or group first.') { + const route = bot ? botConnectionRoute(bot) : null + const target = route ? { kind: 'route', route } : { kind: 'blocked', message: blockedMessage } + + host.setWorkspaceScope?.('bots', ownerKey || BOTS_HOME_OWNER_KEY, target) +} function backendTargetProfile(route, fallbackProfile = 'default') { if (!route) { return fallbackProfile @@ -5288,7 +5316,7 @@ async function ensureBotMetadata(bot) { * resolves a canonical-chat id. */ async function openRosterBot(bot) { const generation = ++botOpenGeneration - const key = botRosterKey(bot) + const key = botSelectionKey(bot) const meta = botRosterMeta(bot, $botMeta.get()) // Keep the currently visible group as a fallback until this explicit action // has actually fronted a new owner; a failed home open must not steal the @@ -5297,35 +5325,7 @@ async function openRosterBot(bot) { haptic('tap') saveSelectedRosterBot(bot) - - if (bot.remoteSource) { - // Selection only. A remote bot must never be opened through whichever - // gateway happens to be live; remote mention delivery remains backend-owned. - $openBotChat.set(null) - $groupChatWorkspace.set(null) - - if (botsHomeEnabled()) { - // Explicitly front the selected owner but keep the existing group tab - // intact. If the workspace door refuses, restore the group selection so - // a failed roster action cannot leave the center ownerless. - if (!openBotsHomeWorkspace(true) && previousGroup) { - $groupChatWorkspace.set(previousGroup) - } - } else { - // Old shells have no home surface; keep the existing visible group as - // owner and offer only guidance, never renderer-owned remote delivery. - if (previousGroup) { - $groupChatWorkspace.set(previousGroup) - } - host.notify?.({ - kind: 'info', - title: displayName(bot, meta), - message: `Stay in this chat and message @${botHandle(bot.name, bot)} from a Bot Chat.` - }) - } - - return false - } + setBotsWorkspaceOwner(botWorkspaceOwnerKey(bot), bot) $groupChatWorkspace.set(null) @@ -5357,7 +5357,7 @@ async function openRosterBot(bot) { } try { - const registryId = await openBotCanonicalChat(bot.name) + const registryId = await openBotCanonicalChat(bot) if (generation !== botOpenGeneration) { return false @@ -5397,7 +5397,7 @@ async function openRosterBot(bot) { $openBotChat.set({ key, openedRegistryId: '' }) closeBotsHomeWorkspace() - host.newChat(bot.name) + newBotChat(bot) return true } @@ -7585,11 +7585,9 @@ function BotRow({ bot, onDelete, onEdit, onGroup, showHandle }) { jsx(ContextMenuSeparator, {}), jsx(ContextMenuItem, { onSelect: () => { - $selectedBot.set(botSelectionKey(bot)) - - if (typeof host.newChat === 'function') { - newBotChat(bot) - } + saveSelectedRosterBot(bot) + setBotsWorkspaceOwner(botWorkspaceOwnerKey(bot), bot) + newBotChat(bot) }, children: 'New chat with this agent' }), @@ -12425,6 +12423,9 @@ function openBotsHomeWorkspace(explicit = false) { return false } + const selected = selectedRosterBot($lastRoster.get(), $selectedRosterKey.get()) + const ownerKey = selected ? botWorkspaceOwnerKey(selected) : BOTS_HOME_OWNER_KEY + setBotsWorkspaceOwner(ownerKey, selected) // Already open and fronted: nothing to do. Already open but backgrounded // (a persisted layout can restore the tab behind the draft): re-open to // re-front it. Never stack a second registration — a stale disposer would @@ -12537,6 +12538,8 @@ function openGroupChat(group) { // but it may not later close or visually steal the room the user chose. botOpenGeneration += 1 $groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: false }) + const ownerKey = groupWorkspaceOwnerKey(group) + setBotsWorkspaceOwner(ownerKey, null, 'New group conversations start in the group composer.') if (typeof host.openWorkspace === 'function') { try { @@ -13795,6 +13798,17 @@ export default { const stopSidebarSync = $sidebarVisible.listen(visible => { $botsPaneVisible.set(Boolean(visible)) + if (visible) { + const group = $groupChatWorkspace.get() + const selected = selectedRosterBot($lastRoster.get(), $selectedRosterKey.get()) + setBotsWorkspaceOwner( + group ? groupWorkspaceOwnerKey(group) : selected ? botWorkspaceOwnerKey(selected) : BOTS_HOME_OWNER_KEY, + group ? null : selected, + group ? 'New group conversations start in the group composer.' : 'Select a Bot or group first.' + ) + } else { + host.setWorkspaceScope?.('sessions') + } // A generic composer has no stored-session owner, so passive sync // replaces it with the Bot home. A real restored chat keeps the // center until the user explicitly selects a Bot owner. diff --git a/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs index 4f6da3d2c0d7..8c3794cebda4 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/remote-routing-races.test.mjs @@ -37,7 +37,7 @@ function load({ requestProfile, agents, profileRoutes } = {}) { activeConnectionId = connectionId calls.push(['ensureAgent', connectionId, profile]) }, - newChat: route => calls.push(['newChat', route]), + newChat: (route, options) => calls.push(['newChat', route, options]), notify: () => undefined, notifyError: () => undefined, profileRoutes: profileRoutes || (async () => []), @@ -50,6 +50,7 @@ function load({ requestProfile, agents, profileRoutes } = {}) { calls.push(['profile', route, method, params]) return {} }), + setWorkspaceScope: (mode, ownerKey, target) => calls.push(['workspaceScope', mode, ownerKey, target]), state: { connectionId: { get: () => activeConnectionId, listen: () => undefined }, gateway: { get: () => 'open', listen: () => undefined }, @@ -351,6 +352,17 @@ test('delayed duplicate, delete, and new chat keep source ownership', async () = .filter(value => value && typeof value === 'object') assert.ok(routes.length >= 3) assert.ok(routes.every(route => route.connectionId === 'remote-a')) + + const newChat = runtime.calls.find(call => call[0] === 'newChat') + assert.equal(newChat[2].workspaceMode, 'bots') + assert.equal(newChat[2].workspaceOwnerKey, 'bot:remote-a::worker') + assert.equal(newChat[1].targetProfile, 'backend-worker') + + const scope = runtime.calls.find(call => call[0] === 'workspaceScope') + assert.equal(scope[1], 'bots') + assert.equal(scope[2], 'bot:remote-a::worker') + assert.equal(scope[3].kind, 'route') + assert.equal(scope[3].route.connectionId, 'remote-a') }) diff --git a/apps/desktop/src/sdk/index.test.ts b/apps/desktop/src/sdk/index.test.ts index 0817188888f2..a279f3cd00ed 100644 --- a/apps/desktop/src/sdk/index.test.ts +++ b/apps/desktop/src/sdk/index.test.ts @@ -178,6 +178,7 @@ describe('host workspace scope', () => { afterEach(async () => { host.setWorkspaceScope('sessions') const tree = await import('@/components/pane-shell/tree/store') + tree.$newSessionTabAction.set(null) tree.removeTreePane('plugin-workspace:scope-test') }) @@ -214,4 +215,23 @@ describe('host workspace scope', () => { expect($workspaceMode.get()).toBe('bots') expect($workspaceOwnerKey.get()).toBe('connection-b::default') }) + + it('uses the shared tab action for an exact Bot owner without moving Sessions', async () => { + const tree = await import('@/components/pane-shell/tree/store') + const { $workspaceNewSessionTarget } = await import('@/components/pane-shell/workspace-scope') + const opened: string[] = [] + + const route = { + connectionId: 'connection-b', + mode: 'remote' as const, + profile: 'writer', + targetProfile: 'writer' + } + + tree.$newSessionTabAction.set(() => opened.push('tab')) + host.newChat(route, { workspaceMode: 'bots', workspaceOwnerKey: 'bot:connection-b::writer' }) + + expect(opened).toEqual(['tab']) + expect($workspaceNewSessionTarget.get()).toEqual({ kind: 'route', route }) + }) }) diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 210448cef924..f6cf8082caec 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -26,12 +26,16 @@ import { openSession, type OpenSessionIntent } from '@/app/open-session' import type { ClientSessionState } from '@/app/types' import { $narrowViewport, + $newSessionTabAction, $paneVisible, registerPaneCloser, removeTreePane, revealTreePane } from '@/components/pane-shell/tree/store' -import { setWorkspaceScope as publishWorkspaceScope } from '@/components/pane-shell/workspace-scope' +import { + setWorkspaceScope as publishWorkspaceScope, + type WorkspaceNewSessionTarget +} from '@/components/pane-shell/workspace-scope' import { onGatewayEvent } from '@/contrib/events' import { registry } from '@/contrib/registry' import type { WorkspaceMode } from '@/contrib/types' @@ -297,6 +301,11 @@ export interface PluginOpenSessionOptions { retryHydrationTimeoutOnce?: boolean } +export interface PluginNewChatOptions { + workspaceMode?: WorkspaceMode + workspaceOwnerKey?: string +} + function waitForFocusedSessionHydration({ connectionId, expectHistory, @@ -747,6 +756,11 @@ export const host = { const intent = options.intent ?? 'in-place' if (options.workspaceMode === 'bots') { + publishWorkspaceScope( + 'bots', + options.workspaceOwnerKey ?? null, + ownerRoute ? { kind: 'route', route: ownerRoute } : null + ) openSession(storedSessionId, navigate, intent, { workspaceMode: 'bots', workspaceOwnerKey: options.workspaceOwnerKey @@ -912,13 +926,38 @@ export const host = { }, /** Switch the visible main-pane workspace without unregistering retained panes. */ - setWorkspaceScope: (mode: WorkspaceMode, ownerKey: null | string = null): boolean => - publishWorkspaceScope(mode, ownerKey), + setWorkspaceScope: ( + mode: WorkspaceMode, + ownerKey: null | string = null, + newSessionTarget: WorkspaceNewSessionTarget | null = null + ): boolean => publishWorkspaceScope(mode, ownerKey, newSessionTarget), /** Start a fresh chat draft, optionally pointed at another profile (its * backend spins up in the background — same door the sidebar's per-profile * "+" uses). */ - newChat: (profile?: null | string | PluginProfileRoute): void => { + newChat: (profile?: null | string | PluginProfileRoute, options: PluginNewChatOptions = {}): void => { + if (options.workspaceMode === 'bots') { + if (!profile || typeof profile === 'string' || !options.workspaceOwnerKey) { + notify({ kind: 'error', message: 'Select a Bot before starting another chat.' }) + + return + } + + publishWorkspaceScope('bots', options.workspaceOwnerKey, { kind: 'route', route: { ...profile } }) + + const openTab = $newSessionTabAction.get() + + if (!openTab) { + notify({ kind: 'error', message: 'Update Hermes Desktop to open another Bot chat.' }) + + return + } + + openTab() + + return + } + if (profile && typeof profile !== 'string') { newSessionInAgent({ ...profile }) } else { From b42d8279edb45937559cb620eb3c8bb5b2222998 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:07:41 +0200 Subject: [PATCH 105/666] feat(desktop): retain Bot group drafts by room --- .../desktop/src/plugins/hermes-bots/plugin.js | 170 ++++++++++++++++-- .../tests/group-chat-identity-edit.test.mjs | 4 +- .../tests/group-composer-drafts.test.mjs | 106 +++++++++++ 3 files changed, 264 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/plugins/hermes-bots/tests/group-composer-drafts.test.mjs diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 8f4b6d65ba26..df8b4cdbd752 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -6203,6 +6203,10 @@ async function renameGroupChat(oldName, newName, members) { const all = { ...$groupChats.get() } const room = all[oldName] + if (room) { + migrateGroupComposerDraft(groupComposerDraftKey(oldName, room), groupComposerDraftKey(next, room)) + } + delete all[oldName] if (room) { @@ -11301,11 +11305,131 @@ function GroupClarifyCard({ entry, members }) { }) } +// Group composer drafts are window-local UI state. They must survive pane +// parking/re-registration and owner switches, but must never enter shared room +// metadata (where another Desktop would see half-typed text or attachment +// bytes). Current rooms key by immutable roomId; legacy rooms fall back to the +// display name until they are upgraded. +const groupComposerDrafts = new Map() + +function emptyGroupComposerDraft() { + return { activeReplyThread: null, main: '', pendingAttachments: {}, replies: {}, revision: 0 } +} + +function groupComposerDraftKey(group, room) { + return groupChatRoomKey(group, room) +} + +function groupComposerDraftSnapshot(key) { + return groupComposerDrafts.get(key) || emptyGroupComposerDraft() +} + +function updateGroupComposerDraft(key, mutate) { + const current = groupComposerDraftSnapshot(key) + const next = mutate({ + ...current, + pendingAttachments: Object.fromEntries( + Object.entries(current.pendingAttachments || {}).map(([thread, attachments]) => [ + thread, + [...(attachments || [])] + ]) + ), + replies: { ...(current.replies || {}) } + }) + + next.revision = current.revision + 1 + groupComposerDrafts.delete(key) + groupComposerDrafts.set(key, next) + + return next +} + +function restoreGroupComposerDraft(key, expectedRevision, snapshot) { + const current = groupComposerDraftSnapshot(key) + + if (current.revision !== expectedRevision) { + return null + } + + const restored = { + ...snapshot, + pendingAttachments: Object.fromEntries( + Object.entries(snapshot.pendingAttachments || {}).map(([thread, attachments]) => [ + thread, + [...(attachments || [])] + ]) + ), + replies: { ...(snapshot.replies || {}) }, + revision: current.revision + 1 + } + + groupComposerDrafts.set(key, restored) + + return restored +} + +function clearGroupComposerDraft(key) { + groupComposerDrafts.delete(key) +} + +function migrateGroupComposerDraft(oldKey, newKey) { + if (oldKey === newKey || !groupComposerDrafts.has(oldKey)) { + return + } + + if (!groupComposerDrafts.has(newKey)) { + groupComposerDrafts.set(newKey, groupComposerDrafts.get(oldKey)) + } + + groupComposerDrafts.delete(oldKey) +} + function GroupChatWorkspace({ group, members, onBack, visible = true }) { const rooms = useValue($groupChats) const allMeta = useValue($botMeta) const room = rooms[group] || { log: [], running: false } - const [draft, setDraft] = useState('') + const composerKey = groupComposerDraftKey(group, room) + const composerKeyRef = useRef(composerKey) + const [composerDraft, setComposerDraft] = useState(() => groupComposerDraftSnapshot(composerKey)) + + if (composerKeyRef.current !== composerKey) { + migrateGroupComposerDraft(composerKeyRef.current, composerKey) + composerKeyRef.current = composerKey + } + + const updateComposerDraft = mutate => { + const next = updateGroupComposerDraft(composerKeyRef.current, mutate) + setComposerDraft(next) + + return next + } + + const draft = composerDraft.main || '' + const replyDrafts = composerDraft.replies || {} + const replyThread = composerDraft.activeReplyThread || null + const pendingImages = composerDraft.pendingAttachments || {} + const setDraft = value => + updateComposerDraft(current => ({ + ...current, + main: typeof value === 'function' ? value(current.main || '') : value + })) + const setReplyDrafts = value => + updateComposerDraft(current => ({ + ...current, + replies: typeof value === 'function' ? value(current.replies || {}) : value + })) + const setReplyThread = value => + updateComposerDraft(current => ({ + ...current, + activeReplyThread: + typeof value === 'function' ? value(current.activeReplyThread || null) : value + })) + const setPendingImages = value => + updateComposerDraft(current => ({ + ...current, + pendingAttachments: + typeof value === 'function' ? value(current.pendingAttachments || {}) : value + })) const [confirmDisband, setConfirmDisband] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) // Click-to-disambiguate: which log entry is showing its speaker's full @@ -11318,12 +11442,9 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { // `replyThread` is the thread whose reply box currently owns the composer // (null = the main composer, which STARTS a new thread). const [openThreads, setOpenThreads] = useState({}) - const [replyThread, setReplyThread] = useState(null) - const [replyDrafts, setReplyDrafts] = useState({}) // Pending image attachments per composer: `null` thread key = the main // composer, otherwise the reply box of that thread. Data URLs, already // downscaled — they ride the send into every responding member's session. - const [pendingImages, setPendingImages] = useState({}) // Scroll anchoring (#89835): rooms used to open at scroll position 0 and // stay there while replies streamed in. Scroll the bottom sentinel into @@ -11385,11 +11506,6 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { setPendingImages(prev => ({ ...prev, [key]: [...(prev[key] || []), ...picked] })) } - const clearImages = thread => { - const key = thread ?? 'main' - setPendingImages(prev => ({ ...prev, [key]: [] })) - } - const removeImage = (thread, index) => { const key = thread ?? 'main' setPendingImages(prev => ({ ...prev, [key]: (prev[key] || []).filter((_, i) => i !== index) })) @@ -11579,8 +11695,12 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { return } - setDraft('') - clearImages(null) + const before = groupComposerDraftSnapshot(composerKeyRef.current) + const cleared = updateComposerDraft(current => ({ + ...current, + main: '', + pendingAttachments: { ...(current.pendingAttachments || {}), main: [] } + })) // Main composer = START A NEW THREAD with the whole group (Slack shape). // Full descriptors ride into the turn loop: remote members keep their // connection fields so their turns route to their own machines. @@ -11588,6 +11708,12 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { if (minted) { setOpenThreads(prev => ({ ...prev, [minted]: true })) + } else { + const restored = restoreGroupComposerDraft(composerKeyRef.current, cleared.revision, before) + + if (restored) { + setComposerDraft(restored) + } } } @@ -11599,12 +11725,25 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { return } - setReplyDrafts(prev => ({ ...prev, [thread]: '' })) - clearImages(thread) + const before = groupComposerDraftSnapshot(composerKeyRef.current) + const cleared = updateComposerDraft(current => ({ + ...current, + pendingAttachments: { ...(current.pendingAttachments || {}), [thread]: [] }, + replies: { ...(current.replies || {}), [thread]: '' } + })) // Reply box = CONTINUE this thread; the member turns it triggers are // scoped to it. - sendToGroupChat(group, memberDescriptors(), text, thread, images) - setOpenThreads(prev => ({ ...prev, [thread]: true })) + const sent = sendToGroupChat(group, memberDescriptors(), text, thread, images) + + if (sent) { + setOpenThreads(prev => ({ ...prev, [thread]: true })) + } else { + const restored = restoreGroupComposerDraft(composerKeyRef.current, cleared.revision, before) + + if (restored) { + setComposerDraft(restored) + } + } } /** Pending-attachment chips + the picker for one composer (thread = null → @@ -12040,6 +12179,7 @@ function GroupChatWorkspace({ group, members, onBack, visible = true }) { doneLabel: 'Disbanded', onClose: () => setConfirmDisband(false), onConfirm: async () => { + clearGroupComposerDraft(composerKeyRef.current) await disbandGroupChat(group, members) host.notify({ kind: 'success', message: `Disbanded “${group}”` }) } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs index c96b4ada0ef7..775c4ae536c1 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-chat-identity-edit.test.mjs @@ -26,7 +26,9 @@ test('source contract: settings dialog edits name and picture after creation', ( test('source contract: rename re-keys the room AND local memberships, keeps sessions', () => { // The room record moves wholesale under the new key (sessions included, so // members keep resuming their per-group sessions by stored sid). - assert.match(pluginSource, /const room = all\[oldName\]\s*\n\s*\n?\s*delete all\[oldName\]/) + assert.match(pluginSource, /const room = all\[oldName\][\s\S]{0,320}?delete all\[oldName\]/) + // Window-local composer drafts follow the immutable room identity too. + assert.match(pluginSource, /migrateGroupComposerDraft\(groupComposerDraftKey\(oldName, room\), groupComposerDraftKey\(next, room\)\)/) // Local members' canonical groups lists swap old → new via ui_meta. assert.match(pluginSource, /botGroups\(meta\)\.map\(g => \(g === oldName \? next : g\)\)/) // Collisions are rejected, not silently suffixed — rename is explicit intent. diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-composer-drafts.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-composer-drafts.test.mjs new file mode 100644 index 000000000000..6c4f0d570671 --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-composer-drafts.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import vm from 'node:vm' + +const source = readFileSync(new URL('../plugin.js', import.meta.url), 'utf8') + +function between(start, end) { + const from = source.indexOf(start) + const to = source.indexOf(end, from) + + assert.notEqual(from, -1, `missing ${start}`) + assert.notEqual(to, -1, `missing ${end}`) + + return source.slice(from, to) +} + +function load() { + const context = { Map, Object } + const roomKey = between('function groupChatRoomKey(', '/** Lift any historical projection shape') + const drafts = between('const groupComposerDrafts = new Map()', 'function GroupChatWorkspace(') + + vm.runInNewContext( + `${roomKey}\n${drafts}\nglobalThis.drafts = { + clearGroupComposerDraft, + groupComposerDraftKey, + groupComposerDraftSnapshot, + migrateGroupComposerDraft, + restoreGroupComposerDraft, + updateGroupComposerDraft + }`, + context + ) + + return context.drafts +} + +test('workspace retirement and re-registration restore the exact room draft', () => { + const drafts = load() + const key = drafts.groupComposerDraftKey('Launch room', { roomId: 'room-1' }) + const attachment = { data: 'data:image/png;base64,abc', kind: 'image', name: 'plan.png' } + + drafts.updateGroupComposerDraft(key, state => ({ + ...state, + activeReplyThread: 'thread-1', + main: 'main draft', + pendingAttachments: { main: [attachment], 'thread-1': [attachment] }, + replies: { 'thread-1': 'reply draft' } + })) + + // Dropping the component reference simulates pane retirement. A fresh + // registration reads the same module-scope, roomId-qualified snapshot. + const remounted = drafts.groupComposerDraftSnapshot(key) + + assert.equal(remounted.main, 'main draft') + assert.equal(remounted.replies['thread-1'], 'reply draft') + assert.equal(remounted.activeReplyThread, 'thread-1') + assert.equal(remounted.pendingAttachments.main[0].name, 'plan.png') +}) + +test('legacy name-keyed drafts migrate when an immutable room id appears', () => { + const drafts = load() + const legacy = drafts.groupComposerDraftKey('Launch room', {}) + const current = drafts.groupComposerDraftKey('Renamed room', { roomId: 'room-1' }) + + drafts.updateGroupComposerDraft(legacy, state => ({ ...state, main: 'keep me' })) + drafts.migrateGroupComposerDraft(legacy, current) + + assert.equal(drafts.groupComposerDraftSnapshot(current).main, 'keep me') + assert.equal(drafts.groupComposerDraftSnapshot(legacy).main, '') +}) + +test('a failed send cannot overwrite text entered after the optimistic clear', () => { + const drafts = load() + const key = 'id:room-1' + + drafts.updateGroupComposerDraft(key, state => ({ ...state, main: 'send this' })) + const before = drafts.groupComposerDraftSnapshot(key) + const cleared = drafts.updateGroupComposerDraft(key, state => ({ ...state, main: '' })) + + drafts.updateGroupComposerDraft(key, state => ({ ...state, main: 'newer typing' })) + + assert.equal(drafts.restoreGroupComposerDraft(key, cleared.revision, before), null) + assert.equal(drafts.groupComposerDraftSnapshot(key).main, 'newer typing') +}) + +test('disband removes only that room draft', () => { + const drafts = load() + + drafts.updateGroupComposerDraft('id:a', state => ({ ...state, main: 'a' })) + drafts.updateGroupComposerDraft('id:b', state => ({ ...state, main: 'b' })) + drafts.clearGroupComposerDraft('id:a') + + assert.equal(drafts.groupComposerDraftSnapshot('id:a').main, '') + assert.equal(drafts.groupComposerDraftSnapshot('id:b').main, 'b') +}) + +test('GroupChatWorkspace owns composer state through the room draft store', () => { + const workspace = between('function GroupChatWorkspace(', '/** Live closers for group-chat MAIN-window tabs') + + assert.match(workspace, /groupComposerDraftKey\(group, room\)/) + assert.match(workspace, /restoreGroupComposerDraft\(composerKeyRef\.current, cleared\.revision, before\)/) + assert.match(workspace, /clearGroupComposerDraft\(composerKeyRef\.current\)/) + assert.doesNotMatch(workspace, /useState\(''\).*?draft/) + assert.doesNotMatch(workspace, /useState\(\{\}\).*?replyDrafts/) +}) From 856fc66d19b679cf6440aadf122ac5bb0d75984a Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:18:53 +0200 Subject: [PATCH 106/666] fix(desktop): bound Bot owner wake races --- .../desktop/src/plugins/hermes-bots/plugin.js | 34 ++++- .../hermes-bots/tests/bots-home.test.mjs | 140 +++++++++++++----- apps/desktop/src/sdk/index.ts | 90 +++++++++-- apps/desktop/src/sdk/profile-routing.test.ts | 107 +++++++++++-- 4 files changed, 303 insertions(+), 68 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index df8b4cdbd752..a49c7ccd5e95 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -5104,6 +5104,28 @@ function isCanonicalBotChatHistory(history) { return rootTitle === CANONICAL_CHAT_TITLE || (!rootTitle && title === CANONICAL_CHAT_TITLE) } +function botModeGatewayNeedsUpdate(error) { + const message = String(error?.message || error || '') + + return /(?:method not found|no handler for|unknown method|unsupported rpc)/i.test(message) +} + +function notifyBotOpenFailure(error, bot, fallbackMessage) { + if (botModeGatewayNeedsUpdate(error)) { + const gateway = bot.connectionLabel || bot.connectionId || 'this gateway' + + host.notify?.({ + kind: 'error', + title: 'Update this gateway to use Bot Mode', + message: `Update ${gateway}, then try again.` + }) + + return + } + + host.notifyError?.(error, fallbackMessage) +} + /** THE identity lookup: the profile's session titled exactly "Bot Chat", * consulted on the bot's OWN source. The core UNIQUE title index guarantees * at most ONE such row per profile db — Profile → Named Session is an exact @@ -5115,7 +5137,6 @@ function isCanonicalBotChatHistory(history) { * authorizes this RPC. */ async function findExistingCanonicalChat(owner) { const { bot, name, route } = botOwner(owner) - // FAIL CLOSED. A failed registry lookup MUST NOT read as "no Bot Chat // exists" — that is the one remaining way to fork a bot's forever chat. // The failure lives exactly in the post-update window: the desktop @@ -5346,7 +5367,8 @@ async function openRosterBot(bot) { $groupChatWorkspace.set(previousGroup) } syncBotsHomeWorkspace() - host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the gateway'}`) + + notifyBotOpenFailure(error, bot, `Could not reach ${bot.connectionLabel || 'the gateway'}`) } return false @@ -5378,7 +5400,8 @@ async function openRosterBot(bot) { $groupChatWorkspace.set(previousGroup) } syncBotsHomeWorkspace() - host.notifyError?.(error, `Could not open ${displayName(bot, meta)}'s chat — try again`) + + notifyBotOpenFailure(error, bot, `Could not open ${displayName(bot, meta)}'s chat — try again`) } return false @@ -13947,6 +13970,11 @@ export default { group ? 'New group conversations start in the group composer.' : 'Select a Bot or group first.' ) } else { + // Strand any owner wake still dialing. Its SDK open will fail the + // workspace token too; this plugin generation prevents that expected + // cancellation from repainting Bots home or showing an error after + // the user deliberately returned to Sessions. + botOpenGeneration += 1 host.setWorkspaceScope?.('sessions') } // A generic composer has no stored-session owner, so passive sync diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs index 054491e38971..f3be856978eb 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs @@ -41,6 +41,8 @@ function load({ focusedStoredSessionId = null, paneVisibility = true, openWorksp const notifications = [] const requests = [] const invalidations = [] + const sessionOpens = [] + const workspaceScopes = [] const paneVisible = new Map() const focused = atom(focusedStoredSessionId) @@ -54,6 +56,23 @@ function load({ focusedStoredSessionId = null, paneVisibility = true, openWorksp requests.push({ method, params }) return Promise.resolve({}) }, + requestProfile: (route, method, params) => { + requests.push({ method, params, route }) + if (method === 'profiles.list') { + return Promise.resolve({ profiles: [{ name: route.targetProfile || route.profile }] }) + } + if (method === 'session.list') { + return Promise.resolve({ + sessions: [{ id: `chat-${route.connectionId}-${route.profile}`, title: 'Bot Chat', message_count: 1 }] + }) + } + return Promise.resolve({}) + }, + openSession: async (id, options) => sessionOpens.push({ id, options }), + setWorkspaceScope: (mode, ownerKey = null) => { + workspaceScopes.push({ mode, ownerKey }) + return true + }, notify: params => notifications.push(params), notifyError: (error, fallback) => notifications.push({ kind: 'error', message: fallback, error }), ensureAgent: async () => undefined, @@ -142,7 +161,19 @@ globalThis.__home = { vm.runInNewContext(source, context, { filename: 'plugin.js' }) - return { ...context.__home, closed, focused, host, invalidations, notifications, opened, paneVisible, requests } + return { + ...context.__home, + closed, + focused, + host, + invalidations, + notifications, + opened, + paneVisible, + requests, + sessionOpens, + workspaceScopes + } } /** Every door that would create, activate, or route something. A passive @@ -398,9 +429,9 @@ test('an unknown source list is not proof of deletion', () => { assert.equal(t.ghostRosterOwner('work-vps::researcher', [{ connectionId: 'local', reachable: true }]), null) }) -// ── explicit open: local routes, remote never does ────────────────────────── +// ── explicit open: every reachable owner routes exactly ──────────────────── -test('opening a remote bot selects it and routes nothing', async () => { +test('opening a remote bot selects and opens its exact owner chat', async () => { const t = load() t.setPluginCtx({ storage: { set: () => undefined } }) t.$botsPaneVisible.set(true) @@ -415,13 +446,20 @@ test('opening a remote bot selects it and routes nothing', async () => { const result = await t.openRosterBot(bot) - assert.equal(result, false, 'a remote row does not open a chat') + assert.equal(result, true) assert.equal(t.$selectedRosterKey.get(), 'work-vps::researcher') - assert.equal(t.$openBotChat.get(), null) - assertNothingRouted(t, 'clicking a remote row') -}) - -test('a remote owner fronts the Bots home without closing the group tab', async () => { + const openBotChat = t.$openBotChat.get() + assert.equal(openBotChat?.key, 'work-vps::researcher') + assert.equal(openBotChat?.openedRegistryId, 'chat-work-vps-researcher') + assert.equal(t.sessionOpens.length, 1) + assert.equal(t.sessionOpens[0].options.workspaceMode, 'bots') + assert.equal(t.sessionOpens[0].options.workspaceOwnerKey, 'bot:work-vps::researcher') + assert.equal(t.sessionOpens[0].options.keepAllProfilesScope, true) + assert.equal(t.sessionOpens[0].options.route.connectionId, 'work-vps') + assert.ok(t.requests.every(request => request.route?.connectionId === 'work-vps')) +}) + +test('a remote owner opens its chat without closing an unrelated group tab', async () => { const t = load() t.setPluginCtx({ storage: { set: () => undefined } }) t.$botsPaneVisible.set(true) @@ -435,13 +473,14 @@ test('a remote owner fronts the Bots home without closing the group tab', async remoteSource: true }) - assert.equal(result, false) - assert.equal(t.botsHomeVisible(), true) + assert.equal(result, true) + assert.equal(t.botsHomeVisible(), false) assert.equal(t.$groupChatWorkspace.get(), null) assert.equal(groupEntry.disposed, false, 'explicit selection must not close an unrelated group tab') + assert.equal(t.sessionOpens.length, 1) }) -test('a remote owner preserves a group when the Bots home cannot open', async () => { +test('a remote owner does not depend on the informational home surface', async () => { const t = load() t.setPluginCtx({ storage: { set: () => undefined } }) t.$botsPaneVisible.set(true) @@ -458,9 +497,10 @@ test('a remote owner preserves a group when the Bots home cannot open', async () remoteSource: true }) - assert.equal(result, false) - assert.equal(t.$groupChatWorkspace.get(), 'Launch room') + assert.equal(result, true) + assert.equal(t.$groupChatWorkspace.get(), null) assert.equal(groupEntry.disposed, false) + assert.equal(t.sessionOpens.length, 1) }) test('a failed local open leaves no phantom owner in the center', async () => { @@ -469,9 +509,9 @@ test('a failed local open leaves no phantom owner in the center', async () => { t.$botsPaneVisible.set(true) t.$openBotChat.set({ key: 'local::writer', openedRegistryId: 'previous' }) - // A source-scoped row on a desktop that cannot activate it: prepareBotSource + // A source-scoped row on a desktop that cannot address it: prepareBotSource // refuses rather than letting the open fall through to the live gateway. - delete t.host.ensureAgent + delete t.host.requestProfile const bot = { connectionId: 'work-vps', name: 'writer', sourceScoped: true } const result = await t.openRosterBot(bot) @@ -482,6 +522,26 @@ test('a failed local open leaves no phantom owner in the center', async () => { assertNothingRouted(t, 'a refused local open') }) +test('an older gateway gets an actionable Bot Mode update message', async () => { + const t = load() + t.setPluginCtx({ storage: { set: () => undefined } }) + t.$botsPaneVisible.set(true) + t.host.requestProfile = async () => { + throw new Error('Unknown method profiles.list') + } + + const result = await t.openRosterBot({ + connectionId: 'work-vps', + connectionLabel: 'Work', + name: 'writer', + sourceScoped: true + }) + + assert.equal(result, false) + assert.equal(t.notifications.at(-1).title, 'Update this gateway to use Bot Mode') + assert.equal(t.notifications.at(-1).message, 'Update Work, then try again.') +}) + test('a missing profile-scoped draft API returns to the home without navigating', async () => { const t = load() t.setPluginCtx({ storage: { set: () => undefined } }) @@ -662,7 +722,7 @@ test('a persisted layout that restored the home behind the draft gets re-fronted assert.equal(t.botsHomeVisible(), true) }) -test('an explicit remote selection fronts the home over a focused chat', async () => { +test('an explicit remote selection opens its owner tab without moving the focused Sessions chat', async () => { const t = load({ focusedStoredSessionId: 'local-scout-chat' }) t.setPluginCtx({ storage: { set: () => undefined } }) t.$botsPaneVisible.set(true) @@ -671,19 +731,19 @@ test('an explicit remote selection fronts the home over a focused chat', async ( t.syncBotsHomeWorkspace() assert.deepEqual(t.opened, []) - // …but clicking the same-named twin on another gateway is a gesture at - // that owner: the home fronts, the chat stays alive underneath. + // …but clicking the same-named twin on another gateway opens that owner in + // Bot Mode while the Sessions chat stays alive underneath. await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'scout', remoteSource: true }) - assert.equal(t.opened.length, 1) + assert.equal(t.opened.length, 0) + assert.equal(t.sessionOpens.length, 1) assert.equal(t.$selectedRosterKey.get(), 'work-vps::scout') - assert.equal(t.$openBotChat.get(), null) - assertNothingRouted(t, 'explicit remote selection') + assert.equal(t.$openBotChat.get().key, 'work-vps::scout') + assert.equal(t.focused.get(), 'local-scout-chat') - // Browsing more remote owners reuses the fronted home instead of - // re-registering it (a stale disposer would tear down the newer one). + // Browsing more remote owners opens that owner without reusing the first. await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'relay', remoteSource: true }) - assert.equal(t.opened.length, 1) + assert.equal(t.sessionOpens.length, 2) assert.equal(t.$selectedRosterKey.get(), 'work-vps::relay') }) @@ -701,8 +761,8 @@ test('an explicit Bots-home gesture fronts the selected owner over a Sessions co test('source contract: sidebar entry and boot restore reconcile passively after layout hydration', () => { assert.match(pluginSource, /const syncWorkspaceSurfaces = \(\) =>/) - assert.match(pluginSource, /stopSidebarSync = \$sidebarVisible\.listen\(visible => \{[\s\S]{0,450}?syncWorkspaceSurfaces\(\)/) - assert.doesNotMatch(pluginSource, /stopSidebarSync = \$sidebarVisible\.listen\(visible => \{[\s\S]{0,450}?syncWorkspaceSurfaces\(Boolean\(visible\)\)/) + assert.match(pluginSource, /stopSidebarSync = \$sidebarVisible\.listen\(visible => \{[\s\S]{0,1500}?syncWorkspaceSurfaces\(\)/) + assert.doesNotMatch(pluginSource, /stopSidebarSync = \$sidebarVisible\.listen\(visible => \{[\s\S]{0,1500}?syncWorkspaceSurfaces\(Boolean\(visible\)\)/) assert.match( pluginSource, /\$botChatFocused\.set\(sessionOwnsWorkspace\(\)\)[\s\S]{0,500}?syncWorkspaceSurfaces\(\)[\s\S]{0,120}?scheduleSurfaceSync\(\)/ @@ -818,20 +878,28 @@ test('the home never yanks the center back from a sibling tab the user chose', ( assert.deepEqual(t.closed, []) }) -test('older shells without the main-area door simply have no home', async () => { +test('older shells without owner routing fail with an update path', async () => { const t = load({ openWorkspace: false, paneVisibility: false }) t.setPluginCtx({ storage: { set: () => undefined } }) t.$botsPaneVisible.set(true) + delete t.host.requestProfile + delete t.host.openSession t.syncBotsHomeWorkspace() assert.deepEqual(t.opened, []) - // And the remote row keeps its previous guidance toast instead. - await t.openRosterBot({ connectionId: 'work-vps', connectionLabel: 'Work', name: 'researcher', remoteSource: true }) + // A remote row cannot guess through the active gateway on an old shell. + await t.openRosterBot({ + connectionId: 'work-vps', + connectionLabel: 'Work', + name: 'researcher', + remoteSource: true, + sourceScoped: true + }) assert.equal(t.notifications.length, 1) - // Guidance is presentation only; remote mention delivery remains backend-owned. - assert.match(t.notifications[0].message, /message @researcher from a Bot Chat/) + assert.match(t.notifications[0].message, /Could not reach Work/) + assert.match(String(t.notifications[0].error), /Update Hermes Desktop/) assertNothingRouted(t, 'remote row on an older shell') }) @@ -906,15 +974,15 @@ test('an unavailable owner offers retry instead of a dead Open chat button', () assert.doesNotMatch(view, /its work keeps running on that gateway/) }) -test('an available remote owner explains the supported Bot Chat path without a fake direct action', () => { +test('an available remote owner offers the same direct chat action', () => { const start = pluginSource.indexOf('function BotsHomeView(') const view = pluginSource.slice(start, pluginSource.indexOf('function closeBotsHomeWorkspace(')) - assert.match(view, /unavailable \|\| !bot\.remoteSource/) - assert.match(view, /This bot lives on \$\{gateway\}\. Mention it from any Bot Chat to send it a message\./) + assert.match(view, /children: 'Open chat'/) + assert.match(view, /Open this bot’s continuous chat/) assert.doesNotMatch(view, /Copy @/) assert.doesNotMatch(view, /remoteCopy/) - assert.doesNotMatch(view, /ensureAgent|requestProfile|newChat/) + assert.doesNotMatch(view, /Mention it from any Bot Chat/) }) test('an unavailable owner never presents a guessed mention handle', () => { diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index f6cf8082caec..1510f14d6120 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -33,6 +33,8 @@ import { revealTreePane } from '@/components/pane-shell/tree/store' import { + $workspaceMode, + $workspaceOwnerKey, setWorkspaceScope as publishWorkspaceScope, type WorkspaceNewSessionTarget } from '@/components/pane-shell/workspace-scope' @@ -255,6 +257,31 @@ async function requestPluginProfile( ) } +/** Re-read Electron's current registry before retrying an exact-owner wake. + * A route that was removed or replaced while the first hydration wait ran is + * no longer authority to touch that backend, even when its labels still look + * identical. */ +async function pluginRouteStillRegistered(route: PluginProfileRoute): Promise { + const getProfileRoutes = window.hermesDesktop?.getProfileRoutes + + if (!getProfileRoutes) { + return false + } + + try { + const routes = await getProfileRoutes($profiles.get().map(profile => profile.name)) + + return routes.some( + candidate => + candidate.connectionId === route.connectionId && + candidate.profile === route.profile && + candidate.targetProfile === route.targetProfile + ) + } catch { + return false + } +} + if (typeof window !== 'undefined') { const refresh = () => $viewport.set(readViewport()) window.addEventListener('resize', refresh) @@ -307,17 +334,17 @@ export interface PluginNewChatOptions { } function waitForFocusedSessionHydration({ - connectionId, expectHistory, generation, + isCurrent, profile, requireActiveProfile, storedSessionId, timeoutMs }: { - connectionId?: string expectHistory: boolean generation: number + isCurrent?: () => boolean profile: string requireActiveProfile: boolean storedSessionId: string @@ -351,17 +378,27 @@ function waitForFocusedSessionHydration({ } const check = () => { - if (generation !== openSessionGeneration) { + if (generation !== openSessionGeneration || (isCurrent && !isCurrent())) { finish(new Error('Session open was superseded by a newer selection.')) return } const profileMatches = !requireActiveProfile || normalizeProfileKey($activeGatewayProfile.get()) === profile - const connectionMatches = !connectionId || $activeConnectionId.get() === connectionId - const sessionMatches = $selectedStoredSessionId.get() === storedSessionId - const runtimeReady = Boolean($activeSessionId.get()) - const historyPainted = Boolean($messages.get().length) + const mainMatches = $selectedStoredSessionId.get() === storedSessionId + const tileMatches = $focusedStoredSessionId.get() === storedSessionId + + const runtimeReady = mainMatches + ? Boolean($activeSessionId.get()) + : tileMatches + ? Boolean($focusedRuntimeId.get()) + : false + + const historyPainted = mainMatches + ? Boolean($messages.get().length) + : tileMatches + ? Boolean($focusedSessionState.get()?.messages.length) + : false // Paint-first hydration: for a history-bearing chat, the wake is DONE // the moment the persisted transcript is painted on the right session — @@ -377,7 +414,7 @@ function waitForFocusedSessionHydration({ // surface is real rather than a stuck loader. const hydrated = expectHistory ? historyPainted : runtimeReady - if (profileMatches && connectionMatches && sessionMatches && hydrated) { + if (profileMatches && (mainMatches || tileMatches) && hydrated) { finish() } } @@ -387,6 +424,11 @@ function waitForFocusedSessionHydration({ unbinds.push($selectedStoredSessionId.listen(check)) unbinds.push($activeSessionId.listen(check)) unbinds.push($messages.listen(check)) + unbinds.push($focusedStoredSessionId.listen(check)) + unbinds.push($focusedRuntimeId.listen(check)) + unbinds.push($focusedSessionState.listen(check)) + unbinds.push($workspaceMode.listen(check)) + unbinds.push($workspaceOwnerKey.listen(check)) timer = window.setTimeout(() => { finish(new Error(`Timed out loading ${profile}'s session history.`)) @@ -671,6 +713,19 @@ export const host = { const targetProfile = normalizeProfileKey(profile || $activeGatewayProfile.get()) const expectHistory = options.expectHistory ?? false + if (options.workspaceMode === 'bots') { + publishWorkspaceScope( + 'bots', + options.workspaceOwnerKey ?? null, + ownerRoute ? { kind: 'route', route: ownerRoute } : null + ) + } + + const openingStillCurrent = () => + generation === openSessionGeneration && + (options.workspaceMode !== 'bots' || + ($workspaceMode.get() === 'bots' && $workspaceOwnerKey.get() === (options.workspaceOwnerKey ?? null))) + const plan = planPluginOpenSession({ activeProfile: $activeGatewayProfile.get(), keepAllProfilesScope: options.keepAllProfilesScope, @@ -720,6 +775,10 @@ export const host = { profileActiveAt = Date.now() } + if (!openingStillCurrent()) { + throw new Error('Session open was superseded by a newer selection.') + } + if (ownerRoute) { setShowAllProfiles(true) } else if (plan.showAllProfiles !== null) { @@ -728,7 +787,7 @@ export const host = { wakePhase = 'hydration' - if (generation !== openSessionGeneration) { + if (!openingStillCurrent()) { throw new Error('Session open was superseded by a newer selection.') } @@ -756,11 +815,6 @@ export const host = { const intent = options.intent ?? 'in-place' if (options.workspaceMode === 'bots') { - publishWorkspaceScope( - 'bots', - options.workspaceOwnerKey ?? null, - ownerRoute ? { kind: 'route', route: ownerRoute } : null - ) openSession(storedSessionId, navigate, intent, { workspaceMode: 'bots', workspaceOwnerKey: options.workspaceOwnerKey @@ -790,9 +844,9 @@ export const host = { if (options.awaitHydration) { await waitForFocusedSessionHydration({ - connectionId: ownerRoute?.connectionId, expectHistory, generation, + isCurrent: openingStillCurrent, profile: targetProfile, // A background dial never moves $activeGatewayProfile, so gating // hydration on it would wait for something that is not coming. @@ -815,6 +869,10 @@ export const host = { throw error } + if (ownerRoute && !(await pluginRouteStillRegistered(ownerRoute))) { + throw new Error(`The ${targetProfile} gateway is no longer available.`) + } + // Logged per attempt so a support bundle shows the retry happened at // all; the terminal failure is reported once by the catch below. console.warn('[bot-wake] hydration timed out, retrying', { @@ -828,7 +886,7 @@ export const host = { } catch (error) { if ( options.awaitHydration && - generation === openSessionGeneration && + openingStillCurrent() && error instanceof Error && error.message.startsWith('Timed out loading ') ) { diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index fd6f14e6ecf7..556bb887b320 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -123,6 +123,7 @@ const { } = await import('@/store/profile') const { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } = await import('@/store/session-states') +const { setWorkspaceScope } = await import('@/components/pane-shell/workspace-scope') const { $activeSessionId, @@ -156,6 +157,7 @@ afterEach(() => { setMockAtom($selectedStoredSessionId, null) setMockAtom($messages, []) $profiles.set([profile('cached-only')]) + setWorkspaceScope('sessions') delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop }) @@ -433,7 +435,98 @@ describe('profile-aware plugin session opens', () => { }) }) - it('waits until the target Bot Chat runtime and history are on main before resolving', async () => { + it('finishes hydration on the focused Bot tile without moving the Sessions gateway', async () => { + const route = { + connectionId: 'source-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'backend-default' + } + + const opening = host.openSession('bot-chat', { + awaitHydration: true, + expectHistory: true, + hydrationTimeoutMs: 1_000, + intent: 'tab', + route, + workspaceMode: 'bots', + workspaceOwnerKey: 'bot:source-a::default' + }) + + await Promise.resolve() + setMockAtom($focusedStoredSessionId, 'bot-chat') + setMockAtom($focusedRuntimeId, 'runtime-bot-chat') + setMockAtom($focusedSessionState, { + messages: [{ id: 'bot-history', parts: [], role: 'assistant' }], + storedSessionId: 'bot-chat' + } as never) + + await opening + expect($activeGatewayProfile.get()).toBe('remote-worker') + expect($selectedStoredSessionId.get()).toBeNull() + }) + + it('strands a late Bot wake when the user returns to Sessions', async () => { + let releaseDial: (() => void) | undefined + + vi.mocked(openGatewayForAgent).mockImplementationOnce( + () => + new Promise(resolve => { + releaseDial = resolve + }) + ) + + const opening = host.openSession('late-bot-chat', { + awaitHydration: true, + expectHistory: true, + hydrationTimeoutMs: 1_000, + intent: 'tab', + route: { + connectionId: 'source-a', + mode: 'remote', + profile: 'writer', + targetProfile: 'writer' + }, + workspaceMode: 'bots', + workspaceOwnerKey: 'bot:source-a::writer' + }) + + setWorkspaceScope('sessions') + releaseDial?.() + + await expect(opening).rejects.toThrow(/superseded/i) + expect(openSessionCore).not.toHaveBeenCalled() + expect(setResumeExhaustedSessionId).not.toHaveBeenCalled() + }) + + it('revalidates an exact route before the one allowed hydration retry', async () => { + ;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { + getProfileRoutes: vi.fn(async () => []) + } + + await expect( + host.openSession('removed-owner-chat', { + awaitHydration: true, + expectHistory: true, + hydrationTimeoutMs: 1, + intent: 'tab', + retryHydrationTimeoutOnce: true, + route: { + connectionId: 'removed-source', + mode: 'remote', + profile: 'writer', + targetProfile: 'writer' + }, + workspaceMode: 'bots', + workspaceOwnerKey: 'bot:removed-source::writer' + }) + ).rejects.toThrow(/no longer available/i) + + expect(openSessionCore).toHaveBeenCalledTimes(1) + expect(setResumeExhaustedSessionId).not.toHaveBeenCalled() + }) + + it('waits until the target Bot Chat runtime and history are on the focused surface', async () => { vi.mocked(openGatewayForProfile).mockImplementationOnce(async () => undefined) let resolved = false @@ -467,18 +560,6 @@ describe('profile-aware plugin session opens', () => { storedSessionId: 'bot-chat' } as never) - await Promise.resolve() - expect(resolved).toBe(false) - - setMockAtom($selectedStoredSessionId, 'bot-chat') - setMockAtom($activeSessionId, 'runtime-hyoseob') - setMockAtom($messages, []) - - await Promise.resolve() - expect(resolved).toBe(false) - - setMockAtom($messages, [{ id: 'history-1', parts: [], role: 'user' }] as never) - await opening expect(resolved).toBe(true) expect($gatewaySwapTarget.get()).toBeNull() From a81854a2bd81eb73b83761eb077c75616313b276 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sun, 23 Aug 2026 03:05:00 +0200 Subject: [PATCH 107/666] fix(desktop): preserve Bot tabs across owner lifecycles --- apps/desktop/src/app/chat/session-tile.tsx | 38 +++-- .../hooks/use-session-tile-delegate.test.ts | 58 +++++++- .../hooks/use-session-tile-delegate.ts | 72 +++++++-- apps/desktop/src/app/contrib/wiring.tsx | 6 +- apps/desktop/src/app/open-session.test.ts | 5 +- apps/desktop/src/app/open-session.ts | 9 +- .../desktop/src/plugins/hermes-bots/plugin.js | 44 +++--- .../hermes-bots/tests/bots-home.test.mjs | 3 +- .../tests/canonical-chat-registry.test.mjs | 8 +- apps/desktop/src/sdk/index.ts | 30 ++-- apps/desktop/src/sdk/profile-routing.test.ts | 45 +++++- apps/desktop/src/store/session-states.test.ts | 69 ++++++++- apps/desktop/src/store/session-states.ts | 140 ++++++++++++++---- 13 files changed, 431 insertions(+), 96 deletions(-) diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 2adcc048a331..4a234022dff3 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -47,14 +47,17 @@ import { sessionMatchesStoredId, sessionPinId } from '@/store/session' +import { requestForSessionProfile } from '@/store/session-request-router' import { $sessionStates, + $sessionTileDelegateRevision, $sessionTiles, closeSessionTile, discardSessionTile, patchSessionTile, type SessionTile, - sessionTileDelegate + sessionTileDelegate, + sessionTileOwnerRoute } from '@/store/session-states' import type { SessionInfo } from '@/types/hermes' @@ -135,7 +138,15 @@ function TileChat({ }) { const { gateway, requestGateway } = useGatewayRequest() const queryClient = useQueryClient() - const { selectModel } = useModelControls({ queryClient, requestGateway }) + const ownerRoute = sessionTileOwnerRoute(storedSessionId) + + const requestTileGateway = useCallback( + (method: string, params?: Record, timeoutMs?: number, signal?: AbortSignal): Promise => + requestForSessionProfile(ownerRoute, requestGateway, method, params, timeoutMs, signal), + [ownerRoute, requestGateway] + ) + + const { selectModel } = useModelControls({ queryClient, requestGateway: requestTileGateway }) const activeGatewayProfile = useStore($activeGatewayProfile) const cwd = useStore(view.$cwd) const gatewayOpen = useStore($gatewayState) === 'open' @@ -160,7 +171,7 @@ function TileChat({ const composer = useComposerActions({ activeSessionId: runtimeId, currentCwd: cwd, - requestGateway, + requestGateway: requestTileGateway, scope: { add: attachments.add, remove: attachments.remove, @@ -201,11 +212,11 @@ function TileChat({ ) : null, - [activeGatewayProfile, gateway, gatewayOpen, requestGateway, selectModel] + [activeGatewayProfile, gateway, gatewayOpen, ownerRoute?.profile, requestTileGateway, selectModel] ) return ( @@ -247,6 +258,7 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string } const tile = tiles.find(t => t.storedSessionId === storedSessionId) const runtimeId = tile?.runtimeId ?? null const gatewayOpen = useStore($gatewayState) === 'open' + const delegateRevision = useStore($sessionTileDelegateRevision) const resumingRef = useRef(false) const view = useMemo(() => buildTileView(storedSessionId), [storedSessionId]) @@ -333,7 +345,7 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string } .finally(() => { resumingRef.current = false }) - }, [gatewayOpen, runtimeId, storedSessionId, tile?.error]) + }, [delegateRevision, gatewayOpen, runtimeId, storedSessionId, tile?.error]) // The gateway (re)opening invalidates any latched error — it likely came // from a not-yet-open gateway or the previous connection. Clearing it @@ -401,15 +413,17 @@ export function tileStoredRow(storedSessionId: string): SessionInfo | undefined * skipping the re-register that hands the tab back to this string. */ function tileTitle(storedSessionId: string): string { const stored = tileStoredRow(storedSessionId) + const explicit = $sessionTiles.get().find(tile => tile.storedSessionId === storedSessionId)?.workspaceTabTitle - return stored ? sessionTitle(stored) : NEW_SESSION_TITLE + return stored ? sessionTitle(stored) : explicit || NEW_SESSION_TITLE } /** The `@session` link payload for a tile tab drag — id + owning profile + title. * Resolved at drag time, so an unsent tab drags under its draft name. */ function tileDragPayload(storedSessionId: string): SessionDragPayload { const stored = tileStoredRow(storedSessionId) - const title = stored ? sessionTitle(stored) : draftTitleFor(storedSessionId) || NEW_SESSION_TITLE + const explicit = $sessionTiles.get().find(tile => tile.storedSessionId === storedSessionId)?.workspaceTabTitle + const title = stored ? sessionTitle(stored) : explicit || draftTitleFor(storedSessionId) || NEW_SESSION_TITLE return { id: storedSessionId, profile: stored?.profile ?? '', title } } @@ -617,7 +631,11 @@ export const watchSessionTiles = paneMirror({ ), // Until the first turn lists a row there is no title to register, so the tab // takes its name from the composer instead — live, without re-registering. - tabTitle: storedSessionId => (tileStoredRow(storedSessionId) ? null : ), + tabTitle: storedSessionId => + tileStoredRow(storedSessionId) || + $sessionTiles.get().some(tile => tile.storedSessionId === storedSessionId && tile.workspaceTabTitle) ? null : ( + + ), render: storedSessionId => , tabWrap: (storedSessionId, tab) => ( ({ ...(await importActual()), getLatestSessionMessages: vi.fn(async () => ({ messages: [], session_id: '' })) })) +vi.mock('@/store/gateway', async importActual => ({ + ...(await importActual>()), + requestGatewayForAgent: vi.fn(), + requestGatewayForProfile: vi.fn() +})) const { getLatestSessionMessages } = await import('@/hermes') +const { requestGatewayForAgent, requestGatewayForProfile } = await import('@/store/gateway') const row = (over: Partial): SessionInfo => ({ @@ -76,17 +82,26 @@ describe('useSessionTileDelegate resumeTile', () => { method === 'session.resume' ? ({ session_id: 'runtime-1' } as never) : ({} as never) ) + vi.mocked(requestGatewayForProfile).mockResolvedValueOnce({ session_id: 'runtime-1' } as never) + renderTile(requestGateway) const runtimeId = await sessionTileDelegate()!.resumeTile('stored-x') expect(runtimeId).toBe('runtime-1') expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-x', 'ai-engineer') - expect(requestGateway).toHaveBeenCalledWith('session.resume', { - session_id: 'stored-x', - cols: 96, - profile: 'ai-engineer', - omit_messages: true - }) + expect(requestGatewayForProfile).toHaveBeenCalledWith( + 'ai-engineer', + 'session.resume', + { + session_id: 'stored-x', + cols: 96, + profile: 'ai-engineer', + omit_messages: true + }, + undefined, + undefined + ) + expect(requestGateway).not.toHaveBeenCalled() }) it('resolves and carries a default-profile session explicitly', async () => { @@ -107,6 +122,35 @@ describe('useSessionTileDelegate resumeTile', () => { }) }) + it('routes a Bot tile prefetch and resume through its exact connection owner', async () => { + const route = { + connectionId: 'barry', + mode: 'remote' as const, + profile: 'oxcoder', + targetProfile: 'backend-oxcoder' + } + + setSessionOwnerHint('stored-remote', route) + vi.mocked(requestGatewayForAgent).mockResolvedValueOnce({ session_id: 'runtime-remote' } as never) + const ambientRequest = vi.fn(async () => ({}) as never) + + renderTile(ambientRequest) + const runtimeId = await sessionTileDelegate()!.resumeTile('stored-remote') + + expect(runtimeId).toBe('runtime-remote') + expect(getLatestSessionMessages).toHaveBeenCalledWith('stored-remote', { + connectionId: 'barry', + profile: 'backend-oxcoder' + }) + expect(requestGatewayForAgent).toHaveBeenCalledWith('barry', 'oxcoder', 'session.resume', { + session_id: 'stored-remote', + cols: 96, + omit_messages: true, + profile: 'backend-oxcoder' + }) + expect(ambientRequest).not.toHaveBeenCalled() + }) + it('reuses a warm binding that still carries a transcript', async () => { const stateA = { busy: false, messages: [{ id: 'm1' }], storedSessionId: 'stored-a' } const runtimeIdByStoredSessionIdRef = { current: new Map([['stored-a', 'runtime-a']]) } diff --git a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts index ce4783cb7abb..4eaf3d14d904 100644 --- a/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts +++ b/apps/desktop/src/app/contrib/hooks/use-session-tile-delegate.ts @@ -2,7 +2,9 @@ import { useEffect } from 'react' import { getLatestSessionMessages, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS } from '@/hermes' import { toChatMessages } from '@/lib/chat-messages' -import { publishSessionState, setSessionTileDelegate } from '@/store/session-states' +import { getSessionOwnerHint } from '@/store/session' +import { requestForSessionProfile, type SessionOwnerScope } from '@/store/session-request-router' +import { publishSessionState, sessionTileOwnerRoute, setSessionTileDelegate } from '@/store/session-states' import type { SessionResumeResponse } from '@/types/hermes' import type { usePromptActions } from '../../session/hooks/use-prompt-actions' @@ -71,6 +73,26 @@ export function useSessionTileDelegate({ } } + const ownerForStoredSession = async (storedSessionId: string): Promise => { + const owner = + getSessionOwnerHint(storedSessionId) ?? + sessionTileOwnerRoute(storedSessionId) ?? + (await resolveSessionProfile(storedSessionId)) + + return owner + } + + const requestForStoredSession = async ( + storedSessionId: string, + method: string, + params: Record, + timeoutMs?: number + ): Promise => { + const owner = await ownerForStoredSession(storedSessionId) + + return requestForSessionProfile(owner, requestGateway, method, params, timeoutMs) + } + setSessionTileDelegate({ archiveSession: async storedSessionId => { await archiveSession(storedSessionId) @@ -89,8 +111,12 @@ export function useSessionTileDelegate({ // backend no longer knows. Drop the map so resumeTile's warm path can't // re-bind a tile to a dead runtime; live bindings re-record from // post-reconnect events and fresh resumes. - invalidateRuntimeBindings: () => { - runtimeIdByStoredSessionIdRef.current.clear() + invalidateRuntimeBindings: preserveStoredSessionIds => { + for (const storedSessionId of runtimeIdByStoredSessionIdRef.current.keys()) { + if (!preserveStoredSessionIds?.has(storedSessionId)) { + runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) + } + } }, interruptSession: async runtimeId => { // Same cooldown as the primary chat's Stop (#83855): the gateway may @@ -99,12 +125,20 @@ export function useSessionTileDelegate({ // false. Mark the runtime id (and any recovered id) before the RPC so // the window covers the whole wind-down. markSessionRecentlyInterrupted(runtimeId) + + const storedSessionId = storedSessionIdForRuntime(runtimeId) + + const routedRequest = storedSessionId + ? (method: string, params?: Record, timeoutMs?: number) => + requestForStoredSession(storedSessionId, method, params ?? {}, timeoutMs) + : requestGateway + await withSessionNotFoundResume( runtimeId, - storedSessionIdForRuntime(runtimeId), - liveId => requestGateway('session.interrupt', { session_id: liveId }), + storedSessionId, + liveId => routedRequest('session.interrupt', { session_id: liveId }), { - requestGateway, + requestGateway: routedRequest, onRecovered: recoveredId => { markSessionRecentlyInterrupted(recoveredId) rebindTileRuntime(runtimeId)(recoveredId) @@ -133,15 +167,20 @@ export function useSessionTileDelegate({ // reading messages) without a profile lets the gateway fall back to the // launch-profile DB and fork the conversation into the wrong profile — // the same cross-profile bleed the recovery resumes had (#67603). - const profile = await resolveSessionProfile(storedSessionId) + const owner = await ownerForStoredSession(storedSessionId) + + const restScope = + owner && typeof owner === 'object' + ? { connectionId: owner.connectionId, profile: owner.targetProfile || owner.profile } + : owner const [prefetch, resumed] = await Promise.all([ - getLatestSessionMessages(storedSessionId, profile).catch(() => null), - requestGateway('session.resume', { + getLatestSessionMessages(storedSessionId, restScope).catch(() => null), + requestForSessionProfile(owner, requestGateway, 'session.resume', { session_id: storedSessionId, cols: 96, omit_messages: true, - ...(profile ? { profile } : {}) + ...(owner ? { profile: typeof owner === 'string' ? owner : owner.profile } : {}) }) ]) @@ -165,11 +204,18 @@ export function useSessionTileDelegate({ return runtimeId }, submitToSession: async (runtimeId, text) => { + const storedSessionId = storedSessionIdForRuntime(runtimeId) + + const routedRequest = storedSessionId + ? (method: string, params?: Record, timeoutMs?: number) => + requestForStoredSession(storedSessionId, method, params ?? {}, timeoutMs) + : requestGateway + await withSessionNotFoundResume( runtimeId, - storedSessionIdForRuntime(runtimeId), - liveId => requestGateway('prompt.submit', { session_id: liveId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS), - { requestGateway, onRecovered: rebindTileRuntime(runtimeId) } + storedSessionId, + liveId => routedRequest('prompt.submit', { session_id: liveId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS), + { requestGateway: routedRequest, onRecovered: rebindTileRuntime(runtimeId) } ) }, updateSession: (runtimeId, updater) => updateSessionState(runtimeId, updater) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 7a6e557f7ce8..e5b7dbdcc26b 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -890,7 +890,11 @@ export function ContribWiring({ children }: { children: ReactNode }) { void openNewSessionTile('center', { listed: false, route: workspaceNewSessionTarget.route, - workspaceScope: { workspaceMode: 'bots', workspaceOwnerKey } + workspaceScope: { + ownerRoute: workspaceNewSessionTarget.route, + workspaceMode: 'bots', + workspaceOwnerKey + } }) return diff --git a/apps/desktop/src/app/open-session.test.ts b/apps/desktop/src/app/open-session.test.ts index d2d8339f324d..4a3f95d7df0e 100644 --- a/apps/desktop/src/app/open-session.test.ts +++ b/apps/desktop/src/app/open-session.test.ts @@ -99,7 +99,7 @@ describe('openSession', () => { it('in-place focuses an existing tile and does not navigate', () => { focusOpenSession.mockReturnValue('tile') openSession('s1', navigate) - expect(focusOpenSession).toHaveBeenCalledWith('s1') + expect(focusOpenSession).toHaveBeenCalledWith('s1', { workspaceMode: 'sessions' }) expect(navigate).not.toHaveBeenCalled() expect(openSessionTile).not.toHaveBeenCalled() }) @@ -134,7 +134,7 @@ describe('openSession', () => { it('tab focuses an existing open session instead of stacking another', () => { focusOpenSession.mockReturnValue('tile') openSession('s1', navigate, 'tab') - expect(focusOpenSession).toHaveBeenCalledWith('s1') + expect(focusOpenSession).toHaveBeenCalledWith('s1', { workspaceMode: 'sessions' }) expect(openSessionTile).not.toHaveBeenCalled() expect(navigate).not.toHaveBeenCalled() }) @@ -153,6 +153,7 @@ describe('openSession', () => { openSession('s1', navigate, 'tab', scope) expect(setSessionTileWorkspaceScope).toHaveBeenCalledWith('s1', scope) + expect(focusOpenSession).toHaveBeenCalledWith('s1', scope) expect(openSessionTile).toHaveBeenCalledWith('s1', 'center', undefined, undefined, scope) }) diff --git a/apps/desktop/src/app/open-session.ts b/apps/desktop/src/app/open-session.ts index 0781cf001383..e76a9face204 100644 --- a/apps/desktop/src/app/open-session.ts +++ b/apps/desktop/src/app/open-session.ts @@ -16,6 +16,7 @@ */ import type { WorkspaceMode } from '@/contrib/types' import { $activeSessionId, $selectedStoredSessionId, markSessionRead } from '@/store/session' +import type { SessionProfileRoute } from '@/store/session-request-router' import { focusedSessionNeedsRoute, focusOpenSession, @@ -32,8 +33,10 @@ export type OpenSessionIntent = 'in-place' | 'main' | 'stack' | 'tab' | 'window' export type OpenSessionNavigate = (to: string, options?: { replace?: boolean }) => void export interface OpenSessionWorkspaceScope { + ownerRoute?: SessionProfileRoute workspaceMode: WorkspaceMode workspaceOwnerKey?: string + workspaceTabTitle?: string } /** @@ -131,7 +134,9 @@ export function openSession( // Already on screen? Front it. openSessionTile would no-op on main without // focusing, or try to relocate an existing tile — neither is right for a // soft "open beside" link. - if (focusOpenSession(storedSessionId)) { + const focused = focusOpenSession(storedSessionId, workspaceScope) + + if (focused) { return } @@ -160,7 +165,7 @@ export function openSession( // otherwise load it into main. From a full page (artifacts, skills, …) a // `'main'` hit still has to route back: fronting the workspace tab alone // leaves the page showing. - if (focusedSessionNeedsRoute(focusOpenSession(storedSessionId), $workspaceIsPage.get())) { + if (focusedSessionNeedsRoute(focusOpenSession(storedSessionId, workspaceScope), $workspaceIsPage.get())) { navigate(sessionRoute(storedSessionId)) } } diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index a49c7ccd5e95..0c97b25a4be7 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -5058,7 +5058,8 @@ async function openStoredBotChat(owner, storedId, summary) { throw new Error('This Hermes Desktop version cannot open stored sessions') } - const { name, route } = botOwner(owner) + const { bot, name, route } = botOwner(owner) + const ownerKey = botWorkspaceOwnerKey(bot) const hasAuthoritativeCount = typeof summary?.message_count === 'number' && Number.isFinite(summary.message_count) @@ -5073,23 +5074,14 @@ async function openStoredBotChat(owner, storedId, summary) { await host.openSession(storedId, { ...(route ? { route } : {}), profile: name, - intent: 'main', + intent: 'tab', awaitHydration: true, expectHistory, - // Move the WORKSPACE onto this bot, not just the transcript. - // - // With the default (true) the bot's chat opened against its own backend - // while `$activeGatewayProfile` stayed on whatever profile was active - // before — so "New session" from inside any bot was created on that other - // backend. Measured: four consecutive new chats started from different - // bots all landed in the `ops` profile's state.db. Clicking a bot is a - // workspace switch in this product (one bot = one workspace), so the - // chrome has to follow. EXCEPT across connections: a remote bot's chat - // opens on its own source while Desktop's chrome/API home stays put — - // re-homing the window onto another machine for one chat is the bug - // #90006 exists to remove. - keepAllProfilesScope: route ? true : false, - retryHydrationTimeoutOnce: true + keepAllProfilesScope: true, + workspaceMode: 'bots', + workspaceOwnerKey: ownerKey, + retryHydrationTimeoutOnce: true, + tabTitle: CANONICAL_CHAT_TITLE }) return storedId @@ -13110,9 +13102,15 @@ function BotsPane() { return () => cancelAnimationFrame(frame) }, [hiddenExpanded, hasRosterConstraint]) - if (live) { + useEffect(() => { + if (!live) { + return + } + // Offline-owner ghosts belong only to this render. Shared roster state - // feeds merge caching, group membership, creation, and durable sync. + // feeds merge caching, group membership, creation, and durable sync. These + // writes must settle after render: BotsHomeView subscribes to the same + // atoms, so publishing here used to update it while BotsPane was rendering. $lastRoster.set(roster.filter(row => !row?.ghost)) if (Array.isArray(data?.sources)) { $lastSources.set(data.sources) @@ -13121,7 +13119,10 @@ function BotsPane() { pullServerAvatars(activeSourceRoster) trackInboundActivity(roster) backfillMessagingProtocol(activeSourceRoster) - } + // React Query owns the stable server snapshot; derived arrays intentionally + // follow that snapshot rather than retriggering on their own atom writes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]) // The roster has ANSWERED once data or a terminal error exists — that, not // row count, is what lets the home stop showing its loading state (an empty @@ -13137,6 +13138,11 @@ function BotsPane() { if (selectionHydrated) { reconcileRosterSelection(roster, sourceSnapshot, allMeta) + const selected = selectedRosterBot(roster, $selectedRosterKey.get()) + + if ($botsPaneVisible.get() && !$groupChatWorkspace.get() && selected) { + setBotsWorkspaceOwner(botWorkspaceOwnerKey(selected), selected) + } } }, [data, error, selectionHydrated, roster, sourceSnapshot, allMeta]) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs index f3be856978eb..411b4255fcc9 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs @@ -454,6 +454,7 @@ test('opening a remote bot selects and opens its exact owner chat', async () => assert.equal(t.sessionOpens.length, 1) assert.equal(t.sessionOpens[0].options.workspaceMode, 'bots') assert.equal(t.sessionOpens[0].options.workspaceOwnerKey, 'bot:work-vps::researcher') + assert.equal(t.sessionOpens[0].options.tabTitle, 'Bot Chat') assert.equal(t.sessionOpens[0].options.keepAllProfilesScope, true) assert.equal(t.sessionOpens[0].options.route.connectionId, 'work-vps') assert.ok(t.requests.every(request => request.route?.connectionId === 'work-vps')) @@ -940,7 +941,7 @@ test('roster hydration and selection reconciliation run after render', () => { assert.match( pane, - /useEffect\(\(\) => \{[\s\S]{0,500}?\$rosterHydrated\.set\(true\)[\s\S]{0,300}?reconcileRosterSelection\(roster, sourceSnapshot, allMeta\)[\s\S]{0,220}?\}, \[data, error, selectionHydrated, roster, sourceSnapshot, allMeta\]\)/, + /useEffect\(\(\) => \{[\s\S]{0,500}?\$rosterHydrated\.set\(true\)[\s\S]{0,300}?reconcileRosterSelection\(roster, sourceSnapshot, allMeta\)[\s\S]{0,700}?\}, \[data, error, selectionHydrated, roster, sourceSnapshot, allMeta\]\)/, 'persisted roster ownership must reconcile from an effect, never from a replayable render' ) assert.equal( diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs index 1fb3ad1edff7..331185ecf96a 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs @@ -80,8 +80,12 @@ test('open resolves the profile\u2019s "Bot Chat" row by exact title and opens i assert.equal(runtime.opened.length, 1) assert.equal(runtime.opened[0].id, 'forever-chat') assert.equal(runtime.opened[0].options.profile, 'ops') - assert.equal(runtime.opened[0].options.keepAllProfilesScope, false, - 'opening a bot moves the workspace onto that bot') + assert.equal(runtime.opened[0].options.keepAllProfilesScope, true, + 'opening a bot leaves the Sessions workspace on its current gateway') + assert.equal(runtime.opened[0].options.intent, 'tab') + assert.equal(runtime.opened[0].options.workspaceMode, 'bots') + assert.equal(runtime.opened[0].options.workspaceOwnerKey, 'bot:ops') + assert.equal(runtime.opened[0].options.tabTitle, 'Bot Chat') const list = runtime.requests.find(r => r.method === 'session.list') assert.equal(list?.params?.title, 'Bot Chat', 'lookup is by exact title') diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 1510f14d6120..5f196bbbc396 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -86,7 +86,8 @@ import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId, - $sessionStates + $sessionStates, + $sessionTiles } from '@/store/session-states' import { runGatewayRestart } from '@/store/system-actions' import type { UsageStats } from '@/types/hermes' @@ -141,7 +142,6 @@ const $focusedSessionOwner = computed( [$focusedStoredSessionId, $sessions, $activeGatewayProfile, $connection], (focused, sessions, activeProfile, connection): PluginFocusedSessionOwner | null => { const activeConnectionId = String(connection?.connectionId || (connection?.mode === 'local' ? 'local' : '')).trim() - const fallback = { connectionId: activeConnectionId, profile: normalizeProfileKey(activeProfile) @@ -326,6 +326,7 @@ export interface PluginOpenSessionOptions { * overlay ($resumeExhaustedSessionId) — a caller-side retry can't do this * itself because only this SDK layer sees $resumeExhaustedSessionId. */ retryHydrationTimeoutOnce?: boolean + tabTitle?: string } export interface PluginNewChatOptions { @@ -386,18 +387,23 @@ function waitForFocusedSessionHydration({ const profileMatches = !requireActiveProfile || normalizeProfileKey($activeGatewayProfile.get()) === profile const mainMatches = $selectedStoredSessionId.get() === storedSessionId - const tileMatches = $focusedStoredSessionId.get() === storedSessionId + const storedTile = $sessionTiles.get().find(tile => tile.storedSessionId === storedSessionId) + const tileMatches = $focusedStoredSessionId.get() === storedSessionId || Boolean(storedTile) + const focusedTileMatches = $focusedStoredSessionId.get() === storedSessionId + const tileRuntimeId = focusedTileMatches ? $focusedRuntimeId.get() : (storedTile?.runtimeId ?? null) - const runtimeReady = mainMatches - ? Boolean($activeSessionId.get()) - : tileMatches - ? Boolean($focusedRuntimeId.get()) - : false + const tileState = focusedTileMatches + ? $focusedSessionState.get() + : tileRuntimeId + ? $sessionStates.get()[tileRuntimeId] + : undefined + + const runtimeReady = mainMatches ? Boolean($activeSessionId.get()) : tileMatches ? Boolean(tileRuntimeId) : false const historyPainted = mainMatches ? Boolean($messages.get().length) : tileMatches - ? Boolean($focusedSessionState.get()?.messages.length) + ? Boolean(tileState?.messages.length) : false // Paint-first hydration: for a history-bearing chat, the wake is DONE @@ -427,6 +433,8 @@ function waitForFocusedSessionHydration({ unbinds.push($focusedStoredSessionId.listen(check)) unbinds.push($focusedRuntimeId.listen(check)) unbinds.push($focusedSessionState.listen(check)) + unbinds.push($sessionTiles.listen(check)) + unbinds.push($sessionStates.listen(check)) unbinds.push($workspaceMode.listen(check)) unbinds.push($workspaceOwnerKey.listen(check)) @@ -816,8 +824,10 @@ export const host = { if (options.workspaceMode === 'bots') { openSession(storedSessionId, navigate, intent, { + ownerRoute: ownerRoute ?? undefined, workspaceMode: 'bots', - workspaceOwnerKey: options.workspaceOwnerKey + workspaceOwnerKey: options.workspaceOwnerKey, + ...(options.tabTitle ? { workspaceTabTitle: options.tabTitle } : {}) }) } else { openSession(storedSessionId, navigate, intent) diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index 556bb887b320..129231aadab5 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -43,6 +43,7 @@ vi.mock('@/store/session-states', async () => { $focusedRuntimeId: atom(null), $focusedSessionState: atom(null), $focusedStoredSessionId: atom(null), + $sessionTiles: atom([]), $sessionStates: atom({}) } }) @@ -122,7 +123,8 @@ const { setShowAllProfiles } = await import('@/store/profile') -const { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } = await import('@/store/session-states') +const { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId, $sessionStates, $sessionTiles } = + await import('@/store/session-states') const { setWorkspaceScope } = await import('@/components/pane-shell/workspace-scope') const { @@ -153,6 +155,8 @@ afterEach(() => { setMockAtom($focusedRuntimeId, null) setMockAtom($focusedStoredSessionId, null) setMockAtom($focusedSessionState, null) + setMockAtom($sessionStates, {}) + setMockAtom($sessionTiles, []) setMockAtom($activeSessionId, null) setMockAtom($selectedStoredSessionId, null) setMockAtom($messages, []) @@ -430,6 +434,7 @@ describe('profile-aware plugin session opens', () => { }) expect(openSessionCore).toHaveBeenCalledWith('bot-chat', expect.any(Function), 'in-place', { + ownerRoute: route, workspaceMode: 'bots', workspaceOwnerKey: 'source-a::default' }) @@ -466,6 +471,44 @@ describe('profile-aware plugin session opens', () => { expect($selectedStoredSessionId.get()).toBeNull() }) + it('finishes a restored Bot wake from its hydrated tile before the first pointer focus', async () => { + const route = { + connectionId: 'source-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'backend-default' + } + + const opening = host.openSession('restored-bot-chat', { + awaitHydration: true, + expectHistory: true, + hydrationTimeoutMs: 1_000, + intent: 'tab', + route, + workspaceMode: 'bots', + workspaceOwnerKey: 'bot:source-a::default' + }) + + await Promise.resolve() + setMockAtom($sessionTiles, [ + { + runtimeId: 'runtime-restored', + storedSessionId: 'restored-bot-chat', + workspaceMode: 'bots', + workspaceOwnerKey: 'bot:source-a::default' + } + ]) + setMockAtom($sessionStates, { + 'runtime-restored': { + messages: [{ id: 'restored-history', parts: [], role: 'assistant' }], + storedSessionId: 'restored-bot-chat' + } + } as never) + + await opening + expect($focusedStoredSessionId.get()).toBeNull() + }) + it('strands a late Bot wake when the user returns to Sessions', async () => { let releaseDial: (() => void) | undefined diff --git a/apps/desktop/src/store/session-states.test.ts b/apps/desktop/src/store/session-states.test.ts index 3cf175250746..6eff590dbf7e 100644 --- a/apps/desktop/src/store/session-states.test.ts +++ b/apps/desktop/src/store/session-states.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClientSessionState } from '@/app/types' import { findGroupOfPane, group, split } from '@/components/pane-shell/tree/model' import { $layoutTree } from '@/components/pane-shell/tree/store' +import { $activeGatewayProfile } from '@/store/profile' import { $selectedStoredSessionId } from '@/store/session' import type { SessionTile } from '@/store/session-states' import { @@ -10,6 +11,7 @@ import { $sessionTiles, blankDraftTile, focusedSessionNeedsRoute, + focusOpenSession, markSelectionRestore, nextSessionTileForWorkspace, openSessionTile, @@ -54,17 +56,49 @@ describe('resetTileRuntimeBindings', () => { expect(() => resetTileRuntimeBindings()).not.toThrow() expect($sessionTiles.get()[0]?.runtimeId).toBeUndefined() }) + + it('keeps exact-owner Bot runtimes when only the primary gateway reconnects', () => { + const invalidateRuntimeBindings = vi.fn() + setSessionTileDelegate({ invalidateRuntimeBindings } as unknown as SessionTileDelegate) + $sessionTiles.set([ + { + ownerRoute: { + connectionId: 'barry', + mode: 'remote', + profile: 'oxcoder', + targetProfile: 'oxcoder' + }, + runtimeId: 'runtime-bot', + storedSessionId: 'stored-bot', + workspaceMode: 'bots', + workspaceOwnerKey: 'bot:barry::oxcoder' + } + ]) + + resetTileRuntimeBindings() + + expect($sessionTiles.get()[0]?.runtimeId).toBe('runtime-bot') + expect(invalidateRuntimeBindings).toHaveBeenCalledWith(new Set(['stored-bot'])) + }) }) describe('SessionTile workspace scope', () => { afterEach(() => { + $activeGatewayProfile.set('default') $layoutTree.set(null) $selectedStoredSessionId.set(null) $sessionTiles.set([]) }) it('stores an exact Bot owner and keeps it through placement patches', () => { - const scope = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::default' } + const ownerRoute = { + connectionId: 'connection-a', + mode: 'remote' as const, + profile: 'default', + targetProfile: 'backend-default' + } + + const scope = { ownerRoute, workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::default' } openSessionTile('bot-chat', 'right', undefined, undefined, scope) patchSessionTile('bot-chat', { dir: 'left' }) @@ -72,6 +106,7 @@ describe('SessionTile workspace scope', () => { expect($sessionTiles.get()).toEqual([ expect.objectContaining({ dir: 'left', + ownerRoute, storedSessionId: 'bot-chat', workspaceMode: 'bots', workspaceOwnerKey: 'connection-a::default' @@ -79,6 +114,38 @@ describe('SessionTile workspace scope', () => { ]) }) + it('allows a Bot-scoped tab when the same stored session is hidden in Sessions main', () => { + const scope = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::default' } + + $selectedStoredSessionId.set('bot-chat') + openSessionTile('bot-chat', 'center', undefined, undefined, scope) + + expect($sessionTiles.get()).toEqual([ + expect.objectContaining({ + storedSessionId: 'bot-chat', + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-a::default' + }) + ]) + expect(focusOpenSession('bot-chat', scope)).toBe('tile') + }) + + it('keeps Bot tabs while a profile publication swaps the Sessions bucket', () => { + const scope = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::writer' } + + openSessionTile('sessions-chat') + openSessionTile('bot-chat', 'center', undefined, undefined, scope) + $activeGatewayProfile.set('other-profile') + + expect($sessionTiles.get()).toEqual([ + expect.objectContaining({ + storedSessionId: 'bot-chat', + workspaceMode: 'bots', + workspaceOwnerKey: 'connection-a::writer' + }) + ]) + }) + it('re-scopes an existing tile without changing its placement', () => { openSessionTile('chat', 'bottom', 'workspace') diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index e2570c1bd2f9..119f54bff330 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -48,6 +48,7 @@ import { setActiveSessionStoredIdRotation, setSessions } from './session' +import type { SessionProfileRoute } from './session-request-router' import { ackStoredSessionId, markSessionUnreadFinished } from './session-unread' import { isSecondaryWindow } from './windows' @@ -540,11 +541,17 @@ export interface SessionTile { workspaceMode?: WorkspaceMode /** Exact opaque owner key for Bot Mode tabs. */ workspaceOwnerKey?: string + /** Credential-free exact route used to resume this tab after relaunch. */ + ownerRoute?: SessionProfileRoute + /** Stable title for hidden relationship chats absent from the Sessions list. */ + workspaceTabTitle?: string } export interface SessionTileWorkspaceScope { + ownerRoute?: SessionProfileRoute workspaceMode: WorkspaceMode workspaceOwnerKey?: string + workspaceTabTitle?: string } // Tiles are persisted PER PROFILE: a session belongs to one profile, and the @@ -556,22 +563,32 @@ export interface SessionTileWorkspaceScope { const TILES_KEY = 'hermes.desktop.sessionTiles.v2' const LEGACY_TILES_KEY = 'hermes.desktop.sessionTiles.v1' const TILE_PANE_PREFIX = 'session-tile:' +const BOTS_TILE_BUCKET = '__bots_workspace__' /** Persisted placement — `dir` + strip slot (`before`) + dock `anchor` so a * restart / profile swap re-adopts tiles in the same order, not all stacked * right of workspace. */ type StoredTile = Pick< SessionTile, - 'anchor' | 'before' | 'dir' | 'storedSessionId' | 'workspaceMode' | 'workspaceOwnerKey' + | 'anchor' + | 'before' + | 'dir' + | 'ownerRoute' + | 'storedSessionId' + | 'workspaceMode' + | 'workspaceOwnerKey' + | 'workspaceTabTitle' > const toStored = (t: SessionTile): StoredTile => ({ anchor: t.anchor, before: t.before, dir: t.dir, + ...(t.ownerRoute ? { ownerRoute: t.ownerRoute } : {}), storedSessionId: t.storedSessionId, ...(t.workspaceMode ? { workspaceMode: t.workspaceMode } : {}), - ...(t.workspaceOwnerKey ? { workspaceOwnerKey: t.workspaceOwnerKey } : {}) + ...(t.workspaceOwnerKey ? { workspaceOwnerKey: t.workspaceOwnerKey } : {}), + ...(t.workspaceTabTitle ? { workspaceTabTitle: t.workspaceTabTitle } : {}) }) function parseTileList(value: unknown): StoredTile[] { @@ -585,12 +602,26 @@ function parseTileList(value: unknown): StoredTile[] { anchor: typeof raw.anchor === 'string' ? raw.anchor : undefined, before: typeof raw.before === 'string' || raw.before === null ? raw.before : undefined, dir: raw.dir, + ownerRoute: + raw.ownerRoute && + typeof raw.ownerRoute.connectionId === 'string' && + typeof raw.ownerRoute.profile === 'string' + ? { + connectionId: raw.ownerRoute.connectionId, + mode: raw.ownerRoute.mode, + profile: raw.ownerRoute.profile, + ...(typeof raw.ownerRoute.targetProfile === 'string' + ? { targetProfile: raw.ownerRoute.targetProfile } + : {}) + } + : undefined, storedSessionId: raw.storedSessionId, workspaceMode: raw.workspaceMode === 'bots' ? 'bots' : 'sessions', workspaceOwnerKey: raw.workspaceMode === 'bots' && typeof raw.workspaceOwnerKey === 'string' ? raw.workspaceOwnerKey - : undefined + : undefined, + workspaceTabTitle: typeof raw.workspaceTabTitle === 'string' ? raw.workspaceTabTitle : undefined } }) : [] @@ -603,9 +634,19 @@ function loadTilesByProfile(): Record { if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { for (const [profile, list] of Object.entries(parsed as Record)) { const tiles = parseTileList(list) + const key = profile === BOTS_TILE_BUCKET ? BOTS_TILE_BUCKET : normalizeProfileKey(profile) if (tiles.length > 0) { - byProfile[normalizeProfileKey(profile)] = tiles + const sessionTiles = tiles.filter(tile => tile.workspaceMode !== 'bots') + const botTiles = tiles.filter(tile => tile.workspaceMode === 'bots') + + if (sessionTiles.length > 0) { + byProfile[key] = [...(byProfile[key] ?? []), ...sessionTiles] + } + + if (botTiles.length > 0) { + byProfile[BOTS_TILE_BUCKET] = [...(byProfile[BOTS_TILE_BUCKET] ?? []), ...botTiles] + } } } } @@ -615,7 +656,17 @@ function loadTilesByProfile(): Record { if (legacy.length > 0) { const key = normalizeProfileKey('default') - byProfile[key] = [...(byProfile[key] ?? []), ...legacy] + const sessionTiles = legacy.filter(tile => tile.workspaceMode !== 'bots') + const botTiles = legacy.filter(tile => tile.workspaceMode === 'bots') + + byProfile[key] = [...(byProfile[key] ?? []), ...sessionTiles] + byProfile[BOTS_TILE_BUCKET] = [...(byProfile[BOTS_TILE_BUCKET] ?? []), ...botTiles] + } + + if (byProfile[BOTS_TILE_BUCKET]?.length) { + byProfile[BOTS_TILE_BUCKET] = [ + ...new Map(byProfile[BOTS_TILE_BUCKET].map(tile => [tile.storedSessionId, tile])).values() + ] } writeJson(LEGACY_TILES_KEY, null) @@ -634,7 +685,9 @@ const profileKey = () => normalizeProfileKey($activeGatewayProfile.get()) // atom hydrates from the stored (runtime-less) tiles for the active profile. // A secondary window (single-chat pop-out) shows ONLY its routed session — no // tiles, and no repopulation on a profile switch. -export const $sessionTiles = atom(isSecondaryWindow() ? [] : [...(tilesByProfile[profileKey()] ?? [])]) +export const $sessionTiles = atom( + isSecondaryWindow() ? [] : [...(tilesByProfile[profileKey()] ?? []), ...(tilesByProfile[BOTS_TILE_BUCKET] ?? [])] +) function persistTiles() { // Shares the origin's storage; a secondary window holds no tiles, so a write @@ -647,16 +700,24 @@ function persistTiles() { } function saveTiles(tiles: SessionTile[]) { - $sessionTiles.set(tiles) const stored = tiles.map(toStored) + const sessionTiles = stored.filter(tile => tile.workspaceMode !== 'bots') + const botTiles = stored.filter(tile => tile.workspaceMode === 'bots') - if (stored.length > 0) { - tilesByProfile[profileKey()] = stored + if (sessionTiles.length > 0) { + tilesByProfile[profileKey()] = sessionTiles } else { delete tilesByProfile[profileKey()] } + if (botTiles.length > 0) { + tilesByProfile[BOTS_TILE_BUCKET] = botTiles + } else { + delete tilesByProfile[BOTS_TILE_BUCKET] + } + persistTiles() + $sessionTiles.set(tiles) } // Profile switch: surface the new profile's tiles with runtime ids cleared so @@ -665,7 +726,7 @@ function saveTiles(tiles: SessionTile[]) { // never carries tiles, so it stays out of this entirely. if (!isSecondaryWindow()) { $activeGatewayProfile.subscribe(() => { - $sessionTiles.set([...(tilesByProfile[profileKey()] ?? [])]) + $sessionTiles.set([...(tilesByProfile[profileKey()] ?? []), ...(tilesByProfile[BOTS_TILE_BUCKET] ?? [])]) }) } @@ -673,21 +734,34 @@ export function patchSessionTile(storedSessionId: string, patch: Partial (t.storedSessionId === storedSessionId ? { ...t, ...patch } : t))) } -export function setSessionTileWorkspaceScope( - storedSessionId: string, - scope: SessionTileWorkspaceScope -): boolean { +export function sessionTileOwnerRoute(storedSessionId: string): SessionProfileRoute | undefined { + return $sessionTiles.get().find(tile => tile.storedSessionId === storedSessionId)?.ownerRoute +} + +export function setSessionTileWorkspaceScope(storedSessionId: string, scope: SessionTileWorkspaceScope): boolean { const tile = $sessionTiles.get().find(candidate => candidate.storedSessionId === storedSessionId) const workspaceOwnerKey = scope.workspaceMode === 'bots' ? scope.workspaceOwnerKey : undefined + const ownerRoute = scope.workspaceMode === 'bots' ? scope.ownerRoute : undefined + const workspaceTabTitle = scope.workspaceMode === 'bots' ? scope.workspaceTabTitle : undefined if ( !tile || - ((tile.workspaceMode ?? 'sessions') === scope.workspaceMode && tile.workspaceOwnerKey === workspaceOwnerKey) + ((tile.workspaceMode ?? 'sessions') === scope.workspaceMode && + tile.workspaceOwnerKey === workspaceOwnerKey && + tile.ownerRoute?.connectionId === ownerRoute?.connectionId && + tile.ownerRoute?.profile === ownerRoute?.profile && + tile.ownerRoute?.targetProfile === ownerRoute?.targetProfile && + tile.workspaceTabTitle === workspaceTabTitle) ) { return false } - patchSessionTile(storedSessionId, { workspaceMode: scope.workspaceMode, workspaceOwnerKey }) + patchSessionTile(storedSessionId, { + ownerRoute, + workspaceMode: scope.workspaceMode, + workspaceOwnerKey, + workspaceTabTitle + }) return true } @@ -699,12 +773,18 @@ export function setSessionTileWorkspaceScope( * runtime id from the cache, so post-wake tiles repainted empty and never * actually re-resumed. */ export function resetTileRuntimeBindings() { - sessionTileDelegate()?.invalidateRuntimeBindings?.() - const tiles = $sessionTiles.get() - if (tiles.some(t => t.runtimeId)) { - $sessionTiles.set(tiles.map(toStored)) + const preservedStoredIds = new Set( + tiles + .filter(tile => tile.workspaceMode === 'bots' && Boolean(tile.ownerRoute?.connectionId)) + .map(tile => tile.storedSessionId) + ) + + sessionTileDelegate()?.invalidateRuntimeBindings?.(preservedStoredIds) + + if (tiles.some(tile => tile.runtimeId && !preservedStoredIds.has(tile.storedSessionId))) { + $sessionTiles.set(tiles.map(tile => (preservedStoredIds.has(tile.storedSessionId) ? tile : toStored(tile)))) } } @@ -749,7 +829,7 @@ export interface SessionTileDelegate { * recorded before the reconnect is suspect — without this, `resumeTile`'s * warm path re-binds tiles to dead runtime ids (the sleep/wake "empty * right pane" bug). Bindings re-record from live post-reconnect events. */ - invalidateRuntimeBindings?(): void + invalidateRuntimeBindings?(preserveStoredSessionIds?: ReadonlySet): void /** Bind a live runtime id for a stored session (resume without touching * the main view). Returns the runtime id, or throws. */ resumeTile(storedSessionId: string): Promise @@ -761,9 +841,11 @@ export interface SessionTileDelegate { } let delegate: SessionTileDelegate | null = null +export const $sessionTileDelegateRevision = atom(0) export function setSessionTileDelegate(next: SessionTileDelegate) { delegate = next + $sessionTileDelegateRevision.set($sessionTileDelegateRevision.get() + 1) } export function sessionTileDelegate(): SessionTileDelegate | null { @@ -847,14 +929,13 @@ export function openSessionTile( markSessionRead(storedSessionId) ackStoredSessionId(storedSessionId) - if (storedSessionId === $selectedStoredSessionId.get()) { + if (workspaceScope.workspaceMode === 'sessions' && storedSessionId === $selectedStoredSessionId.get()) { return } const dock = anchor ?? focusedSessionTabAnchor() ?? undefined - const workspaceOwnerKey = - workspaceScope.workspaceMode === 'bots' ? workspaceScope.workspaceOwnerKey : undefined + const workspaceOwnerKey = workspaceScope.workspaceMode === 'bots' ? workspaceScope.workspaceOwnerKey : undefined if (!tiles.some(t => t.storedSessionId === storedSessionId)) { saveTiles([ @@ -863,9 +944,11 @@ export function openSessionTile( anchor: dock, before, dir, + ownerRoute: workspaceScope.workspaceMode === 'bots' ? workspaceScope.ownerRoute : undefined, storedSessionId, workspaceMode: workspaceScope.workspaceMode, - workspaceOwnerKey + workspaceOwnerKey, + workspaceTabTitle: workspaceScope.workspaceMode === 'bots' ? workspaceScope.workspaceTabTitle : undefined } ]) // Adoption is async via the registry — order sync runs after the move path @@ -938,7 +1021,10 @@ export function nextSessionTileForWorkspace(): null | string { * Callers that own the router need the `'main'` vs `'tile'` distinction: a * `'main'` hit only reaches the screen if the workspace pane is actually * showing the chat, whereas a tile renders in its own pane regardless. */ -export function focusOpenSession(storedSessionId: string): 'main' | 'tile' | null { +export function focusOpenSession( + storedSessionId: string, + workspaceScope: SessionTileWorkspaceScope = { workspaceMode: 'sessions' } +): 'main' | 'tile' | null { if ($sessionTiles.get().some(t => t.storedSessionId === storedSessionId)) { const paneId = `${TILE_PANE_PREFIX}${storedSessionId}` revealTreePane(paneId) // un-dismiss + adopt + front in its group @@ -954,7 +1040,7 @@ export function focusOpenSession(storedSessionId: string): 'main' | 'tile' | nul // Already the main session: front the workspace tab and drop tile focus so // the readouts + sidebar highlight come home (a no-op when main is focused). - if (storedSessionId === $selectedStoredSessionId.get()) { + if (workspaceScope.workspaceMode === 'sessions' && storedSessionId === $selectedStoredSessionId.get()) { revealTreePane('workspace') noteActiveTreeGroup(null) From 9ff7f3325c24d937caf9b10546dcf8718dec1dcd Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:54:27 +0200 Subject: [PATCH 108/666] fix(desktop): preserve cross-realm Bot registry errors --- apps/desktop/src/plugins/hermes-bots/plugin.js | 6 +++++- .../src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 0c97b25a4be7..9357b94e38f9 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -5149,7 +5149,11 @@ async function findExistingCanonicalChat(owner) { include_hidden: true }) } catch (error) { - const detail = error instanceof Error && error.message ? ` (${error.message})` : '' + // Plugin tests and host bridges can return Error-like values from another + // JS realm, where `instanceof Error` is false. Preserve the provider/RPC + // message so update-required classification and diagnostics still work. + const message = typeof error?.message === 'string' ? error.message : '' + const detail = message ? ` (${message})` : '' throw new Error(`Could not check ${name}'s Bot Chat registry${detail} — not starting a new chat`) } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs index b37c29cbe49c..e66069014e64 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/hide-bot-chats.test.mjs @@ -278,6 +278,6 @@ test('the canonical-chat adoption scan lists with include_hidden', () => { // and fails CLOSED: a thrown lookup never falls through to minting. assert.match( source, - /requestForBot\(bot, 'session\.list', \{\s*profile: backendTargetProfile\(route, name\),\s*title: CANONICAL_CHAT_TITLE,[\s\S]{0,200}?include_hidden: true\s*\}\)\s*\} catch \(error\) \{[\s\S]{0,400}?const rows = res\?\.sessions \?\? \[\]\s*return rows\.find\(row => isCanonicalBotChatHistory\(row\)\)/ + /requestForBot\(bot, 'session\.list', \{\s*profile: backendTargetProfile\(route, name\),\s*title: CANONICAL_CHAT_TITLE,[\s\S]{0,200}?include_hidden: true\s*\}\)\s*\} catch \(error\) \{[\s\S]{0,800}?const rows = res\?\.sessions \?\? \[\]\s*return rows\.find\(row => isCanonicalBotChatHistory\(row\)\)/ ) }) From 8523819fcf881f8fc903bcd46d68abf2075fb979 Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:53:36 +0200 Subject: [PATCH 109/666] refactor(desktop): consume upstream Bot owner routing --- .../desktop/src/plugins/hermes-bots/plugin.js | 142 +++++++++--------- .../tests/active-now-strip.test.mjs | 2 +- .../hermes-bots/tests/bot-delete.test.mjs | 4 +- .../hermes-bots/tests/bots-home.test.mjs | 12 +- .../tests/canonical-chat-registry.test.mjs | 1 + .../tests/focused-bot-highlight.test.mjs | 6 +- .../hermes-bots/tests/group-chat.test.mjs | 15 +- .../hermes-bots/tests/hide-bots.test.mjs | 19 ++- .../tests/multi-source-roster.test.mjs | 9 ++ .../tests/profile-prewarm.test.mjs | 38 ++++- .../hermes-bots/tests/roster-preview.test.mjs | 7 +- apps/desktop/src/sdk/index.ts | 1 + apps/desktop/src/sdk/profile-routing.test.ts | 1 + 13 files changed, 150 insertions(+), 107 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 9357b94e38f9..a56c91048be6 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -156,7 +156,7 @@ function trackInboundActivity(roster) { // Activity in the exact bot owner the user is currently looking at is // already visible — never badge the open chat or its same-named twin. - if ($selectedRosterKey.get() === key) { + if ($selectedBot.get() === key) { continue } @@ -164,7 +164,7 @@ function trackInboundActivity(roster) { // Roster-hidden bots stay quiet: the unread flag above accumulates // silently (unhiding reveals the badge) but a hidden bot never toasts. - if (isBotHidden(bot, $botMeta.get())) { + if (botRosterMeta(bot, $botMeta.get())?.hidden) { continue } @@ -226,9 +226,9 @@ let botsHomeClose = null let suppressBotsHomeReopen = false function saveSelectedRosterBot(bot) { - const key = botSelectionKey(bot) + const key = botRosterKey(bot) - $selectedBot.set(key) + $selectedBot.set(botSelectionKey(bot)) $selectedRosterKey.set(key) try { @@ -239,7 +239,7 @@ function saveSelectedRosterBot(bot) { } function clearSelectedRosterBot(bot) { - clearSelectedRosterKey(botSelectionKey(bot)) + clearSelectedRosterKey(botRosterKey(bot)) } /** Drop the persisted selection when it is exactly this key — the caller has @@ -1918,12 +1918,8 @@ async function sweepBotProfileSessions() { // back to the active gateway's own profile list (local bots; remote // sources get covered by the next sweep once the roster cache exists). try { - const route = await activeBotRoute() - const res = await requestForBot( - route ? { name: route.profile, sourceScoped: true, route } : { name: 'default' }, - 'profiles.list', - {} - ) + const activeBot = { name: String(host.state.profile?.get?.() || 'default').trim() || 'default' } + const res = await requestForBot(activeBot, 'profiles.list', {}) roster = Array.isArray(res?.profiles) ? res.profiles : [] } catch { return @@ -4224,7 +4220,16 @@ function useRoster() { if (typeof host.agents === 'function') { try { const union = await host.agents() - return { ...mergeMultiSourceRoster(local, union, activeConnectionId, $lastRoster.get()), fetchedAt: issuedAt } + const previous = $lastRoster.get().filter(row => !row?.ghost) + const merged = mergeMultiSourceRoster(local, union, activeConnectionId, previous) + const sources = Array.isArray(union?.sources) ? union.sources : [] + + return { + ...merged, + profiles: (merged?.profiles || []).map(row => annotateBotSource(row, sources)), + sources, + fetchedAt: issuedAt + } } catch { /* older build or roster failure — single-source list stands */ } @@ -4300,7 +4305,6 @@ function cachedUnionRoster() { function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) { const localProfiles = Array.isArray(local?.profiles) ? local.profiles : [] const agents = Array.isArray(union?.agents) ? union.agents : [] - const sources = Array.isArray(union?.sources) ? union.sources : [] // A live id of null/'' means the window is on the unscoped local backend // (legacy hosts reported null for mode:'local'; the SDK now reports // 'local'). Do NOT fall back to registry primary when the third argument @@ -4446,10 +4450,7 @@ function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) const name = String(row?.name || '').trim() const key = `${connectionId}::${name || 'default'}` - // Ghost owners are presentation-only placeholders. Re-adopting one as - // a cached remote row would keep it alive after the selection changes - // and let an identity without its durable handle leak into shared state. - if (row?.ghost || !row?.remoteSource || !connectionId || !name || present.has(key)) { + if (!row?.remoteSource || !connectionId || !name || present.has(key)) { continue } @@ -4464,7 +4465,7 @@ function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) } } - return { ...local, profiles: profiles.map(row => annotateBotSource(row, sources)), sources } + return { ...local, profiles } } /** The @handle users tag a bot with. Multi-source rosters precompute the @@ -5333,7 +5334,7 @@ async function ensureBotMetadata(bot) { * resolves a canonical-chat id. */ async function openRosterBot(bot) { const generation = ++botOpenGeneration - const key = botSelectionKey(bot) + const key = botRosterKey(bot) const meta = botRosterMeta(bot, $botMeta.get()) // Keep the currently visible group as a fallback until this explicit action // has actually fronted a new owner; a failed home open must not steal the @@ -7333,7 +7334,7 @@ function botRowOwnsWorkspace( } if (botsHomeFronted || !botChatFocused) { - return selectedRosterKey === botSelectionKey(bot) + return selectedRosterKey === botRosterKey(bot) } return isActiveRosterBot(bot, focusedOwner) @@ -12458,22 +12459,17 @@ function BotsHomeView() { children: description }) : null, - unavailable || !bot.remoteSource - ? jsx('p', { - className: cn( - 'mt-4 max-w-lg text-xs leading-5', - unavailable ? 'text-amber-700 dark:text-amber-300' : 'text-(--ui-text-tertiary)' - ), - children: unavailable - ? sourceRemoved - ? `${gateway} was removed. Choose another bot from the sidebar.` - : `${gateway} is unavailable. Retry when it is back online.` - : 'Open this bot’s continuous chat. Its background work keeps running when you switch away.' - }) - : jsx('p', { - className: 'mt-4 max-w-lg text-xs leading-5 text-(--ui-text-tertiary)', - children: `This bot lives on ${gateway}. Mention it from any Bot Chat to send it a message.` - }), + jsx('p', { + className: cn( + 'mt-4 max-w-lg text-xs leading-5', + unavailable ? 'text-amber-700 dark:text-amber-300' : 'text-(--ui-text-tertiary)' + ), + children: unavailable + ? sourceRemoved + ? `${gateway} was removed. Choose another bot from the sidebar.` + : `${gateway} is unavailable. Retry when it is back online.` + : 'Open this bot’s continuous chat. Its background work keeps running when you switch away.' + }), unavailable && !sourceRemoved ? jsx(Button, { variant: 'secondary', @@ -12482,15 +12478,13 @@ function BotsHomeView() { onClick: retrySource, children: 'Retry' }) - : bot.remoteSource - ? null - : jsx(Button, { - variant: 'secondary', - size: 'sm', - className: 'mt-5', - onClick: () => void openRosterBot(bot), - children: 'Open chat' - }) + : jsx(Button, { + variant: 'secondary', + size: 'sm', + className: 'mt-5', + onClick: () => void openRosterBot(bot), + children: 'Open chat' + }) ] }) }) @@ -13338,22 +13332,28 @@ function BotsPane() { className: 'flex min-w-0 items-center gap-1 px-2.5 pb-1.5', children: [ showRosterSearch - ? jsx(SearchField, { - 'aria-label': 'Search bots and group chats', - containerClassName: cn( - 'min-w-0 flex-1', - query ? 'opacity-100!' : 'opacity-50 focus-within:opacity-100' - ), - inputClassName: - 'w-full text-[0.75rem] placeholder:text-(--ui-text-tertiary)', - placeholder: 'Search bots and group chats…', - value: query, - onChange: setQuery - }) - : jsx('span', { className: 'min-w-0 flex-1' }), + ? jsx( + SearchField, + { + 'aria-label': 'Search bots and group chats', + containerClassName: cn( + 'min-w-0 flex-1', + query ? 'opacity-100!' : 'opacity-50 focus-within:opacity-100' + ), + inputClassName: + 'w-full text-[0.75rem] placeholder:text-(--ui-text-tertiary)', + placeholder: 'Search bots and group chats…', + value: query, + onChange: setQuery + }, + 'roster-search' + ) + : jsx('span', { className: 'min-w-0 flex-1' }, 'roster-search-spacer'), showRosterFilters - ? jsxs(DropdownMenu, { - children: [ + ? jsxs( + DropdownMenu, + { + children: [ jsx(Tip, { label: activeFilterCount ? `Filters (${activeFilterCount} active)` : 'Filter roster', children: jsx(DropdownMenuTrigger, { @@ -13466,8 +13466,10 @@ function BotsPane() { : null ] }) - ] - }) + ] + }, + 'roster-filters' + ) : null ] }) @@ -13554,10 +13556,12 @@ function BotsPane() { row.kind === 'group' ? renderGroupRow(row) : renderBotRow(row.bot) )), showHiddenSection - ? jsxs('div', { - ref: hiddenSectionRef, - className: 'mt-1 border-t border-(--ui-stroke-tertiary) pt-1', - children: [ + ? jsxs( + 'div', + { + ref: hiddenSectionRef, + className: 'mt-1 border-t border-(--ui-stroke-tertiary) pt-1', + children: [ hasRosterConstraint ? jsxs('div', { className: @@ -13596,8 +13600,10 @@ function BotsPane() { children: 'No hidden bots match these filters.' }) : null - ] - }) + ] + }, + 'hidden-section' + ) : null ] }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs index 6734e3fed7e4..c667250a3060 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/active-now-strip.test.mjs @@ -189,5 +189,5 @@ test('ActiveNowStrip renders above the roster, is a live region, and is click-ac const open = source.slice(openStart, openStart + 3200) assert.match(open, /await prepareBotSource\(bot\)/) - assert.match(open, /await openBotCanonicalChat\(bot\.name\)/) + assert.match(open, /await openBotCanonicalChat\(bot\)/) }) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs index b7f699a1ef94..63c58f0d14a4 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bot-delete.test.mjs @@ -204,13 +204,13 @@ test('unit: older desktop without host.deleteProfile falls back to the non-inter test('integration: a deleted bot is removed from plugin-local state and the roster is refreshed', async () => { const { context, invalidations, stored } = load() context.__delete.$botMeta.set({ researcher: { title: 'Research' }, writer: { title: 'Writer' } }) - context.__delete.$botUnread.set({ 'legacy::researcher': true, 'legacy::writer': true }) + context.__delete.$botUnread.set({ researcher: true, writer: true }) context.__delete.$selectedBot.set('researcher') await context.__delete.deleteBot({ name: 'researcher' }) assert.equal(context.__delete.$botMeta.get().researcher, undefined) - assert.equal(context.__delete.$botUnread.get()['legacy::researcher'], undefined) + assert.equal(context.__delete.$botUnread.get().researcher, undefined) assert.equal(context.__delete.$selectedBot.get(), 'default') assert.equal(stored.at(-1).key, 'bot-meta') assert.equal(stored.at(-1).value.researcher, undefined) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs index 411b4255fcc9..d20db31067c2 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/bots-home.test.mjs @@ -127,7 +127,6 @@ globalThis.__home = { ghostRosterOwner, rosterWithSelectedOwner, reconcileRosterSelection, - saveRosterPreference, saveSelectedRosterBot, clearSelectedRosterBot, clearSelectedRosterKey, @@ -197,7 +196,7 @@ test('selection persists the source-qualified key, not the bare profile name', ( assertNothingRouted(t, 'saving a selection') }) -test('a remote selection never redirects the bare-name consumers at a local twin', () => { +test('a remote selection keeps the exact-owner selection distinct from a local twin', () => { const t = load() t.setPluginCtx({ storage: { set: () => undefined } }) @@ -210,8 +209,8 @@ test('a remote selection never redirects the bare-name consumers at a local twin assert.equal(t.$selectedRosterKey.get(), 'mac-mini::default') assert.equal( t.$selectedBot.get(), - 'default', - 'the Cronjobs/slash-guard name tracker keeps its LOCAL owner — a remote row must not claim it' + 'mac-mini::default', + 'the shared selection follows the same exact owner as the roster workspace' ) }) @@ -331,10 +330,7 @@ test('a hidden bot is not auto-selected', () => { const sources = [{ connectionId: 'local', kind: 'local', label: 'This device', reachable: true }] const roster = [{ connectionId: 'local', name: 'hidden-one' }, { connectionId: 'local', name: 'writer' }] - // Hidden is a source-qualified Desktop preference, so hide the exact row. - t.saveRosterPreference(roster[0], 'hidden', true) - - t.reconcileRosterSelection(roster, sources, {}) + t.reconcileRosterSelection(roster, sources, { 'hidden-one': { hidden: true } }) assert.equal( t.$selectedRosterKey.get(), diff --git a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs index 331185ecf96a..c2ef83ca3da9 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/canonical-chat-registry.test.mjs @@ -48,6 +48,7 @@ function loadOpenPath({ openSession, request }) { ? { bot: { name: owner }, key: owner, name: owner, route: null } : { bot: owner, key: owner?.name, name: owner?.name, route: owner?.route || null }), backendTargetProfile: (route, name) => route?.targetProfile || name, + botWorkspaceOwnerKey: bot => `bot:${bot?.connectionId ? `${bot.connectionId}::` : ''}${bot?.name || 'default'}`, requestForBot: (_bot, method, params) => context.host.request(method, params), window: { setTimeout: callback => callback() } } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/focused-bot-highlight.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/focused-bot-highlight.test.mjs index 7b31017a8c07..8ca96f3e0e33 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/focused-bot-highlight.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/focused-bot-highlight.test.mjs @@ -58,17 +58,17 @@ test('BotRow keys the highlight off the focused profile, not the socket home', ( const row = source.slice(rowStart, rowStart + 2000) assert.match(row, /const focusedOwner = focusedRosterOwner\(useValue\(\$focusedBotOwner\)\)/) - assert.match(row, /const isActive = !activeGroup && isActiveRosterBot\(bot, focusedOwner\)/) + assert.match(row, /const isActive = botRowOwnsWorkspace\([\s\S]*?focusedOwner,[\s\S]*?selectedRosterKey/) }) test('BotRow keeps turn-busy (work mood) a socket fact', () => { const rowStart = source.indexOf('function BotRow(') - const row = source.slice(rowStart, rowStart + 3000) + const row = source.slice(rowStart, rowStart + 5000) // Only the gateway-home profile can actually be mid-turn: the mood must NOT // switch to the focus-keyed identity. assert.match(row, /const isGatewayHome = !bot\.remoteSource && bot\.name === activeProfile/) - assert.match(row, /const botMood = \(isGatewayHome && gatewayState === 'busy'\) \|\| activeNow \? 'work' : 'idle'/) + assert.match(row, /const botMood = workerActive \|\| \(isGatewayHome && gatewayState === 'busy'\) \? 'work' : 'idle'/) }) test('RoutinesPane scopes the Cronjobs tile to the focused chat owner', () => { diff --git a/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs index f84a1a56bc28..626d2a276924 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/group-chat.test.mjs @@ -1148,7 +1148,7 @@ test('closing an older selected group does not clear the newer selection', () => }) test('source contract: active group styling suppresses bot styling', () => { - assert.match(pluginSource, /const isActive = !activeGroup && isActiveRosterBot\(bot, focusedOwner\)/) + assert.match(pluginSource, /function botRowOwnsWorkspace\([\s\S]*?if \(activeGroup\) \{\s*return false/) assert.match(pluginSource, /active && 'bg-\(--ui-row-active-background\)'/) assert.match(pluginSource, /active: groupChatName === row\.name/) }) @@ -1261,10 +1261,10 @@ test('disband: a running room leaves an epoch-bumped empty tombstone so in-fligh 'disbanded name is immediately reusable — tombstone does not hold it') }) -test('source contract: workspace header offers disband behind a ConfirmDialog', () => { +test('source contract: workspace deletion stays behind a ConfirmDialog', () => { assert.match(pluginSource, /function disbandGroupChat\(/) - assert.match(pluginSource, /Disband group chat\?/) - assert.match(pluginSource, /title: `Disband the \$\{group\} group chat`/) + assert.match(pluginSource, /title: 'Delete group chat\?'/) + assert.match(pluginSource, /await disbandGroupChat\(deletingGroup\.name, deletingGroup\.members\)/) }) test('default profile speaks as Hermes in room transcripts, not @default', () => { @@ -1328,9 +1328,10 @@ test('source contract: room messages carry the speaker avatar via the roster app assert.match(workspace, /image && !isBackfilledFacePng\(image\)/) assert.match(workspace, /jsx\(BotFace, \{\s*shape,\s*color,\s*image: photo \? image : null,\s*size: 24,\s*name: entry\.from\.name/) - // Header shows the member faces (capped) with a names tooltip. - assert.match(workspace, /members\.slice\(0, 6\)\.map\(/) - assert.match(workspace, /title: members\.map\(b => displayName\(b, botRosterMeta\(b, allMeta\)\)\)\.join\(', '\)/) + // Header stays quiet: member avatars belong to messages, while the header + // exposes a concise count instead of an overlapping face stack. + assert.doesNotMatch(workspace, /members\.slice\(0, 6\)\.map\(/) + assert.match(workspace, /children: members\.length > 0 && availableMembers < members\.length \? availabilityLabel : `\$\{members\.length\} bots`/) }) test('stranded harvest: a timed-out turn whose reply landed late posts into the room and clears the marker', async () => { diff --git a/apps/desktop/src/plugins/hermes-bots/tests/hide-bots.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/hide-bots.test.mjs index 98efb82509ad..2f2289210164 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/hide-bots.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/hide-bots.test.mjs @@ -186,24 +186,23 @@ test('activity: a hidden bot accumulates unread silently but never toasts, even // ── source shape ───────────────────────────────────────────────────────────── -test('shape: roster list filters through isBotHidden unless the show-hidden toggle is on', () => { +test('shape: visible and hidden bots render through separate recoverable sections', () => { assert.match( pluginSource, - /const visibleRoster = showHidden \? roster : roster\.filter\(bot => !isBotHidden\(bot, allMeta\)\)/ + /const visibleRoster = roster\.filter\(bot => !isBotHidden\(bot, allMeta\)\)/ ) - assert.match(pluginSource, /const filteredRoster = filterBots\(visibleRoster, allMeta, query\)/) + assert.match(pluginSource, /const matchingHiddenBots = rowKindFilter === 'groups' \? \[\] : filteredHiddenBots/) }) -test('shape: header eye toggle renders only when at least one bot is hidden', () => { - assert.match(pluginSource, /hiddenBots\.length\s*\n?\s*\? jsx\(Tip, \{/) - assert.match(pluginSource, /onClick: \(\) => \$showHiddenBots\.set\(!showHidden\)/) +test('shape: Hidden section renders only when recovery is relevant', () => { + assert.match(pluginSource, /const showHiddenSection = hiddenBots\.length > 0/) + assert.match(pluginSource, /onClick: \(\) => \$showHiddenBots\.set\(!hiddenExpanded\)/) }) -test('shape: revealed hidden rows are dimmed and flagged with the eye-closed glyph', () => { +test('shape: revealed hidden rows are flagged and can be restored', () => { const botRow = pluginSource.slice(pluginSource.indexOf('function BotRow('), pluginSource.indexOf('// ── model picker')) - assert.match(botRow, /meta\?\.hidden && 'opacity-60'/) assert.match(botRow, /name: 'eye-closed'/) - assert.match(botRow, /children: meta\?\.hidden \? 'Unhide Bot' : 'Hide Bot'/) + assert.match(botRow, /children: hidden \? 'Unhide' : 'Hide'/) assert.match(botRow, /saveBotMeta\(bot, \{ hidden: !hidden \}\)/) }) @@ -213,7 +212,7 @@ test('shape: hiding never filters mentions, group flows, or the meta/activity sw // which is derived from the unfiltered roster, not visibleRoster. assert.match(pluginSource, /const activeSourceRoster = roster\.filter\(bot => !bot\.remoteSource\)/) assert.match(pluginSource, /mergeServerMeta\(activeSourceRoster, data\?\.fetchedAt \|\| 0\)/) - assert.match(pluginSource, /trackInboundActivity\(activeSourceRoster\)/) + assert.match(pluginSource, /trackInboundActivity\(roster\)/) // Mention resolution never consults the hidden flag. const mentions = pluginSource.slice( pluginSource.indexOf('function resolveRosterMentions('), diff --git a/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs index ee0ae378a508..815e21585e4f 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs @@ -661,3 +661,12 @@ test('resolveRosterMentions: @hermes in this chat is not a handoff to yourself', assert.equal(hits.length, 1) assert.equal(hits[0].connectionId, 'mac-mini') }) + +test('source contract: active roster queries use the SDK ambient owner route', () => { + assert.doesNotMatch(source, /activeBotRoute/) + assert.equal( + source.match(/requestForBot\(activeBot, 'profiles\.list', \{\}\)/g)?.length, + 2, + 'roster hydration and the session sweep must both use the upstream ambient-owner route' + ) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs index a79bfeb3c7ed..e03e48579f0e 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs @@ -24,7 +24,6 @@ function activitySessionSource() { function renderBotRow(input = 'alpha') { const bot = typeof input === 'string' ? { name: input } : input const name = bot.name - const prepareSource = sourceBetween('async function prepareBotSource(', 'function displayName(') const botRowSource = sourceBetween('function BotRow(', '// ── model picker') // REAL owner-route resolver — the derivation from bare connectionId rows is // exactly what these tests exercise, so a hand stub would drift. @@ -48,20 +47,28 @@ function renderBotRow(input = 'alpha') { ContextMenuItem: 'ContextMenuItem', ContextMenuSeparator: 'ContextMenuSeparator', ContextMenuTrigger: 'ContextMenuTrigger', + Codicon: 'Codicon', + Tip: 'Tip', ROSTER_KEY: ['hermes-bots', 'roster'], $botMeta: atom({}), $botUnread: atom({}), + $botChatFocused: atom(false), + $botsHomeFronted: atom(false), $focusedBotOwner: atom({ connectionId: 'local', profile: 'default' }), $focusedBotProfile: atom('default'), $groupChatWorkspace: atom(null), $lastRoster: atom([]), $selectedBot: atom('default'), + $selectedRosterKey: atom(''), botAppearance: () => ({ shape: 'round', color: '#000', image: null }), botMetaKey: value => value.sourceScoped ? `${value.route?.connectionId || value.connectionId}::${value.name}` : value.name, botGroups: () => [], botHandle: value => value, + botRosterKey: value => `${value.connectionId || 'legacy'}::${value.name}`, + botRowOwnsWorkspace: () => false, + botSourceStatus: () => ({ available: true, label: 'Ready' }), botOpenGeneration: 0, botRosterMeta: (_bot, metaByName) => { const key = _bot.sourceScoped @@ -73,6 +80,7 @@ function renderBotRow(input = 'alpha') { createCanonicalChat: async () => null, displayName: bot => bot.name, duplicateBot: async () => `${name}-copy`, + ensureBotMetadata: async () => ({ pinned: true }), haptic: () => undefined, // #49 session-aware-row helpers referenced inside BotRow. previewKind: () => ({ fromBot: false, sender: null }), @@ -80,6 +88,8 @@ function renderBotRow(input = 'alpha') { focusedRosterOwner: owner => ({ connectionId: owner.connectionId, name: owner.profile }), isActiveRosterBot: () => false, isBackfilledFacePng: () => false, + isBotHidden: () => false, + isBotPinned: () => false, botSelectionKey: value => value.sourceScoped ? `${value.connectionId}::${value.name}` : value.name, isDefaultBot: value => value.name === 'default', newBotChat: () => undefined, @@ -87,6 +97,11 @@ function renderBotRow(input = 'alpha') { opened.push(args) return 'stored-chat' }, + openRosterBot: async value => { + opened.push([value]) + return true + }, + workerActiveAt: () => false, ACTIVE_WINDOW_S: 90, A2A_PREFIX_RE: /^$/, useEffect: () => undefined, @@ -131,7 +146,7 @@ function renderBotRow(input = 'alpha') { useValue: store => store.get() } - vm.runInNewContext(`${activitySessionSource()}\n${routeSource}\n${prepareSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context) + vm.runInNewContext(`${activitySessionSource()}\n${routeSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context) const tree = context.BotRow({ bot, onEdit: context.onEdit }) const row = tree.type === 'button' ? tree : tree.props.children[0].props.children @@ -211,7 +226,6 @@ test('behavior: remote default never opens the same-name local chat', async () = remoteSource: true, sourceScoped: true } - const prepareSource = sourceBetween('async function prepareBotSource(', 'function displayName(') const botRowSource = sourceBetween('function BotRow(', '// ── model picker') const ensured = [] const opened = [] @@ -230,29 +244,40 @@ test('behavior: remote default never opens the same-name local chat', async () = ContextMenuItem: 'ContextMenuItem', ContextMenuSeparator: 'ContextMenuSeparator', ContextMenuTrigger: 'ContextMenuTrigger', + Codicon: 'Codicon', + Tip: 'Tip', ROSTER_KEY: ['hermes-bots', 'roster'], $botMeta: atom({ default: { chat: 'this-device-chat' } }), $botUnread: atom({}), + $botChatFocused: atom(false), + $botsHomeFronted: atom(false), $focusedBotOwner: atom({ connectionId: 'mac-mini', profile: 'default' }), $focusedBotProfile: atom('default'), $groupChatWorkspace: atom(null), $lastRoster: atom([]), $selectedBot: atom('default'), + $selectedRosterKey: atom(''), botAppearance: () => ({ shape: 'round', color: '#000', image: null }), botGroups: () => [], botHandle: value => value, + botRosterKey: value => `${value.connectionId || 'legacy'}::${value.name}`, + botRowOwnsWorkspace: () => false, + botSourceStatus: () => ({ available: true, label: 'Ready' }), botOpenGeneration: 0, botRosterMeta: () => null, cn: (...values) => values.filter(Boolean).join(' '), createCanonicalChat: async () => null, displayName: bot => bot.connectionLabel || bot.name, duplicateBot: async () => 'copy', + ensureBotMetadata: async () => ({ pinned: true }), haptic: () => undefined, previewKind: () => ({ fromBot: false, sender: null }), generatedSessionTitle: () => null, focusedRosterOwner: owner => ({ connectionId: owner.connectionId, name: owner.profile }), isActiveRosterBot: () => false, isBackfilledFacePng: () => false, + isBotHidden: () => false, + isBotPinned: () => false, botSelectionKey: value => value.sourceScoped ? `${value.connectionId}::${value.name}` : value.name, isDefaultBot: value => value.name === 'default', newBotChat: () => undefined, @@ -260,6 +285,11 @@ test('behavior: remote default never opens the same-name local chat', async () = opened.push(args) return 'this-device-chat' }, + openRosterBot: async value => { + opened.push([value]) + return true + }, + workerActiveAt: () => false, ACTIVE_WINDOW_S: 90, A2A_PREFIX_RE: /^$/, useEffect: () => undefined, @@ -287,7 +317,7 @@ test('behavior: remote default never opens the same-name local chat', async () = } const routeSource = sourceBetween('function botConnectionRoute(', 'function rewriteCliProfileOperands(') - vm.runInNewContext(`${activitySessionSource()}\n${routeSource}\n${prepareSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context) + vm.runInNewContext(`${activitySessionSource()}\n${routeSource}\n${botRowSource}\nglobalThis.BotRow = BotRow`, context) const tree = context.BotRow({ bot, onEdit: context.onEdit }) const row = tree.type === 'button' ? tree : tree.props.children[0].props.children diff --git a/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs index 89cc02fdef56..989767e97a02 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/roster-preview.test.mjs @@ -205,7 +205,7 @@ test('render: BotRow tolerates a fresh bot with no sessions yet', () => { assert.doesNotMatch(text, /No conversations yet/) }) -test('render: a remote bot keeps its identity while gateway details stay in the tooltip', () => { +test('render: a remote default uses its gateway identity without repeating it', () => { const r = renderRuntime() const tree = r.__BotRow({ bot: { @@ -217,14 +217,13 @@ test('render: a remote bot keeps its identity while gateway details stay in the }, onEdit: () => undefined }) - const name = findNode(tree, node => node.type === 'span' && textOf(node) === 'Hermes') + const name = findNode(tree, node => node.type === 'span' && textOf(node) === 'Studio over SSH') const button = findNode(tree, node => node.type === 'button' && node.props?.['aria-label']) assert.ok(name) assert.ok(button) - assert.match(button.props['aria-label'], /Hermes/) assert.match(button.props['aria-label'], /Studio over SSH/) - assert.doesNotMatch(textOf(button), /Studio over SSH/) + assert.equal((textOf(button).match(/Studio over SSH/g) || []).length, 1) }) test('render: BotRow previews the pinned canonical chat, not an unrelated latest session', () => { diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 5f196bbbc396..9495cc969c4f 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -142,6 +142,7 @@ const $focusedSessionOwner = computed( [$focusedStoredSessionId, $sessions, $activeGatewayProfile, $connection], (focused, sessions, activeProfile, connection): PluginFocusedSessionOwner | null => { const activeConnectionId = String(connection?.connectionId || (connection?.mode === 'local' ? 'local' : '')).trim() + const fallback = { connectionId: activeConnectionId, profile: normalizeProfileKey(activeProfile) diff --git a/apps/desktop/src/sdk/profile-routing.test.ts b/apps/desktop/src/sdk/profile-routing.test.ts index 129231aadab5..e70116947f69 100644 --- a/apps/desktop/src/sdk/profile-routing.test.ts +++ b/apps/desktop/src/sdk/profile-routing.test.ts @@ -125,6 +125,7 @@ const { const { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId, $sessionStates, $sessionTiles } = await import('@/store/session-states') + const { setWorkspaceScope } = await import('@/components/pane-shell/workspace-scope') const { From 7ead9b93de49d2b1f70bf38b7d7b671fa1759cec Mon Sep 17 00:00:00 2001 From: David Dudok de Wit <5354424+dokterdok@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:52:02 +0200 Subject: [PATCH 110/666] fix(desktop): resolve Bot tiles on their owner --- .../src/app/chat/session-tile-owner-route.test.ts | 13 +++++++++++++ apps/desktop/src/app/chat/session-tile.tsx | 5 +++-- 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/chat/session-tile-owner-route.test.ts diff --git a/apps/desktop/src/app/chat/session-tile-owner-route.test.ts b/apps/desktop/src/app/chat/session-tile-owner-route.test.ts new file mode 100644 index 000000000000..31058c578dd3 --- /dev/null +++ b/apps/desktop/src/app/chat/session-tile-owner-route.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { describe, expect, it } from 'vitest' + +const source = readFileSync(resolve(process.cwd(), 'src/app/chat/session-tile.tsx'), 'utf8') + +describe('SessionTilePane owner-scoped listing', () => { + it('resolves a newly active tile on its persisted owner route', () => { + expect(source).toContain('void resolveStoredSession(storedSessionId, ownerRoute)') + expect(source).not.toMatch(/void resolveStoredSession\(storedSessionId\)\s*\n/) + }) +}) diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx index 4a234022dff3..f0479448c197 100644 --- a/apps/desktop/src/app/chat/session-tile.tsx +++ b/apps/desktop/src/app/chat/session-tile.tsx @@ -256,6 +256,7 @@ function TileChat({ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }) { const tiles = useStore($sessionTiles) const tile = tiles.find(t => t.storedSessionId === storedSessionId) + const ownerRoute = tile?.ownerRoute const runtimeId = tile?.runtimeId ?? null const gatewayOpen = useStore($gatewayState) === 'open' const delegateRevision = useStore($sessionTileDelegateRevision) @@ -287,7 +288,7 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string } return } - void resolveStoredSession(storedSessionId) + void resolveStoredSession(storedSessionId, ownerRoute) .then(resolved => { if (cancelled || resolved || remaining <= 0) { return @@ -307,7 +308,7 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string } window.clearTimeout(timer) } } - }, [hasMessages, runtimeId, storedSessionId]) + }, [hasMessages, ownerRoute, runtimeId, storedSessionId]) // Same gating as the primary's route resume (use-route-resume): never fire // session.resume before the gateway is OPEN. Persisted tiles mount at boot From 2ec229ec5adfe7372d5a858802af406f82b5beb0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:01:47 -0700 Subject: [PATCH 111/666] fixup: roster query keeps SDK ambient owner route; alias index refresh preserved --- apps/desktop/src/plugins/hermes-bots/plugin.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index a56c91048be6..ece7d449d5ea 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -4190,7 +4190,6 @@ function useRoster() { // come from the ACTIVE gateway's profiles.list — the canonical Bot // Chat is resolved server-side by NAME (the "Bot Chat" registry row), // so the roster never sends session pointers. - const route = await activeBotRoute() // Refresh the alias identity index alongside the roster: alias routes // (Desktop profile → remote backend root) are what let a backend row // keep its configured friendly identity after activation (#89131). @@ -4203,9 +4202,9 @@ function useRoster() { /* keep the previous alias index */ } } - const activeBot = route - ? { name: route.profile, sourceScoped: true, route } - : { name: String(host.state.profile?.get?.() || 'default').trim() || 'default' } + // Owner routing is ambient in the SDK now (post-#92731): requestForBot + // resolves the active owner itself, no captured route needed here. + const activeBot = { name: String(host.state.profile?.get?.() || 'default').trim() || 'default' } const local = await requestForBot(activeBot, 'profiles.list', {}) // Newer backends inject the teammate-messaging protocol into every // session's system prompt (agent.bot_mode_protocol) — SOUL.md must not From dfcef70061583ed98be8503756744c19423e73d7 Mon Sep 17 00:00:00 2001 From: Jack Lau <72348727+jackulau@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:28:42 -0500 Subject: [PATCH 112/666] fix(update): stop a gateway we cannot relaunch instead of leaving it on stale code Fixes #88654. After an in-place update, the manual-gateway leg of the restart phase did this for every profile-mapped gateway: restart_mode = _prepare_profile_gateway_update_restart(proc.profile, pid) if restart_mode is None: continue A None means no relaunch could be armed. The bare continue skipped the drain and the stop, and the unmapped sweep immediately below skips any pid already in profile_processes, so the process was never killed and never counted into the "Stopped N manual gateway process(es)" summary. The gateway kept running with its pre-update modules resident while the new code sat on disk, and every lazy import from that point mixed versions: cannot import name '_MAX_TOOL_ERROR_CHARS' from 'tools.registry' with no operator signal of any kind. Two changes. _prepare_profile_gateway_update_restart now falls back to replaying the process's own captured command line when the profile-derived relaunch cannot be armed. launch_detached_gateway_restart_by_cmdline already exists for exactly this case and documents itself as the companion for gateways with no profile mapping; the Windows post-update path already uses it the same way. The argv is captured a few lines earlier for the external-supervisor check, so the fallback costs nothing extra. The external-supervisor branch still short-circuits first, because replaying argv there would escape the manager and race its replacement process. When neither mechanism can arm a relaunch, the update path no longer falls through silently. It says so, naming the profile and pid, and hands the process to the existing unmapped sweep so it is stopped and reported through the established "Restart manually: hermes gateway run" contract. Leaving it running was the actual harm: a gateway on stale modules fails every lazy import for as long as it lives. --- hermes_cli/gateway.py | 12 ++ hermes_cli/update_cmd.py | 19 ++- .../test_update_gateway_restart_fallback.py | 120 ++++++++++++++++++ 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_update_gateway_restart_fallback.py diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 1cd3d11aaeda..90193f291742 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -863,12 +863,24 @@ def _prepare_profile_gateway_update_restart(profile: str, pid: int) -> str | Non manager. Starting Hermes's detached watcher as well would escape the manager and race its replacement process. Ordinary foreground gateways retain the existing detached-watcher behavior. + + When the profile-derived relaunch cannot be armed -- typically because + ``_gateway_run_args_for_profile`` cannot rebuild a run argv for this + profile -- fall back to replaying the process's own captured command + line, which is what ``launch_detached_gateway_restart_by_cmdline`` + exists for and what the Windows post-update path already does for its + unmapped gateways. Without this the caller has no way to relaunch the + process and (before #88654) silently left it running pre-update modules + against post-update code on disk. ``argv`` is already captured above, + so the fallback costs nothing extra. """ argv = _capture_gateway_argv(pid) if argv and "--external-supervisor" in argv: return "external-supervisor" if launch_detached_profile_gateway_restart(profile, pid): return "detached" + if argv and launch_detached_gateway_restart_by_cmdline(pid, list(argv)): + return "detached-cmdline" return None diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 7b40ed03de6f..292c551fa6fe 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -7890,11 +7890,28 @@ def _on_unit_timeout(svc_name: str, exc: subprocess.TimeoutExpired) -> None: for proc in find_profile_gateway_processes(exclude_pids=service_pids) if proc.pid in manual_pids } + # Profile gateways we could not arm a relaunch for. These must + # NOT be left running: their modules are the pre-update ones and + # every lazy import from here on mixes versions against the new + # code on disk (#88654). Handing them to the unmapped sweep + # below stops them and surfaces them in the "Stopped N manual + # gateway process(es) / Restart manually" summary, which is the + # contract already used for gateways with no profile mapping. + unrestartable_pids = set() for pid, proc in profile_processes.items(): restart_mode = _prepare_profile_gateway_update_restart( proc.profile, pid ) if restart_mode is None: + # Previously a bare ``continue``: the gateway was neither + # relaunched nor stopped nor mentioned, so it kept serving + # from stale modules with no operator signal at all. + print( + f" ⚠ {proc.profile}: could not arm an automatic " + f"gateway restart for PID {pid} — stopping it instead " + "so it cannot keep running pre-update code" + ) + unrestartable_pids.add(pid) continue # Prefer a graceful SIGUSR1 drain so in-flight agent runs # finish before the watcher respawns the gateway. If the @@ -7961,7 +7978,7 @@ def _on_unit_timeout(svc_name: str, exc: subprocess.TimeoutExpired) -> None: relaunched_profiles.append(proc.profile) for pid in manual_pids: - if pid in profile_processes: + if pid in profile_processes and pid not in unrestartable_pids: continue try: os.kill(pid, _signal.SIGTERM) diff --git a/tests/hermes_cli/test_update_gateway_restart_fallback.py b/tests/hermes_cli/test_update_gateway_restart_fallback.py new file mode 100644 index 000000000000..d26f239102b6 --- /dev/null +++ b/tests/hermes_cli/test_update_gateway_restart_fallback.py @@ -0,0 +1,120 @@ +"""Regression coverage for #88654. + +``hermes update`` relaunches manually-run profile gateways through +``_prepare_profile_gateway_update_restart``. When the profile-derived +relaunch could not be armed the helper returned ``None``, and the update +path's response to ``None`` was a bare ``continue`` -- so the gateway was +neither relaunched, nor stopped, nor mentioned. It kept serving from +pre-update modules while the new code sat on disk, and every lazy import +from that point mixed versions. + +The helper now falls back to replaying the process's own captured command +line via ``launch_detached_gateway_restart_by_cmdline`` -- the companion +that already exists for gateways with no profile mapping, and that the +Windows post-update path already uses for exactly this case. +""" + +import pytest + +import hermes_cli.gateway as gateway + + +_ARGV = ["python", "-m", "hermes_cli.main", "gateway", "run"] + + +def _stub_argv(monkeypatch, argv): + monkeypatch.setattr(gateway, "_capture_gateway_argv", lambda _pid: argv) + + +def test_profile_relaunch_wins_and_skips_the_cmdline_replay(monkeypatch): + """The existing path is unchanged: a profile relaunch short-circuits.""" + _stub_argv(monkeypatch, list(_ARGV)) + monkeypatch.setattr( + gateway, "launch_detached_profile_gateway_restart", lambda *_a: True + ) + monkeypatch.setattr( + gateway, + "launch_detached_gateway_restart_by_cmdline", + lambda *_a: pytest.fail("cmdline replay must not run when the profile path works"), + ) + + assert gateway._prepare_profile_gateway_update_restart("fitness", 4242) == "detached" + + +def test_falls_back_to_cmdline_replay_when_profile_relaunch_fails(monkeypatch): + """The #88654 fix: an unarmable profile relaunch still gets the gateway back.""" + _stub_argv(monkeypatch, list(_ARGV)) + monkeypatch.setattr( + gateway, "launch_detached_profile_gateway_restart", lambda *_a: False + ) + seen = [] + + def _by_cmdline(pid, argv): + seen.append((pid, argv)) + return True + + monkeypatch.setattr( + gateway, "launch_detached_gateway_restart_by_cmdline", _by_cmdline + ) + + assert ( + gateway._prepare_profile_gateway_update_restart("fitness", 4242) + == "detached-cmdline" + ) + # Replays the process's OWN argv, which is the whole point: the profile + # could not be mapped back to a run argv, so the captured one is the only + # faithful description of how to restart it. + assert seen == [(4242, _ARGV)] + + +def test_returns_none_when_there_is_no_argv_to_replay(monkeypatch): + """No captured argv means no honest way to relaunch; caller must be told.""" + _stub_argv(monkeypatch, []) + monkeypatch.setattr( + gateway, "launch_detached_profile_gateway_restart", lambda *_a: False + ) + monkeypatch.setattr( + gateway, + "launch_detached_gateway_restart_by_cmdline", + lambda *_a: pytest.fail("must not replay an empty argv"), + ) + + assert gateway._prepare_profile_gateway_update_restart("fitness", 4242) is None + + +def test_returns_none_when_both_relaunch_paths_fail(monkeypatch): + """Both mechanisms failing is still reported as None, not a false success.""" + _stub_argv(monkeypatch, list(_ARGV)) + monkeypatch.setattr( + gateway, "launch_detached_profile_gateway_restart", lambda *_a: False + ) + monkeypatch.setattr( + gateway, "launch_detached_gateway_restart_by_cmdline", lambda *_a: False + ) + + assert gateway._prepare_profile_gateway_update_restart("fitness", 4242) is None + + +def test_external_supervisor_still_short_circuits_before_any_replay(monkeypatch): + """Guardrail: the supervisor hand-back must not gain a replay behind it. + + Replaying the argv for an externally supervised gateway would escape the + manager and race its replacement process, which is the exact hazard the + supervisor branch exists to avoid. + """ + _stub_argv(monkeypatch, _ARGV + ["--external-supervisor"]) + monkeypatch.setattr( + gateway, + "launch_detached_profile_gateway_restart", + lambda *_a: pytest.fail("detached watcher must not be launched"), + ) + monkeypatch.setattr( + gateway, + "launch_detached_gateway_restart_by_cmdline", + lambda *_a: pytest.fail("cmdline replay must not be launched"), + ) + + assert ( + gateway._prepare_profile_gateway_update_restart("fitness", 4242) + == "external-supervisor" + ) From 891ec3fb6c41e912e9d2120dd4009534814a9e7a Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 11:25:25 +0000 Subject: [PATCH 113/666] fmt(js): `npm run fix` on merge (#92896) Co-authored-by: github-actions[bot] --- .../chat/sidebar/profile-remote-override-dialog.tsx | 6 +++++- .../app/session/hooks/use-session-actions/index.ts | 9 +++------ .../pane-shell/tree/renderer/workspace-scope.test.tsx | 6 +++++- .../src/components/pane-shell/workspace-scope.test.ts | 11 +++++------ .../src/components/pane-shell/workspace-scope.ts | 8 ++------ apps/desktop/src/i18n/en.ts | 3 +-- apps/desktop/src/i18n/zh-hant.ts | 3 +-- apps/desktop/src/i18n/zh.ts | 3 +-- apps/desktop/src/lib/voice-client-direct.test.ts | 7 +++++-- 9 files changed, 28 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.tsx b/apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.tsx index acf71926fd94..cfd9bd00df15 100644 --- a/apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.tsx +++ b/apps/desktop/src/app/chat/sidebar/profile-remote-override-dialog.tsx @@ -152,7 +152,11 @@ export function ProfileRemoteOverrideDialog({ profileNames }: { profileNames: st ...(allowPlainText ? { allowPlainTextToken: true } : {}) }) - notify({ kind: 'success', title: p.savedTitle, message: p.savedMessage(profile, remoteHostLabel(trimmedUrl) || trimmedUrl) }) + notify({ + kind: 'success', + title: p.savedTitle, + message: p.savedMessage(profile, remoteHostLabel(trimmedUrl) || trimmedUrl) + }) await refreshProfileRemoteOverrides(profileNames) closeRemoteOverrideDialog() } catch (err) { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 287f3500bf5a..810f6beebffa 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -634,12 +634,9 @@ export function useSessionActions({ if (!stored) { const closeCreated = capturedRoute - ? requestGatewayForAgent( - capturedRoute.connectionId, - capturedRoute.profile, - 'session.close', - { session_id: created.session_id } - ) + ? requestGatewayForAgent(capturedRoute.connectionId, capturedRoute.profile, 'session.close', { + session_id: created.session_id + }) : requestGateway('session.close', { session_id: created.session_id }) await closeCreated.catch(() => undefined) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx index 310d85943b69..29cba946c9ee 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/workspace-scope.test.tsx @@ -23,7 +23,11 @@ function render(ui: ReactNode) { act(() => root!.render(ui)) } -function register(id: string, title: string, scope: { workspaceMode?: 'sessions' | 'bots'; workspaceOwnerKey?: string } = {}) { +function register( + id: string, + title: string, + scope: { workspaceMode?: 'sessions' | 'bots'; workspaceOwnerKey?: string } = {} +) { disposers.push( registry.register({ area: 'panes', diff --git a/apps/desktop/src/components/pane-shell/workspace-scope.test.ts b/apps/desktop/src/components/pane-shell/workspace-scope.test.ts index 241a4da966d0..49686f0f7bda 100644 --- a/apps/desktop/src/components/pane-shell/workspace-scope.test.ts +++ b/apps/desktop/src/components/pane-shell/workspace-scope.test.ts @@ -70,9 +70,7 @@ describe('workspace scope', () => { expect($workspaceNewSessionTarget.get()).toEqual({ kind: 'route', route }) // Equivalent route objects are a semantic no-op, not a new render signal. - expect( - setWorkspaceScope('bots', 'bot:connection-a::writer', { kind: 'route', route: { ...route } }) - ).toBe(false) + expect(setWorkspaceScope('bots', 'bot:connection-a::writer', { kind: 'route', route: { ...route } })).toBe(false) setWorkspaceScope('sessions') expect($workspaceNewSessionTarget.get()).toBeNull() @@ -141,9 +139,10 @@ describe('filterContributionsForWorkspace', () => { 'global-1', 'bot:alpha' ]) - expect( - filterContributionsForWorkspace(contributions, 'sessions', null).map(c => c.id) - ).toEqual(['global-1', 'sessions-only']) + expect(filterContributionsForWorkspace(contributions, 'sessions', null).map(c => c.id)).toEqual([ + 'global-1', + 'sessions-only' + ]) }) it('returns the original array reference on a no-op', () => { diff --git a/apps/desktop/src/components/pane-shell/workspace-scope.ts b/apps/desktop/src/components/pane-shell/workspace-scope.ts index a034477b04a8..c9770d8977c2 100644 --- a/apps/desktop/src/components/pane-shell/workspace-scope.ts +++ b/apps/desktop/src/components/pane-shell/workspace-scope.ts @@ -35,8 +35,7 @@ export interface WorkspaceSessionRoute { /** What the shared `+` / session.newTab command means in this workspace. */ export type WorkspaceNewSessionTarget = - | { kind: 'blocked'; message: string } - | { kind: 'route'; route: WorkspaceSessionRoute } + { kind: 'blocked'; message: string } | { kind: 'route'; route: WorkspaceSessionRoute } /** Sessions uses its established ambient behavior (`null`). Bots publishes an * exact route or a concise reason that a generic session is unavailable. */ @@ -189,10 +188,7 @@ export function rememberActivePane(ownerKey: string, paneId: string): void { * panes. A remembered pane that has since been removed must not restore: the * fallback is the first eligible pane, or null when none are eligible. */ -export function resolveRememberedActivePane( - ownerKey: string, - eligiblePaneIds: readonly string[] -): string | null { +export function resolveRememberedActivePane(ownerKey: string, eligiblePaneIds: readonly string[]): string | null { const remembered = rememberedActivePanes.get(ownerKey) if (remembered != null && eligiblePaneIds.includes(remembered)) { diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index e4a112700e35..df70b466378f 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1819,8 +1819,7 @@ export const en: Translations = { menuItem: 'Connect to a remote host…', badge: (host: string) => `Runs on ${host}`, title: (profile: string) => `Connect ${profile} to a remote host`, - description: - 'Sessions in this profile will run on the remote Hermes you point it at, instead of this computer.', + description: 'Sessions in this profile will run on the remote Hermes you point it at, instead of this computer.', urlLabel: 'Remote address', urlPlaceholder: 'https://hermes.example.com', urlInvalid: 'Enter a full address starting with http:// or https://', diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 7edaa7c6e633..b9b55e0490f3 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1483,8 +1483,7 @@ export const zhHant = defineLocale({ tokenPlaceholder: '貼上遠端工作階段權杖', tokenSavedHint: '已儲存權杖。留空以保留現有權杖。', plainTextOptIn: '這台電腦沒有安全金鑰儲存空間,權杖將以未加密方式儲存到磁碟。仍要儲存。', - collisionWarning: (label: string) => - `設定中已存在名為「${label}」的閘道。此設定檔連線是獨立的,不會變更它。`, + collisionWarning: (label: string) => `設定中已存在名為「${label}」的閘道。此設定檔連線是獨立的,不會變更它。`, confirmTitle: '將此設定檔連線至遠端主機?', confirmNote: (profile: string, host: string) => `${profile} 中的新對話將在 ${host} 上執行。指令執行與檔案讀取都會發生在那台電腦上,而不是這台。請只連線你信任的主機。`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index c6393e6c1f37..f4bf71a4f715 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2013,8 +2013,7 @@ export const zh: Translations = { tokenPlaceholder: '粘贴远程会话令牌', tokenSavedHint: '已保存令牌。留空以保留现有令牌。', plainTextOptIn: '这台电脑没有安全密钥存储,令牌将以未加密方式保存到磁盘。仍然保存。', - collisionWarning: (label: string) => - `设置中已存在名为“${label}”的网关。此配置档案连接是独立的,不会更改它。`, + collisionWarning: (label: string) => `设置中已存在名为“${label}”的网关。此配置档案连接是独立的,不会更改它。`, confirmTitle: '将此配置档案连接到远程主机?', confirmNote: (profile: string, host: string) => `${profile} 中的新对话将在 ${host} 上运行。命令和文件读取都发生在那台电脑上,而不是这台。请只连接你信任的主机。`, diff --git a/apps/desktop/src/lib/voice-client-direct.test.ts b/apps/desktop/src/lib/voice-client-direct.test.ts index a3a028d846ca..6d345cbe2268 100644 --- a/apps/desktop/src/lib/voice-client-direct.test.ts +++ b/apps/desktop/src/lib/voice-client-direct.test.ts @@ -65,7 +65,7 @@ describe('fetchVoiceClientConfig', () => { expect(request.connectionId).toBe('gw-remote') }) - it('a scope switch never reuses another scope\'s credentials', async () => { + it("a scope switch never reuses another scope's credentials", async () => { const api = mockDesktopApi({ ok: true, stt: directStt, tts: relay }) setApiRequestProfile('alpha') await fetchVoiceClientConfig() @@ -237,7 +237,10 @@ describe('synthesizeSpeechClientDirect', () => { }) it('throws on provider rejection', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response('quota exceeded', { status: 429 }))) + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('quota exceeded', { status: 429 })) + ) await expect(synthesizeSpeechClientDirect(openaiTts, 'Hi.')).rejects.toThrow(/openai TTS error.*429/) }) From 102a30f6fb37e30d07ba2b113a2ccb87cb765f03 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:35:27 -0700 Subject: [PATCH 114/666] fix(desktop): typing /voice points at the composer voice button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /voice arms SERVER-side capture (voice.record → PortAudio on the backend host) — meaningless on desktop, which has its own composer-native voice conversation (mic menu / Ctrl+B). It was already suppressed from the slash palette, but typing it got the generic 'advanced' shrug that never mentioned the button exists. New composer-voice unavailability reason with a message naming the actual surface. --- apps/desktop/src/lib/desktop-slash-commands.test.ts | 12 ++++++++++++ apps/desktop/src/lib/desktop-slash-commands.ts | 11 +++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/desktop-slash-commands.test.ts b/apps/desktop/src/lib/desktop-slash-commands.test.ts index a3876087d847..29a582039266 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.test.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.test.ts @@ -45,6 +45,18 @@ describe('desktop slash command curation', () => { expect(isDesktopSlashSuggestion('/curator')).toBe(false) }) + it('/voice points at the composer voice button instead of the generic advanced message', () => { + // /voice arms server-side capture — on the desktop the composer's own + // voice conversation (mic menu / Ctrl+B) is the surface. A user typing + // /voice must be told where the button IS, not shrugged at. + expect(resolveDesktopCommand('/voice')?.surface).toEqual({ kind: 'unavailable', reason: 'composer-voice' }) + expect(isDesktopSlashCommand('/voice')).toBe(false) + + const message = desktopSlashUnavailableMessage('/voice') + expect(message).toContain('microphone button') + expect(message).toContain('Ctrl+B') + }) + it('routes /compact to /compress (context compression), not the TUI display toggle', () => { expect(resolveDesktopCommand('/compact')?.name).toBe('/compress') expect(isDesktopSlashCommand('/compact')).toBe(true) diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index 495479899d6d..eca8e729cf48 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -63,7 +63,7 @@ export type DesktopActionId = export type DesktopPickerId = 'model' | 'session' /** Why a known Hermes command has no desktop UI surface. */ -export type DesktopUnavailableReason = 'advanced' | 'messaging' | 'settings' | 'terminal' +export type DesktopUnavailableReason = 'advanced' | 'composer-voice' | 'messaging' | 'settings' | 'terminal' /** * How the desktop fulfils a command. This is the single discriminator the @@ -370,7 +370,12 @@ const NO_DESKTOP_SURFACE: Record = ], messaging: ['/approve', '/deny'], settings: ['/skills', '/pets'], - advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice'] + advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning'], + // /voice arms SERVER-side capture (voice.record → PortAudio on the backend + // host) — meaningless on desktop, which has its own composer-native voice + // conversation (mic menu / Ctrl+B) with client-side capture and playback. + // Point the user at the button instead of a generic "advanced" shrug. + 'composer-voice': ['/voice'] } const ALL_SPECS: readonly DesktopCommandSpec[] = [ @@ -389,6 +394,8 @@ const ALIAS_TO_CANONICAL = new Map( const UNAVAILABLE_MESSAGE: Record string> = { advanced: command => `${command} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`, + 'composer-voice': () => + 'Voice chat lives in the composer here: click the microphone button and choose "Start voice chat" (or press Ctrl+B).', messaging: command => `${command} is only used from messaging platforms.`, settings: command => `${command} is managed from the desktop sidebar.`, terminal: command => `${command} is only available in the terminal interface.` From 1bf93660f1d7a5c7d37ba525687af245044ad2ea Mon Sep 17 00:00:00 2001 From: Jack Lau <72348727+jackulau@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:16:44 -0500 Subject: [PATCH 115/666] fix(update): verify launchd is supervising the gateway after a restart On macOS, `hermes update` printed "Update complete!" and exited 0 while the ai.hermes.gateway LaunchAgent sat deregistered for 36 minutes (#88848). _restart_macos_launchd_gateways already disagrees with itself about what "restarted" means. Sibling profiles are only appended to restarted_services once _wait_for_launchd_service_pid confirms launchd is running the job on a fresh pid. The invoking profile was appended on "launchd_restart() did not raise" alone. That is a weaker claim than it looks. launchd_restart() returns as soon as the restart has been REQUESTED: the _request_gateway_self_restart branch hands the work to the running gateway and returns immediately, and a plist reload is handed to a detached helper. Both are asynchronous, so a helper that dies before its first bootstrap, or a `launchctl bootstrap` that exits 0 without registering (measured by the reporter on macOS 26.6.1), were both invisible to the caller. The systemd branch of the same phase has never drawn that inference: it polls _wait_for_service_active before recording the unit. Verification is domain-agnostic via a new gateway.wait_for_launchd_gateway_supervision, NOT _wait_for_launchd_service_pid. The sibling helper needs an explicit domain, and the invoking profile's gate deliberately avoids a domain locate because it fails on macOS-26 hosts whose per-user domains reject service management even though launchd_restart() owns that fallback. The new helper judges by a live supervised pid rather than an exit code (the predicate _launchctl_label_supervising_process already existed; this only adds the wait), and returns True immediately when the detached fallback marker is present, because a gateway running unsupervised there is the designed state and not the silent failure this guards against. A label that restarts but is never supervised now lands in failed_or_stale_units, which sets gateway_fleet_restart_incomplete and makes the update exit non-zero instead of reporting success over a gateway that is down. Tests: 12 in tests/hermes_cli/test_update_launchd_restart_verification.py, with no platform gate, driving the real _restart_macos_launchd_gateways through mocked launchctl outcomes. Reverting the verification to an unconditional append fails 2 of them, including the #88848 regression case. tests/hermes_cli/test_update_launchd_fleet_restart.py::_fleet stubs the new verifier so its 27 existing cases keep asserting on routing rather than on a real launchctl probe; unstubbed, each case would poll the full supervision budget. --- hermes_cli/gateway.py | 45 +++ hermes_cli/update_cmd.py | 44 ++- .../test_update_launchd_fleet_restart.py | 17 +- ...est_update_launchd_restart_verification.py | 317 ++++++++++++++++++ 4 files changed, 420 insertions(+), 3 deletions(-) create mode 100644 tests/hermes_cli/test_update_launchd_restart_verification.py diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 90193f291742..64417c323f00 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -5461,6 +5461,51 @@ def launchd_restart(): _clear_launchd_unsupported_marker() +# launchd will not relaunch a KeepAlive job more than about once per 10s. A +# self-restart that exits promptly therefore leaves the label registered with +# NO pid for most of that window, so any verification budget shorter than the +# throttle reports a healthy restart as a failure. +LAUNCHD_SUPERVISION_VERIFY_TIMEOUT = 20.0 + + +def wait_for_launchd_gateway_supervision( + *, + timeout: float = LAUNCHD_SUPERVISION_VERIFY_TIMEOUT, + label: str | None = None, + poll_interval: float = 0.5, +) -> bool: + """Poll launchd until it is supervising a live gateway process. + + :func:`launchd_restart` returns as soon as the restart has been *requested*. + The ``_request_gateway_self_restart`` branch hands the work to the running + gateway and returns immediately, and a plist reload is handed to a detached + helper. Both are asynchronous, so a caller that reads "returned without + raising" as "the service is up" cannot see a helper that dies before its + first bootstrap (#88848) — nor a ``launchctl bootstrap`` that exits 0 + without registering, which the reporter measured on macOS 26.6.1. + + Judge the outcome the way #80491 taught the helper to judge it: by a live + supervised pid, never by an exit code. :func:`_launchctl_label_supervising_process` + is already that predicate, so this only adds the wait. + + Returns True immediately when the detached fallback is active. On a host + where launchd cannot manage the domain the gateway runs unsupervised *by + design*, so the absence of a launchd pid there is the expected state and + not the silent failure this guards against. + """ + if _launchd_unsupported_marker_exists(): + return True + + label = label or get_launchd_label() + deadline = time.monotonic() + max(timeout, 0.0) + while True: + if _launchctl_label_supervising_process(label): + return True + if time.monotonic() >= deadline: + return False + time.sleep(max(poll_interval, 0.01)) + + def launchd_status(deep: bool = False): plist_path = get_launchd_plist_path() label = get_launchd_label() diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index 292c551fa6fe..b470a9a1e10f 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -5040,6 +5040,8 @@ def _service_unit_supports_graceful_sigusr1_restart(svc_name: str) -> bool: def _warn_incomplete_gateway_fleet_restart(failed_units: list) -> None: """Print an explicit incomplete-update warning for unrestarted units.""" + from hermes_cli.gateway import is_macos + if not failed_units: return # Preserve discovery order while de-duplicating. @@ -5054,6 +5056,18 @@ def _warn_incomplete_gateway_fleet_restart(failed_units: list) -> None: print("⚠ Update incomplete — some units were not restarted:") for name in ordered: print(f" - {name}") + if is_macos(): + # A launchd label reaches this list when launchd was not supervising a + # live process after the restart (#88848), so the unit is not merely + # stale — it is very likely deregistered, and `launchctl kickstart` + # cannot revive a job launchd no longer knows about. + print(" Listed services may be deregistered from launchd, or still") + print(" running pre-update code (mixed sys.modules). Recover with:") + print(" hermes gateway status") + print(" launchctl list | grep