Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 85 additions & 6 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ def _resolve_restart_drain_timeout() -> float:
async def _lifespan(app: "FastAPI"):
app.state.event_channels = {} # dict[str, set]
app.state.event_lock = asyncio.Lock()
app.state.pty_active_session_files = {} # dict[str, Path]
# Serializes chat-argv resolution so concurrent /api/pty connections
# don't trigger overlapping ``npm install`` / ``npm run build`` work.
# On app.state (not a module global) so the Lock binds to the running
Expand Down Expand Up @@ -234,6 +235,15 @@ def _get_chat_argv_lock(app: "FastAPI") -> asyncio.Lock:
return app.state.chat_argv_lock


def _get_pty_active_session_files(app: "FastAPI") -> dict[str, Path]:
"""Return channel -> active-session-file state for dashboard PTYs."""
try:
return app.state.pty_active_session_files
except AttributeError:
app.state.pty_active_session_files = {}
return app.state.pty_active_session_files


app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan)

# Memory-provider OAuth connect routes live in the memory layer, not here.
Expand Down Expand Up @@ -11544,6 +11554,7 @@ def _resolve_chat_argv(
resume: Optional[str] = None,
sidecar_url: Optional[str] = None,
profile: Optional[str] = None,
active_session_file: Optional[str] = None,
) -> tuple[list[str], Optional[str], Optional[dict]]:
"""Resolve the argv + cwd + env for the chat PTY.

Expand All @@ -11564,6 +11575,12 @@ def _resolve_chat_argv(
the spawned ``tui_gateway.entry`` can mirror dispatcher emits to the
dashboard's ``/api/pub`` endpoint (see :func:`pub_ws`).

`active_session_file` (when set) is forwarded as
``HERMES_TUI_ACTIVE_SESSION_FILE``. The TUI writes the current session id
there whenever it creates/resumes/switches sessions, giving the dashboard a
small cross-process breadcrumb for reconnecting after an unexpected browser
WebSocket close.

`profile` (when set) scopes the ENTIRE chat to that profile by pointing
``HERMES_HOME`` at the profile dir in the child env. Every spawned
process (the TUI and the ``tui_gateway.entry`` it launches) resolves
Expand Down Expand Up @@ -11611,6 +11628,9 @@ def _resolve_chat_argv(
if sidecar_url:
env["HERMES_TUI_SIDECAR_URL"] = sidecar_url

if active_session_file:
env["HERMES_TUI_ACTIVE_SESSION_FILE"] = active_session_file

# Profile-scoped chats must NOT attach to the dashboard's in-memory
# gateway — it runs under the dashboard's own profile. Without the
# attach URL, gatewayClient spawns its own `tui_gateway.entry`, which
Expand Down Expand Up @@ -11659,6 +11679,7 @@ async def _resolve_chat_argv_async(
resume: Optional[str] = None,
sidecar_url: Optional[str] = None,
profile: Optional[str] = None,
active_session_file: Optional[str] = None,
) -> tuple[list[str], Optional[str], Optional[dict]]:
"""Resolve chat argv without blocking the dashboard event loop.

Expand All @@ -11670,12 +11691,18 @@ async def _resolve_chat_argv_async(
multiple browser tabs connect at once without occupying worker threads
while queued connections wait.
"""
kwargs = {
"resume": resume,
"sidecar_url": sidecar_url,
"profile": profile,
}
if active_session_file is not None:
kwargs["active_session_file"] = active_session_file

async with _get_chat_argv_lock(app):
return await asyncio.to_thread(
_resolve_chat_argv,
resume=resume,
sidecar_url=sidecar_url,
profile=profile,
**kwargs,
)


Expand Down Expand Up @@ -11737,6 +11764,37 @@ def _channel_or_close_code(ws: WebSocket) -> Optional[str]:
return channel if _VALID_CHANNEL_RE.match(channel) else None


def _active_session_file_for_channel(app: "FastAPI", channel: str) -> Path:
"""Return the per-channel file where a dashboard TUI writes its active sid."""
files = _get_pty_active_session_files(app)
existing = files.get(channel)
if existing is not None:
return existing

fd, raw_path = tempfile.mkstemp(prefix="hermes-pty-active-", suffix=".json")
os.close(fd)
path = Path(raw_path)
files[channel] = path
return path


def _read_active_session_file(path: Path) -> Optional[str]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None

session_id = str(data.get("session_id") or "").strip()
return session_id or None


def _forget_active_session_file(path: Path) -> None:
try:
path.unlink(missing_ok=True)
except OSError:
pass


def _ws_close_reason(text: str) -> str:
"""Clamp a WS close reason to the protocol's 123-byte UTF-8 limit.

Expand Down Expand Up @@ -11807,11 +11865,32 @@ async def pty_ws(ws: WebSocket) -> None:
profile = ws.query_params.get("profile") or None
channel = _channel_or_close_code(ws)
sidecar_url = _build_sidecar_url(channel) if channel else None
force_fresh = (ws.query_params.get("fresh") or "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
active_session_file: Optional[Path] = None

if channel:
active_session_file = _active_session_file_for_channel(ws.app, channel)
if force_fresh:
resume = None
_forget_active_session_file(active_session_file)
elif not resume:
resume = _read_active_session_file(active_session_file)

resolve_kwargs = {
"resume": resume,
"sidecar_url": sidecar_url,
"profile": profile,
}
if active_session_file is not None:
resolve_kwargs["active_session_file"] = str(active_session_file)

try:
argv, cwd, env = await _resolve_chat_argv_async(
resume=resume, sidecar_url=sidecar_url, profile=profile
)
argv, cwd, env = await _resolve_chat_argv_async(**resolve_kwargs)
except HTTPException as exc:
# Unknown/invalid profile from _resolve_profile_dir.
await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc.detail}\x1b[0m\r\n")
Expand Down
5 changes: 4 additions & 1 deletion tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5426,6 +5426,7 @@ def _setup(self, monkeypatch, _isolate_hermes_home):
# its own fake argv via ``ws._resolve_chat_argv``.
self.ws_module = ws
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
ws.app.state.pty_active_session_files = {}
self.token = ws._SESSION_TOKEN
self.client = TestClient(ws.app)

Expand Down Expand Up @@ -5761,8 +5762,9 @@ def test_channel_param_propagates_sidecar_url(self, monkeypatch):
same channel — which is how tool events reach the dashboard sidebar."""
captured: dict = {}

def fake_resolve(resume=None, sidecar_url=None, profile=None):
def fake_resolve(resume=None, sidecar_url=None, profile=None, active_session_file=None):
captured["sidecar_url"] = sidecar_url
captured["active_session_file"] = active_session_file
return (["/bin/sh", "-c", "printf sidecar-ok"], None, None)

monkeypatch.setattr(self.ws_module, "_resolve_chat_argv", fake_resolve)
Expand All @@ -5786,6 +5788,7 @@ def fake_resolve(resume=None, sidecar_url=None, profile=None):
assert url.startswith("ws://127.0.0.1:9119/api/pub?")
assert "channel=abc-123" in url
assert "token=" in url
assert captured["active_session_file"]

def test_pub_broadcasts_to_events_subscribers(self):
"""A frame handed to _broadcast_event is sent verbatim to every
Expand Down
130 changes: 130 additions & 0 deletions tests/hermes_cli/test_web_server_pty_reconnect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Focused tests for dashboard PTY reconnect breadcrumbs."""

import json
import sys
from pathlib import Path
from urllib.parse import urlencode

import pytest


pytestmark = pytest.mark.skipif(
sys.platform.startswith("win"), reason="PTY bridge is POSIX-only"
)


class _OneFrameBridge:
def __init__(self):
self._sent = False

@classmethod
def spawn(cls, *args, **kwargs):
return cls()

def read(self, timeout):
if not self._sent:
self._sent = True
return b"ready"
return None

def resize(self, *, cols, rows):
pass

def write(self, raw):
pass

def close(self):
pass


@pytest.fixture
def pty_client(monkeypatch, _isolate_hermes_home):
from starlette.testclient import TestClient

import hermes_cli.web_server as ws

monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
monkeypatch.setattr(ws.PtyBridge, "spawn", _OneFrameBridge.spawn)
ws.app.state.pty_active_session_files = {}

client = TestClient(ws.app)
return ws, client, ws._SESSION_TOKEN


def _url(token: str, **params: str) -> str:
return f"/api/pty?{urlencode({'token': token, **params})}"


def test_resolve_chat_argv_sets_active_session_file_env(monkeypatch):
"""Dashboard chat gives the TUI a breadcrumb file for reconnect resume."""
import hermes_cli.main as main_mod
import hermes_cli.web_server as ws

monkeypatch.setattr(
main_mod,
"_make_tui_argv",
lambda project_root, tui_dev=False: (["node", "dist/entry.js"], "/tmp/ui-tui"),
)

_argv, _cwd, env = ws._resolve_chat_argv(
active_session_file="/tmp/hermes-active-session.json"
)

assert env["HERMES_TUI_ACTIVE_SESSION_FILE"] == "/tmp/hermes-active-session.json"


def test_channel_reconnect_resumes_active_session_file(pty_client, monkeypatch):
"""A new /api/pty socket on the same channel resumes the last TUI sid."""
ws, client, token = pty_client
captured = []

def fake_resolve(resume=None, sidecar_url=None, profile=None, active_session_file=None):
captured.append(
{
"active_session_file": active_session_file,
"resume": resume,
"sidecar_url": sidecar_url,
}
)
if active_session_file and not resume:
Path(active_session_file).write_text(
json.dumps({"session_id": "sess-live"}),
encoding="utf-8",
)
return (["fake-hermes-tui"], None, None)

monkeypatch.setattr(ws, "_resolve_chat_argv", fake_resolve)

with client.websocket_connect(_url(token, channel="reconnect-chan")) as conn:
assert conn.receive_bytes() == b"ready"

with client.websocket_connect(_url(token, channel="reconnect-chan")) as conn:
assert conn.receive_bytes() == b"ready"

assert captured[0]["resume"] is None
assert captured[0]["active_session_file"]
assert captured[1]["resume"] == "sess-live"
assert captured[1]["active_session_file"] == captured[0]["active_session_file"]


def test_fresh_param_ignores_channel_active_session_file(pty_client, monkeypatch):
"""Explicit fresh starts must not resurrect the prior channel session."""
ws, client, token = pty_client
channel = "fresh-chan"
active_file = ws._active_session_file_for_channel(ws.app, channel)
active_file.write_text(json.dumps({"session_id": "sess-old"}), encoding="utf-8")
captured = {}

def fake_resolve(resume=None, sidecar_url=None, profile=None, active_session_file=None):
captured["active_session_file"] = active_session_file
captured["resume"] = resume
return (["fake-hermes-tui"], None, None)

monkeypatch.setattr(ws, "_resolve_chat_argv", fake_resolve)

with client.websocket_connect(_url(token, channel=channel, fresh="1")) as conn:
assert conn.receive_bytes() == b"ready"

assert captured["resume"] is None
assert captured["active_session_file"] == str(active_file)
assert not active_file.exists()
Loading
Loading