mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-04 01:37:34 +08:00
Compare commits
6 Commits
bb/learnin
...
bb/tui-rel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
059652d11a | ||
|
|
fac81a30df | ||
|
|
faafa6d375 | ||
|
|
af6b1a3343 | ||
|
|
8d591fe3c7 | ||
|
|
15ef11a8b8 |
@@ -2721,3 +2721,330 @@ def test_session_most_recent_handles_db_unavailable(monkeypatch):
|
||||
)
|
||||
|
||||
assert resp["result"]["session_id"] is None
|
||||
|
||||
|
||||
# ── browser.manage ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _stub_urlopen(monkeypatch, *, ok: bool):
|
||||
"""Patch urllib.request.urlopen used by browser.manage to short-circuit probes."""
|
||||
|
||||
class _Resp:
|
||||
status = 200 if ok else 503
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
def _opener(_url, timeout=2.0): # noqa: ARG001 — match urllib signature
|
||||
if not ok:
|
||||
raise OSError("probe failed")
|
||||
return _Resp()
|
||||
|
||||
import urllib.request
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _opener)
|
||||
|
||||
|
||||
def test_browser_manage_status_reads_env_var(monkeypatch):
|
||||
"""Status returns the env var verbatim (no network I/O)."""
|
||||
monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "browser.manage", "params": {"action": "status"}}
|
||||
)
|
||||
|
||||
assert resp["result"] == {"connected": True, "url": "http://127.0.0.1:9222"}
|
||||
|
||||
|
||||
def test_browser_manage_status_falls_back_to_config_cdp_url(monkeypatch):
|
||||
"""When env is unset, status surfaces ``browser.cdp_url`` from
|
||||
config.yaml so users see what the next tool call will read."""
|
||||
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
|
||||
|
||||
fake_cfg = types.SimpleNamespace(
|
||||
read_raw_config=lambda: {"browser": {"cdp_url": "http://lan:9222"}}
|
||||
)
|
||||
with patch.dict(sys.modules, {"hermes_cli.config": fake_cfg}):
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "browser.manage", "params": {"action": "status"}}
|
||||
)
|
||||
|
||||
assert resp["result"] == {"connected": True, "url": "http://lan:9222"}
|
||||
|
||||
|
||||
def test_browser_manage_status_does_not_call_get_cdp_override(monkeypatch):
|
||||
"""Regression guard for Copilot's "status must not block" review:
|
||||
status must NOT route through `_get_cdp_override`, which performs a
|
||||
`/json/version` HTTP probe with a multi-second timeout."""
|
||||
monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")
|
||||
|
||||
fake = types.SimpleNamespace(
|
||||
_get_cdp_override=lambda: pytest.fail( # noqa: PT015 — fail loudly if called
|
||||
"_get_cdp_override must not run on /browser status (network I/O)"
|
||||
)
|
||||
)
|
||||
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "browser.manage", "params": {"action": "status"}}
|
||||
)
|
||||
|
||||
assert resp["result"]["connected"] is True
|
||||
|
||||
|
||||
def test_browser_manage_connect_sets_env_and_cleans_twice(monkeypatch):
|
||||
"""`/browser connect` must reach the live process: set env, reap browser
|
||||
sessions before AND after publishing the new URL. The double-cleanup
|
||||
closes the supervisor swap window where ``_ensure_cdp_supervisor``
|
||||
could re-attach to the *old* CDP endpoint between steps."""
|
||||
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
|
||||
cleanup_calls: list[str] = []
|
||||
|
||||
def _cleanup_all():
|
||||
cleanup_calls.append(os.environ.get("BROWSER_CDP_URL", ""))
|
||||
|
||||
fake = types.SimpleNamespace(
|
||||
cleanup_all_browsers=_cleanup_all,
|
||||
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
|
||||
)
|
||||
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
|
||||
_stub_urlopen(monkeypatch, ok=True)
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "browser.manage",
|
||||
"params": {"action": "connect", "url": "http://127.0.0.1:9222"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"] == {"connected": True, "url": "http://127.0.0.1:9222"}
|
||||
assert os.environ.get("BROWSER_CDP_URL") == "http://127.0.0.1:9222"
|
||||
# First cleanup runs against the OLD env (none here), second against the NEW.
|
||||
assert cleanup_calls == ["", "http://127.0.0.1:9222"]
|
||||
|
||||
|
||||
def test_browser_manage_connect_rejects_unreachable_endpoint(monkeypatch):
|
||||
"""An unreachable endpoint must NOT mutate the env or reap sessions."""
|
||||
monkeypatch.setenv("BROWSER_CDP_URL", "http://existing:9222")
|
||||
cleanup_calls: list[str] = []
|
||||
fake = types.SimpleNamespace(
|
||||
cleanup_all_browsers=lambda: cleanup_calls.append(os.environ.get("BROWSER_CDP_URL", "")),
|
||||
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
|
||||
)
|
||||
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
|
||||
_stub_urlopen(monkeypatch, ok=False)
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "browser.manage",
|
||||
"params": {"action": "connect", "url": "http://unreachable:9222"},
|
||||
}
|
||||
)
|
||||
|
||||
assert "error" in resp
|
||||
# Env preserved; nothing reaped.
|
||||
assert os.environ["BROWSER_CDP_URL"] == "http://existing:9222"
|
||||
assert cleanup_calls == []
|
||||
|
||||
|
||||
def test_browser_manage_connect_normalizes_bare_host_port(monkeypatch):
|
||||
"""Persist a parsed `scheme://host:port` URL so `_get_cdp_override`
|
||||
can normalize it; storing a bare host:port would break subsequent
|
||||
tool calls (Copilot review on #17120)."""
|
||||
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
|
||||
fake = types.SimpleNamespace(
|
||||
cleanup_all_browsers=lambda: None,
|
||||
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
|
||||
)
|
||||
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
|
||||
_stub_urlopen(monkeypatch, ok=True)
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "browser.manage",
|
||||
"params": {"action": "connect", "url": "127.0.0.1:9222"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["connected"] is True
|
||||
# Bare host:port got promoted to a full URL with explicit scheme.
|
||||
assert resp["result"]["url"].startswith("http://")
|
||||
assert os.environ["BROWSER_CDP_URL"].startswith("http://")
|
||||
|
||||
|
||||
def test_browser_manage_connect_strips_discovery_path(monkeypatch):
|
||||
"""User-supplied discovery paths like `/json` or `/json/version`
|
||||
must collapse to bare `scheme://host:port`; otherwise
|
||||
``_resolve_cdp_override`` will append ``/json/version`` again and
|
||||
produce a duplicate path (Copilot review round-2 on #17120)."""
|
||||
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
|
||||
fake = types.SimpleNamespace(
|
||||
cleanup_all_browsers=lambda: None,
|
||||
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
|
||||
)
|
||||
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
|
||||
_stub_urlopen(monkeypatch, ok=True)
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "browser.manage",
|
||||
"params": {"action": "connect", "url": "http://127.0.0.1:9222/json"},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["connected"] is True
|
||||
assert resp["result"]["url"] == "http://127.0.0.1:9222"
|
||||
assert os.environ["BROWSER_CDP_URL"] == "http://127.0.0.1:9222"
|
||||
|
||||
|
||||
def test_browser_manage_connect_preserves_devtools_browser_endpoint(monkeypatch):
|
||||
"""Concrete devtools websocket endpoints (e.g. Browserbase) must
|
||||
survive verbatim — we only collapse discovery-style paths."""
|
||||
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
|
||||
fake = types.SimpleNamespace(
|
||||
cleanup_all_browsers=lambda: None,
|
||||
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
|
||||
)
|
||||
concrete = "ws://browserbase.example/devtools/browser/abc123"
|
||||
|
||||
class _OkSocket:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): return False
|
||||
|
||||
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
|
||||
# If urlopen is reached for a concrete ws endpoint, the test
|
||||
# would still pass because _stub_urlopen returned ok=True before;
|
||||
# patch it to assert-fail so we prove the HTTP probe is skipped.
|
||||
with patch("urllib.request.urlopen", side_effect=AssertionError("urlopen called")):
|
||||
with patch("socket.create_connection", return_value=_OkSocket()):
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "browser.manage",
|
||||
"params": {"action": "connect", "url": concrete},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"]["connected"] is True
|
||||
assert resp["result"]["url"] == concrete
|
||||
assert os.environ["BROWSER_CDP_URL"] == concrete
|
||||
|
||||
|
||||
def test_browser_manage_connect_concrete_ws_skips_http_probe(monkeypatch):
|
||||
"""Regression for round-2 Copilot review: a hosted CDP endpoint
|
||||
(no HTTP discovery) must connect via TCP-only reachability check.
|
||||
The HTTP probe used to reject these even though they're valid."""
|
||||
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
|
||||
fake = types.SimpleNamespace(
|
||||
cleanup_all_browsers=lambda: None,
|
||||
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
|
||||
)
|
||||
concrete = "wss://chrome.browserless.io/devtools/browser/sess-1"
|
||||
|
||||
seen_targets: list[tuple[str, int]] = []
|
||||
|
||||
class _OkSocket:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *a): return False
|
||||
|
||||
def _fake_create_connection(addr, timeout=None):
|
||||
seen_targets.append(addr)
|
||||
return _OkSocket()
|
||||
|
||||
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
|
||||
# urlopen would 404/ECONNREFUSED on a real hosted CDP endpoint;
|
||||
# asserting it's never called proves the probe was skipped.
|
||||
with patch("urllib.request.urlopen", side_effect=AssertionError("urlopen called")):
|
||||
with patch("socket.create_connection", side_effect=_fake_create_connection):
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "browser.manage",
|
||||
"params": {"action": "connect", "url": concrete},
|
||||
}
|
||||
)
|
||||
|
||||
assert resp["result"] == {"connected": True, "url": concrete}
|
||||
# wss → port 443, host preserved verbatim.
|
||||
assert seen_targets == [("chrome.browserless.io", 443)]
|
||||
|
||||
|
||||
def test_browser_manage_connect_concrete_ws_tcp_unreachable(monkeypatch):
|
||||
"""If the TCP reachability check fails for a concrete ws endpoint,
|
||||
return a clear 5031 error — no fallback to the HTTP probe (which
|
||||
can never succeed for these URLs anyway)."""
|
||||
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
|
||||
fake = types.SimpleNamespace(
|
||||
cleanup_all_browsers=lambda: None,
|
||||
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
|
||||
)
|
||||
concrete = "ws://offline.example/devtools/browser/missing"
|
||||
|
||||
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
|
||||
with patch("socket.create_connection", side_effect=OSError("ECONNREFUSED")):
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "browser.manage",
|
||||
"params": {"action": "connect", "url": concrete},
|
||||
}
|
||||
)
|
||||
|
||||
assert "error" in resp
|
||||
assert resp["error"]["code"] == 5031
|
||||
|
||||
|
||||
def test_browser_manage_disconnect_drops_env_and_cleans(monkeypatch):
|
||||
monkeypatch.setenv("BROWSER_CDP_URL", "http://127.0.0.1:9222")
|
||||
cleanup_count = {"n": 0}
|
||||
fake = types.SimpleNamespace(
|
||||
cleanup_all_browsers=lambda: cleanup_count.__setitem__("n", cleanup_count["n"] + 1),
|
||||
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
|
||||
)
|
||||
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "browser.manage", "params": {"action": "disconnect"}}
|
||||
)
|
||||
|
||||
assert resp["result"] == {"connected": False}
|
||||
assert "BROWSER_CDP_URL" not in os.environ
|
||||
# Two cleanups: once before env removal, once after, matching connect.
|
||||
assert cleanup_count["n"] == 2
|
||||
|
||||
|
||||
# ── reload.env ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_reload_env_rpc_calls_hermes_cli_reload_env(monkeypatch):
|
||||
"""reload.env mirrors classic CLI's `/reload` — re-reads ~/.hermes/.env
|
||||
into the gateway process and reports the count of vars updated."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def _fake_reload():
|
||||
calls["n"] += 1
|
||||
return 7
|
||||
|
||||
fake = types.SimpleNamespace(reload_env=_fake_reload)
|
||||
with patch.dict(sys.modules, {"hermes_cli.config": fake}):
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "reload.env", "params": {}}
|
||||
)
|
||||
|
||||
assert resp["result"] == {"updated": 7}
|
||||
assert calls["n"] == 1
|
||||
|
||||
|
||||
def test_reload_env_rpc_surfaces_errors(monkeypatch):
|
||||
def _broken():
|
||||
raise RuntimeError("env path locked")
|
||||
|
||||
fake = types.SimpleNamespace(reload_env=_broken)
|
||||
with patch.dict(sys.modules, {"hermes_cli.config": fake}):
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "reload.env", "params": {}}
|
||||
)
|
||||
|
||||
assert "error" in resp
|
||||
assert "env path locked" in resp["error"]["message"]
|
||||
|
||||
@@ -3383,6 +3383,27 @@ def _(rid, params: dict) -> dict:
|
||||
return _err(rid, 5015, str(e))
|
||||
|
||||
|
||||
@method("reload.env")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Re-read ``~/.hermes/.env`` into the gateway process via
|
||||
``hermes_cli.config.reload_env``, matching classic CLI's ``/reload``
|
||||
handler. Newly added API keys take effect on the next agent call
|
||||
without restarting the TUI.
|
||||
|
||||
The credential pool / provider routing for any *already-constructed*
|
||||
agent does not auto-rebuild — that's the same behaviour as classic
|
||||
CLI's ``/reload``. Users who want a brand-new credential resolution
|
||||
should follow with ``/new``.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import reload_env
|
||||
|
||||
count = reload_env()
|
||||
return _ok(rid, {"updated": int(count)})
|
||||
except Exception as e:
|
||||
return _err(rid, 5015, str(e))
|
||||
|
||||
|
||||
_TUI_HIDDEN: frozenset[str] = frozenset(
|
||||
{
|
||||
"sethome",
|
||||
@@ -4674,12 +4695,51 @@ def _(rid, params: dict) -> dict:
|
||||
# ── Methods: browser / plugins / cron / skills ───────────────────────
|
||||
|
||||
|
||||
def _resolve_browser_cdp_url() -> str:
|
||||
"""Return the configured browser CDP override without network I/O.
|
||||
|
||||
``/browser status`` must be fast — calling
|
||||
``tools.browser_tool._get_cdp_override`` would invoke
|
||||
``_resolve_cdp_override``, which performs an HTTP probe to
|
||||
``.../json/version`` for discovery-style URLs. That probe has
|
||||
a multi-second timeout and would block the TUI on a slow or
|
||||
unreachable host even though status only needs to report whether
|
||||
an override is set.
|
||||
|
||||
Mirrors the env/config precedence of ``_get_cdp_override`` (env
|
||||
var first, then ``browser.cdp_url`` from config.yaml) without the
|
||||
websocket-resolution step, so the answer reflects user intent
|
||||
even when the configured host is not currently reachable. The
|
||||
actual WS normalization happens in ``browser_navigate`` on the
|
||||
next tool call.
|
||||
"""
|
||||
env_url = os.environ.get("BROWSER_CDP_URL", "").strip()
|
||||
if env_url:
|
||||
return env_url
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
|
||||
cfg = read_raw_config()
|
||||
browser_cfg = cfg.get("browser", {}) if isinstance(cfg, dict) else {}
|
||||
if isinstance(browser_cfg, dict):
|
||||
return str(browser_cfg.get("cdp_url", "") or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
@method("browser.manage")
|
||||
def _(rid, params: dict) -> dict:
|
||||
action = params.get("action", "status")
|
||||
if action == "status":
|
||||
url = os.environ.get("BROWSER_CDP_URL", "")
|
||||
return _ok(rid, {"connected": bool(url), "url": url})
|
||||
resolved_url = _resolve_browser_cdp_url()
|
||||
return _ok(
|
||||
rid,
|
||||
{
|
||||
"connected": bool(resolved_url),
|
||||
"url": resolved_url,
|
||||
},
|
||||
)
|
||||
if action == "connect":
|
||||
url = params.get("url", "http://localhost:9222")
|
||||
try:
|
||||
@@ -4690,36 +4750,97 @@ def _(rid, params: dict) -> dict:
|
||||
parsed = urlparse(url if "://" in url else f"http://{url}")
|
||||
if parsed.scheme not in {"http", "https", "ws", "wss"}:
|
||||
return _err(rid, 4015, f"unsupported browser url: {url}")
|
||||
probe_root = f"{'https' if parsed.scheme == 'wss' else 'http' if parsed.scheme == 'ws' else parsed.scheme}://{parsed.netloc}"
|
||||
probe_urls = [
|
||||
f"{probe_root.rstrip('/')}/json/version",
|
||||
f"{probe_root.rstrip('/')}/json",
|
||||
]
|
||||
ok = False
|
||||
for probe in probe_urls:
|
||||
try:
|
||||
with urllib.request.urlopen(probe, timeout=2.0) as resp:
|
||||
if 200 <= getattr(resp, "status", 200) < 300:
|
||||
ok = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not ok:
|
||||
return _err(rid, 5031, f"could not reach browser CDP at {url}")
|
||||
|
||||
os.environ["BROWSER_CDP_URL"] = url
|
||||
# A concrete ``ws[s]://.../devtools/browser/<id>`` endpoint is
|
||||
# already directly connectable — those are the URLs Browserbase
|
||||
# / browserless / hosted CDP providers return, and they
|
||||
# generally DON'T serve the discovery-style ``/json/version``
|
||||
# path. Probing it would just reject valid endpoints. Skip
|
||||
# the HTTP probe and do a TCP-level reachability check instead;
|
||||
# the actual CDP handshake happens on the next ``browser_navigate``.
|
||||
is_concrete_ws = (
|
||||
parsed.scheme in {"ws", "wss"}
|
||||
and parsed.path.startswith("/devtools/browser/")
|
||||
)
|
||||
if is_concrete_ws:
|
||||
import socket
|
||||
|
||||
host = parsed.hostname
|
||||
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
|
||||
if not host:
|
||||
return _err(rid, 4015, f"missing host in browser url: {url}")
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=2.0):
|
||||
pass
|
||||
except OSError as e:
|
||||
return _err(rid, 5031, f"could not reach browser CDP at {url}: {e}")
|
||||
else:
|
||||
probe_root = f"{'https' if parsed.scheme == 'wss' else 'http' if parsed.scheme == 'ws' else parsed.scheme}://{parsed.netloc}"
|
||||
probe_urls = [
|
||||
f"{probe_root.rstrip('/')}/json/version",
|
||||
f"{probe_root.rstrip('/')}/json",
|
||||
]
|
||||
ok = False
|
||||
for probe in probe_urls:
|
||||
try:
|
||||
with urllib.request.urlopen(probe, timeout=2.0) as resp:
|
||||
if 200 <= getattr(resp, "status", 200) < 300:
|
||||
ok = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not ok:
|
||||
return _err(rid, 5031, f"could not reach browser CDP at {url}")
|
||||
|
||||
# Persist a normalized URL for downstream CDP resolution.
|
||||
# Discovery-style inputs (`http://host:port` or
|
||||
# `http://host:port/json[/version]`) collapse to bare
|
||||
# ``scheme://host:port`` so ``_resolve_cdp_override`` can
|
||||
# safely append ``/json/version`` without producing a
|
||||
# double-discovery path like ``.../json/json/version``.
|
||||
# Concrete websocket endpoints (``/devtools/browser/<id>``
|
||||
# — what Browserbase and other cloud providers return)
|
||||
# are preserved verbatim.
|
||||
if parsed.path.startswith("/devtools/browser/"):
|
||||
normalized = parsed.geturl()
|
||||
else:
|
||||
normalized = parsed._replace(
|
||||
path="",
|
||||
params="",
|
||||
query="",
|
||||
fragment="",
|
||||
).geturl()
|
||||
|
||||
# Order matters: clear any cached browser sessions BEFORE
|
||||
# publishing the new env var so an in-flight tool call
|
||||
# observing the old supervisor is reaped first, and the
|
||||
# next call freshly resolves the new URL. The previous
|
||||
# ordering left a brief window where ``_ensure_cdp_supervisor``
|
||||
# could re-attach to the *old* supervisor.
|
||||
cleanup_all_browsers()
|
||||
os.environ["BROWSER_CDP_URL"] = normalized
|
||||
# Drain any further cached state that could outlive the
|
||||
# cleanup pass (CDP supervisor for the default task,
|
||||
# cached agent-browser timeouts, etc.) so the next
|
||||
# ``browser_navigate`` definitively reaches ``normalized``.
|
||||
cleanup_all_browsers()
|
||||
except Exception as e:
|
||||
return _err(rid, 5031, str(e))
|
||||
return _ok(rid, {"connected": True, "url": url})
|
||||
return _ok(rid, {"connected": True, "url": normalized})
|
||||
if action == "disconnect":
|
||||
os.environ.pop("BROWSER_CDP_URL", None)
|
||||
try:
|
||||
from tools.browser_tool import cleanup_all_browsers
|
||||
|
||||
cleanup_all_browsers()
|
||||
except Exception:
|
||||
pass
|
||||
os.environ.pop("BROWSER_CDP_URL", None)
|
||||
try:
|
||||
from tools.browser_tool import cleanup_all_browsers as _again
|
||||
|
||||
_again()
|
||||
except Exception:
|
||||
pass
|
||||
return _ok(rid, {"connected": False})
|
||||
return _err(rid, 4015, f"unknown action: {action}")
|
||||
|
||||
|
||||
@@ -314,6 +314,48 @@ describe('createGatewayEventHandler', () => {
|
||||
expect(messages.some(m => m.includes('FileNotFoundError'))).toBe(true)
|
||||
})
|
||||
|
||||
it('prefers raw text over Rich-rendered ANSI on message.complete (#16391)', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
const raw = 'Hermes here.\n\nLine two.'
|
||||
// Rich-rendered ANSI (`final_response_markdown: render`) used to win,
|
||||
// which left visible escape codes in Ink output. Raw text must win.
|
||||
const rendered = '\u001b[33mHermes here.\u001b[0m\n\n\u001b[2mLine two.\u001b[0m'
|
||||
|
||||
onEvent({ payload: { rendered, text: raw }, type: 'message.complete' } as any)
|
||||
|
||||
const assistant = appended.find(msg => msg.role === 'assistant')
|
||||
expect(assistant?.text).toBe(raw)
|
||||
expect(assistant?.text).not.toContain('\u001b[')
|
||||
})
|
||||
|
||||
it('falls back to payload.rendered when text is missing on message.complete', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
const rendered = 'fallback when gateway omitted text'
|
||||
|
||||
onEvent({ payload: { rendered }, type: 'message.complete' } as any)
|
||||
|
||||
const assistant = appended.find(msg => msg.role === 'assistant')
|
||||
expect(assistant?.text).toBe(rendered)
|
||||
})
|
||||
|
||||
it('always accumulates raw text in message.delta and ignores `rendered` (#16391)', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
|
||||
// Stream of partial text deltas; each delta carries an incremental
|
||||
// Rich-ANSI fragment. Pre-fix code would replace the whole bufRef
|
||||
// with the latest fragment, dropping prior text.
|
||||
onEvent({ payload: { rendered: '\u001b[33mFi\u001b[0m', text: 'Fi' }, type: 'message.delta' } as any)
|
||||
onEvent({ payload: { rendered: '\u001b[33mrst.\u001b[0m', text: 'rst.' }, type: 'message.delta' } as any)
|
||||
onEvent({ payload: { text: ' second.' }, type: 'message.delta' } as any)
|
||||
onEvent({ payload: {}, type: 'message.complete' } as any)
|
||||
|
||||
const assistant = appended.find(msg => msg.role === 'assistant')
|
||||
expect(assistant?.text).toBe('First. second.')
|
||||
})
|
||||
|
||||
it('anchors inline_diff as its own segment where the edit happened', () => {
|
||||
const appended: Msg[] = []
|
||||
const onEvent = createGatewayEventHandler(buildCtx(appended))
|
||||
|
||||
@@ -193,6 +193,7 @@ describe('createSlashHandler', () => {
|
||||
it.each([
|
||||
['/browser status', 'browser.manage', { action: 'status' }],
|
||||
['/reload-mcp', 'reload.mcp', { session_id: null }],
|
||||
['/reload', 'reload.env', {}],
|
||||
['/stop', 'process.stop', {}],
|
||||
['/fast status', 'config.get', { key: 'fast', session_id: null }],
|
||||
['/busy status', 'config.get', { key: 'busy' }]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $uiState, resetUiState } from '../app/uiStore.js'
|
||||
import { applyDisplay, normalizeStatusBar } from '../app/useConfigSync.js'
|
||||
import { applyDisplay, normalizeBusyInputMode, normalizeStatusBar } from '../app/useConfigSync.js'
|
||||
|
||||
describe('applyDisplay', () => {
|
||||
beforeEach(() => {
|
||||
@@ -160,3 +160,55 @@ describe('normalizeStatusBar', () => {
|
||||
expect(normalizeStatusBar('OFF')).toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeBusyInputMode', () => {
|
||||
it('passes through the canonical CLI parity values', () => {
|
||||
expect(normalizeBusyInputMode('queue')).toBe('queue')
|
||||
expect(normalizeBusyInputMode('steer')).toBe('steer')
|
||||
expect(normalizeBusyInputMode('interrupt')).toBe('interrupt')
|
||||
})
|
||||
|
||||
it('trims and lowercases input', () => {
|
||||
expect(normalizeBusyInputMode(' Queue ')).toBe('queue')
|
||||
expect(normalizeBusyInputMode('STEER')).toBe('steer')
|
||||
})
|
||||
|
||||
it('defaults to queue for missing/unknown values (TUI-only override)', () => {
|
||||
// CLI / messaging adapters keep `interrupt` as the framework default
|
||||
// (see hermes_cli/config.py + tui_gateway/server.py::_load_busy_input_mode);
|
||||
// the TUI ships `queue` because typing a follow-up while the agent
|
||||
// streams is the common authoring pattern and an unintended interrupt
|
||||
// loses work.
|
||||
expect(normalizeBusyInputMode(undefined)).toBe('queue')
|
||||
expect(normalizeBusyInputMode(null)).toBe('queue')
|
||||
expect(normalizeBusyInputMode('')).toBe('queue')
|
||||
expect(normalizeBusyInputMode('drop')).toBe('queue')
|
||||
expect(normalizeBusyInputMode(42)).toBe('queue')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyDisplay → busy_input_mode', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
})
|
||||
|
||||
it('threads display.busy_input_mode into $uiState', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: { busy_input_mode: 'queue' } } }, setBell)
|
||||
expect($uiState.get().busyInputMode).toBe('queue')
|
||||
|
||||
applyDisplay({ config: { display: { busy_input_mode: 'steer' } } }, setBell)
|
||||
expect($uiState.get().busyInputMode).toBe('steer')
|
||||
})
|
||||
|
||||
it('falls back to queue when value is missing or invalid (TUI-only default)', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: {} } }, setBell)
|
||||
expect($uiState.get().busyInputMode).toBe('queue')
|
||||
|
||||
applyDisplay({ config: { display: { busy_input_mode: 'drop' } } }, setBell)
|
||||
expect($uiState.get().busyInputMode).toBe('queue')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface StateSetter<T> {
|
||||
|
||||
export type StatusBarMode = 'bottom' | 'off' | 'top'
|
||||
|
||||
export type BusyInputMode = 'interrupt' | 'queue' | 'steer'
|
||||
|
||||
export interface SelectionApi {
|
||||
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
||||
clearSelection: () => void
|
||||
@@ -85,6 +87,7 @@ export interface TranscriptRow {
|
||||
export interface UiState {
|
||||
bgTasks: Set<string>
|
||||
busy: boolean
|
||||
busyInputMode: BusyInputMode
|
||||
compact: boolean
|
||||
detailsMode: DetailsMode
|
||||
detailsModeCommandOverride: boolean
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
BrowserManageResponse,
|
||||
DelegationPauseResponse,
|
||||
ProcessStopResponse,
|
||||
ReloadEnvResponse,
|
||||
ReloadMcpResponse,
|
||||
RollbackDiffResponse,
|
||||
RollbackListResponse,
|
||||
@@ -89,6 +90,24 @@ export const opsCommands: SlashCommand[] = [
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 're-read ~/.hermes/.env into the running gateway (CLI parity)',
|
||||
name: 'reload',
|
||||
run: (_arg, ctx) => {
|
||||
ctx.gateway
|
||||
.rpc<ReloadEnvResponse>('reload.env', {})
|
||||
.then(
|
||||
ctx.guarded<ReloadEnvResponse>(r => {
|
||||
const n = Number(r.updated ?? 0)
|
||||
const noun = n === 1 ? 'var' : 'vars'
|
||||
|
||||
ctx.transcript.sys(`reloaded .env (${n} ${noun} updated)`)
|
||||
})
|
||||
)
|
||||
.catch(ctx.guardedErr)
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
help: 'manage browser CDP connection [connect|disconnect|status]',
|
||||
name: 'browser',
|
||||
@@ -98,13 +117,16 @@ export const opsCommands: SlashCommand[] = [
|
||||
const action = (rawAction || 'status').toLowerCase()
|
||||
|
||||
if (!['connect', 'disconnect', 'status'].includes(action)) {
|
||||
return ctx.transcript.sys('usage: /browser [connect|disconnect|status] [url]')
|
||||
return ctx.transcript.sys(
|
||||
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
|
||||
)
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = { action }
|
||||
const requested = rest.join(' ').trim()
|
||||
|
||||
if (action === 'connect') {
|
||||
payload.url = rest.join(' ').trim() || 'http://localhost:9222'
|
||||
payload.url = requested || 'http://localhost:9222'
|
||||
}
|
||||
|
||||
ctx.gateway
|
||||
@@ -113,14 +135,21 @@ export const opsCommands: SlashCommand[] = [
|
||||
ctx.guarded<BrowserManageResponse>(r => {
|
||||
if (action === 'status') {
|
||||
return ctx.transcript.sys(
|
||||
r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser not connected'
|
||||
r.connected
|
||||
? `browser connected: ${r.url || '(url unavailable)'}`
|
||||
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
|
||||
)
|
||||
}
|
||||
|
||||
if (action === 'connect') {
|
||||
return ctx.transcript.sys(
|
||||
r.connected ? `browser connected: ${r.url || '(url unavailable)'}` : 'browser connect failed'
|
||||
)
|
||||
if (r.connected) {
|
||||
ctx.transcript.sys(`browser connected: ${r.url || '(url unavailable)'}`)
|
||||
ctx.transcript.sys('next browser tool call will use this CDP endpoint')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return ctx.transcript.sys('browser connect failed')
|
||||
}
|
||||
|
||||
ctx.transcript.sys('browser disconnected')
|
||||
|
||||
@@ -431,7 +431,13 @@ class TurnController {
|
||||
recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {
|
||||
this.closeReasoningSegment()
|
||||
|
||||
const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
|
||||
// Ink renders markdown via <Md>; the gateway's Rich-rendered ANSI
|
||||
// (`payload.rendered`) is for terminals that can't. Prioritising
|
||||
// `rendered` here garbles output whenever a user opts into
|
||||
// `display.final_response_markdown: render` because raw ANSI escapes
|
||||
// pass through into the React tree. Prefer raw text and fall back
|
||||
// only when the gateway elected not to send any (#16391).
|
||||
const rawText = (payload.text ?? payload.rendered ?? this.bufRef).trimStart()
|
||||
const split = splitReasoning(rawText)
|
||||
const finalText = finalTail(split.text, this.segmentMessages)
|
||||
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
||||
@@ -516,7 +522,7 @@ class TurnController {
|
||||
return { finalMessages, finalText, wasInterrupted }
|
||||
}
|
||||
|
||||
recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) {
|
||||
recordMessageDelta({ text }: { rendered?: string; text?: string }) {
|
||||
if (this.interrupted || !text) {
|
||||
return
|
||||
}
|
||||
@@ -524,7 +530,12 @@ class TurnController {
|
||||
this.pruneTransient()
|
||||
this.endReasoningPhase()
|
||||
|
||||
this.bufRef = rendered ?? this.bufRef + text
|
||||
// Always accumulate the raw text delta. The pre-#16391 path replaced
|
||||
// the entire buffer with `rendered` (an *incremental* Rich ANSI
|
||||
// fragment), which on every tick discarded everything streamed so far
|
||||
// — visible as overlapping coloured text and lost prose under
|
||||
// `display.final_response_markdown: render`.
|
||||
this.bufRef += text
|
||||
|
||||
if (getUiState().streaming) {
|
||||
this.scheduleStreaming()
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { UiState } from './interfaces.js'
|
||||
const buildUiState = (): UiState => ({
|
||||
bgTasks: new Set(),
|
||||
busy: false,
|
||||
busyInputMode: 'queue',
|
||||
compact: false,
|
||||
detailsMode: 'collapsed',
|
||||
detailsModeCommandOverride: false,
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
} from '../gatewayTypes.js'
|
||||
import { asRpcResult } from '../lib/rpc.js'
|
||||
|
||||
import type { StatusBarMode } from './interfaces.js'
|
||||
import type { BusyInputMode, StatusBarMode } from './interfaces.js'
|
||||
import { turnController } from './turnController.js'
|
||||
import { patchUiState } from './uiStore.js'
|
||||
|
||||
@@ -24,6 +24,27 @@ const STATUSBAR_ALIAS: Record<string, StatusBarMode> = {
|
||||
export const normalizeStatusBar = (raw: unknown): StatusBarMode =>
|
||||
raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top'
|
||||
|
||||
const BUSY_MODES = new Set<BusyInputMode>(['interrupt', 'queue', 'steer'])
|
||||
|
||||
// TUI defaults to `queue` even though the framework default
|
||||
// (`hermes_cli/config.py`) is `interrupt`. Rationale: in a full-screen
|
||||
// TUI you're typically authoring the next prompt while the agent is
|
||||
// still streaming, and an unintended interrupt loses work. Set
|
||||
// `display.busy_input_mode: interrupt` (or `steer`) explicitly to
|
||||
// opt out per-config; CLI / messaging adapters keep their `interrupt`
|
||||
// default unchanged.
|
||||
const TUI_BUSY_DEFAULT: BusyInputMode = 'queue'
|
||||
|
||||
export const normalizeBusyInputMode = (raw: unknown): BusyInputMode => {
|
||||
if (typeof raw !== 'string') {
|
||||
return TUI_BUSY_DEFAULT
|
||||
}
|
||||
|
||||
const v = raw.trim().toLowerCase() as BusyInputMode
|
||||
|
||||
return BUSY_MODES.has(v) ? v : TUI_BUSY_DEFAULT
|
||||
}
|
||||
|
||||
const MTIME_POLL_MS = 5000
|
||||
|
||||
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
|
||||
@@ -43,6 +64,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
|
||||
|
||||
setBell(!!d.bell_on_complete)
|
||||
patchUiState({
|
||||
busyInputMode: normalizeBusyInputMode(d.busy_input_mode),
|
||||
compact: !!d.tui_compact,
|
||||
detailsMode: resolveDetailsMode(d),
|
||||
detailsModeCommandOverride: false,
|
||||
|
||||
@@ -4,7 +4,12 @@ import { TYPING_IDLE_MS } from '../config/timing.js'
|
||||
import { attachedImageNotice } from '../domain/messages.js'
|
||||
import { looksLikeSlashCommand } from '../domain/slash.js'
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js'
|
||||
import type {
|
||||
InputDetectDropResponse,
|
||||
PromptSubmitResponse,
|
||||
SessionSteerResponse,
|
||||
ShellExecResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { asRpcResult } from '../lib/rpc.js'
|
||||
import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js'
|
||||
import { PASTE_SNIPPET_RE } from '../protocol/paste.js'
|
||||
@@ -207,6 +212,70 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
[interpolate, send, shellExec]
|
||||
)
|
||||
|
||||
// Honors `display.busy_input_mode` from config.yaml (CLI parity):
|
||||
// - 'queue' (legacy): append to queueRef; drains on busy → false
|
||||
// - 'steer' : inject into the current turn via session.steer; falls
|
||||
// back to queue when steer is rejected (no agent / no
|
||||
// tool window).
|
||||
// - 'interrupt' (default): cancel the in-flight turn, then send the
|
||||
// new text as a fresh prompt so it actually moves.
|
||||
//
|
||||
// `opts.fallbackToFront` controls whether a steer fallback re-inserts
|
||||
// at the front of the queue (used by the queue-edit path to preserve
|
||||
// a picked item's position); the mainline submit path always appends.
|
||||
const handleBusyInput = useCallback(
|
||||
(full: string, opts: { fallbackToFront?: boolean } = {}) => {
|
||||
const live = getUiState()
|
||||
const mode = live.busyInputMode
|
||||
const fallback = (note: string) => {
|
||||
if (opts.fallbackToFront) {
|
||||
composerRefs.queueRef.current.unshift(full)
|
||||
composerActions.syncQueue()
|
||||
} else {
|
||||
composerActions.enqueue(full)
|
||||
}
|
||||
sys(note)
|
||||
}
|
||||
|
||||
if (mode === 'queue') {
|
||||
return composerActions.enqueue(full)
|
||||
}
|
||||
|
||||
if (mode === 'steer' && live.sid) {
|
||||
gw.request<SessionSteerResponse>('session.steer', { session_id: live.sid, text: full })
|
||||
.then(raw => {
|
||||
const r = asRpcResult<SessionSteerResponse>(raw)
|
||||
|
||||
if (r?.status !== 'queued') {
|
||||
fallback('steer rejected — message queued for next turn')
|
||||
}
|
||||
})
|
||||
.catch(() => fallback('steer failed — message queued for next turn'))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 'interrupt' (default): tear down the current turn, then send.
|
||||
// `interruptTurn` fires `session.interrupt` without awaiting; if
|
||||
// the gateway is still mid-response when `prompt.submit` lands,
|
||||
// `send()`'s catch path re-queues with a "queued: ..." sys note
|
||||
// (`isSessionBusyError`) — so a lost race degrades to queue
|
||||
// semantics, not a dropped message.
|
||||
if (live.sid) {
|
||||
turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys })
|
||||
}
|
||||
|
||||
if (hasInterpolation(full)) {
|
||||
patchUiState({ busy: true })
|
||||
|
||||
return interpolate(full, send)
|
||||
}
|
||||
|
||||
send(full)
|
||||
},
|
||||
[appendMessage, composerActions, composerRefs, gw, interpolate, send, sys]
|
||||
)
|
||||
|
||||
const dispatchSubmission = useCallback(
|
||||
(full: string) => {
|
||||
if (!full.trim()) {
|
||||
@@ -252,9 +321,16 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
}
|
||||
|
||||
if (getUiState().busy) {
|
||||
composerRefs.queueRef.current.unshift(picked)
|
||||
// 'interrupt' / 'steer' should reach the live turn instead of
|
||||
// silently going back to the queue. handleBusyInput resolves
|
||||
// mode-specific behavior (interrupt-and-send, steer, or queue).
|
||||
if (getUiState().busyInputMode === 'queue') {
|
||||
composerRefs.queueRef.current.unshift(picked)
|
||||
|
||||
return composerActions.syncQueue()
|
||||
return composerActions.syncQueue()
|
||||
}
|
||||
|
||||
return handleBusyInput(picked, { fallbackToFront: true })
|
||||
}
|
||||
|
||||
return sendQueued(picked)
|
||||
@@ -263,7 +339,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
composerActions.pushHistory(full)
|
||||
|
||||
if (getUiState().busy) {
|
||||
return composerActions.enqueue(full)
|
||||
return handleBusyInput(full)
|
||||
}
|
||||
|
||||
if (hasInterpolation(full)) {
|
||||
@@ -274,7 +350,17 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||
|
||||
send(full)
|
||||
},
|
||||
[appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef]
|
||||
[
|
||||
appendMessage,
|
||||
composerActions,
|
||||
composerRefs,
|
||||
handleBusyInput,
|
||||
interpolate,
|
||||
send,
|
||||
sendQueued,
|
||||
shellExec,
|
||||
slashRef
|
||||
]
|
||||
)
|
||||
|
||||
const submit = useCallback(
|
||||
|
||||
@@ -53,6 +53,7 @@ export type CommandDispatchResponse =
|
||||
|
||||
export interface ConfigDisplayConfig {
|
||||
bell_on_complete?: boolean
|
||||
busy_input_mode?: string
|
||||
details_mode?: string
|
||||
inline_diffs?: boolean
|
||||
sections?: Record<string, string>
|
||||
@@ -299,6 +300,10 @@ export interface ReloadMcpResponse {
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface ReloadEnvResponse {
|
||||
updated?: number
|
||||
}
|
||||
|
||||
export interface ProcessStopResponse {
|
||||
killed?: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user