feat(tui-gateway): WebSocket transport + /chat web UI, wire-compatible with Ink

Extracts the JSON-RPC transport from stdio into an abstraction so the same
dispatcher drives Ink over stdio AND browser/iOS clients over WebSocket
without duplicating handler logic. Adds a Chat page to the existing web
dashboard that exercises the full surface — streaming, tool calls, slash
commands, model picker, session resume.

Backend
-------
* tui_gateway/transport.py — Transport protocol + contextvar binding + the
  module-level StdioTransport. Stream is resolved through a callback so
  tests that monkeypatch `_real_stdout` keep working.
* tui_gateway/server.py — write_json and dispatch are now transport-aware.
  Backward compatible: no transport bound = legacy stdio path, so entry.py
  (Ink's stdio entrypoint) is unchanged externally.
* tui_gateway/ws.py — WSTransport + handle_ws coroutine. Safe to call from
  any thread: detects loop-thread deadlock and fire-and-forget schedules
  when needed, blocking run_coroutine_threadsafe + future.result otherwise.
* hermes_cli/web_server.py — mounts /api/ws on the existing FastAPI app,
  gated by the same ephemeral session token used for REST. Adds
  HERMES_DASHBOARD_DEV_TOKEN env override so Vite HMR dev can share the
  token with the backend.

Frontend
--------
* web/src/lib/gatewayClient.ts — browser WebSocket JSON-RPC client that
  mirrors ui-tui/src/gatewayClient.ts.
* web/src/lib/slashExec.ts — slash command pipeline (slash.exec with
  command.dispatch fallback + exec/plugin/alias/skill/send directive
  handling), mirrors ui-tui/src/app/createSlashHandler.ts.
* web/src/pages/ChatPage.tsx — transcript + composer driven entirely by
  the WS.
* web/src/components/SlashPopover.tsx — autocomplete popover above the
  composer, debounced complete.slash.
* web/src/components/ModelPickerDialog.tsx — two-stage provider/model
  picker; confirms by emitting /model through the slash pipeline.
* web/src/components/ToolCall.tsx — expandable tool call row (Ink-style
  chevron + context + summary/error/diff).
* web/src/App.tsx — logo links to /, Chat entry added to nav.
* web/src/pages/SessionsPage.tsx — every session row gets an Open-in-chat
  button that navigates to /chat?resume=<id> (uses session.resume).
* web/vite.config.ts — /api proxy configured with ws: true so WebSocket
  upgrades forward in dev mode; injectDevToken plugin reads
  HERMES_DASHBOARD_DEV_TOKEN and injects it into the served index.html so
  Vite HMR can authenticate against FastAPI without a separate flow.

Tests
-----
tests/hermes_cli/test_web_server.py picks up three new classes:

* TestTuiGatewayWebSocket — handshake, auth rejection, parse errors,
  unknown methods, inline + pool handler round-trips, session event
  routing, disconnect cleanup.
* TestTuiGatewayTransportParity — byte-identical envelopes for the same
  RPC over stdio vs WS (unknown method, inline handler, error envelope,
  explicit stdio transport).
* TestTuiGatewayE2EAnyPort — scripted multi-RPC conversation driven
  identically via handle_request and via WebSocket; order + shape must
  match. This is the "hermes --tui in any port" check.

Existing tests under tests/tui_gateway/ and tests/test_tui_gateway_server.py
all still pass unchanged — backward compat preserved.

Try it
------
    hermes dashboard          # builds web, serves on :9119, click Chat

Dev with HMR:

    export HERMES_DASHBOARD_DEV_TOKEN="dev-\$(openssl rand -hex 16)"
    hermes dashboard --no-open
    cd web && npm run dev     # :5173, /api + /api/ws proxied to :9119

fix(chat): insert tool rows before the streaming assistant message

Transcript used to read "user → empty assistant bubble → tool → bubble
filling in", which is disorienting: the streaming cursor sits at the top
while the "work" rows appear below it chronologically.

Now tool.start inserts the row just before the current streaming
assistant message, so the order reads "user → tools → final message".
If no streaming assistant exists yet (rare), tools still append at the
end; tool.progress / tool.complete match by id regardless of position.

fix(web-chat): font, composer, streaming caret + port GoodVibesHeart

- ChatPage root opts out of App's `font-mondwest uppercase` (dashboard
  chrome style) — adds `font-courier normal-case` so transcript prose is
  readable mono mixed-case instead of pixel-display caps.
- Composer: textarea + send button wrapped as one bordered unit with
  `focus-within` ring; `font-sans` dropped (it mapped to `Collapse`
  display). Heights stretch together via `items-stretch`; button is a
  flush cap with `border-l` divider.
- Streaming caret no longer wraps to a new line when the assistant
  renders a block element. Markdown now takes a `streaming` prop and
  injects the caret inside the last block (paragraph, list item, code)
  so it hugs the trailing character. Caret sized in em units.
- EmptyState gets a blinking caret + <kbd> shortcut chips.
- Port ui-tui's GoodVibesHeart easter egg to the web: typing "thanks" /
  "ty" / "ily" / "good bot" flashes a Lucide heart next to the
  connection badge (same regex, same 650ms beat, same palette as
  ui-tui/src/app/useMainApp.ts).
This commit is contained in:
Brooklyn Nicholson 2026-04-19 17:29:51 -05:00 committed by Austin Pickett
parent c95c6bdb7c
commit 25ba6783b8
16 changed files with 2913 additions and 48 deletions

View file

@ -1677,3 +1677,454 @@ class TestDashboardPluginManifestExtensions:
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "mixed-slots")
assert entry["slots"] == ["sidebar", "header-right"]
# ---------------------------------------------------------------------------
# /api/ws — WebSocket wire-compatible with stdio tui_gateway
# ---------------------------------------------------------------------------
class TestTuiGatewayWebSocket:
"""E2E tests for /api/ws.
The WS endpoint multiplexes the same JSON-RPC protocol Ink speaks over
stdio onto a browser/iOS-friendly socket. These tests exercise the
transport boundary without booting a real AIAgent handlers are
monkey-patched in for deterministic byte-level assertions.
"""
@pytest.fixture(autouse=True)
def _setup(self):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app, _SESSION_TOKEN
self.client = TestClient(app)
self.token = _SESSION_TOKEN
def _url(self, token=None):
tok = self.token if token is None else token
return f"/api/ws?token={tok}" if tok else "/api/ws"
def _drain_ready(self, ws):
"""Skip the ``gateway.ready`` event emitted on accept."""
frame = ws.receive_json()
assert frame.get("method") == "event"
assert frame["params"]["type"] == "gateway.ready"
return frame
def test_handshake_emits_gateway_ready(self):
with self.client.websocket_connect(self._url()) as ws:
first = ws.receive_json()
assert first["jsonrpc"] == "2.0"
assert first["method"] == "event"
assert first["params"]["type"] == "gateway.ready"
assert "skin" in first["params"]["payload"]
def test_rejects_missing_token(self):
from starlette.websockets import WebSocketDisconnect
with pytest.raises(WebSocketDisconnect):
with self.client.websocket_connect(self._url(token="")) as ws:
ws.receive_json()
def test_rejects_bad_token(self):
from starlette.websockets import WebSocketDisconnect
with pytest.raises(WebSocketDisconnect):
with self.client.websocket_connect(self._url(token="bogus-token-xyz")) as ws:
ws.receive_json()
def test_parse_error_on_bad_frame(self):
with self.client.websocket_connect(self._url()) as ws:
self._drain_ready(ws)
ws.send_text("this is { not json")
resp = ws.receive_json()
assert resp["jsonrpc"] == "2.0"
assert resp["error"]["code"] == -32700
assert resp["error"]["message"] == "parse error"
def test_unknown_method_returns_rpc_error(self):
with self.client.websocket_connect(self._url()) as ws:
self._drain_ready(ws)
ws.send_json({"jsonrpc": "2.0", "id": "u1", "method": "does.not.exist"})
resp = ws.receive_json()
assert resp["id"] == "u1"
assert resp["error"]["code"] == -32601
assert "does.not.exist" in resp["error"]["message"]
def test_inline_handler_returns_response(self):
"""An inline handler's result round-trips via the WS transport."""
from tui_gateway import server
sentinel = "_ws_inline_test"
server._methods[sentinel] = lambda rid, params: server._ok(rid, {"pong": params.get("ping")})
try:
with self.client.websocket_connect(self._url()) as ws:
self._drain_ready(ws)
ws.send_json({"jsonrpc": "2.0", "id": "i1", "method": sentinel, "params": {"ping": "PONG"}})
resp = ws.receive_json()
assert resp == {"jsonrpc": "2.0", "id": "i1", "result": {"pong": "PONG"}}
finally:
server._methods.pop(sentinel, None)
def test_pool_handler_response_arrives_via_ws(self):
"""Long-handler responses written from the thread pool must reach the WS client."""
from tui_gateway import server
# Register a "slash.exec" replacement so we exercise the pool path
# (_LONG_HANDLERS includes "slash.exec").
original = server._methods.get("slash.exec")
server._methods["slash.exec"] = lambda rid, params: server._ok(rid, {"output": "async-ok"})
try:
with self.client.websocket_connect(self._url()) as ws:
self._drain_ready(ws)
ws.send_json({"jsonrpc": "2.0", "id": "p1", "method": "slash.exec", "params": {}})
resp = ws.receive_json()
assert resp["id"] == "p1"
assert resp["result"] == {"output": "async-ok"}
finally:
if original is not None:
server._methods["slash.exec"] = original
else:
server._methods.pop("slash.exec", None)
def test_session_events_route_to_owning_ws(self):
"""Events emitted for a session created over WS land on that WS."""
from tui_gateway import server
from tui_gateway.transport import current_transport
sentinel_create = "_ws_emit_test_create"
sentinel_emit = "_ws_emit_test_fire"
created_sid = {"value": ""}
def create(rid, params):
sid = f"ws-emit-test-{uuid_hex()}"
created_sid["value"] = sid
server._sessions[sid] = {
"session_key": sid,
"transport": current_transport(),
}
return server._ok(rid, {"session_id": sid})
def fire(rid, params):
sid = params["session_id"]
server._emit("demo.event", sid, {"n": params.get("n", 0)})
return server._ok(rid, {"ok": True})
def uuid_hex():
import uuid
return uuid.uuid4().hex[:8]
server._methods[sentinel_create] = create
server._methods[sentinel_emit] = fire
try:
with self.client.websocket_connect(self._url()) as ws:
self._drain_ready(ws)
ws.send_json({"jsonrpc": "2.0", "id": "c1", "method": sentinel_create})
create_resp = ws.receive_json()
assert create_resp["id"] == "c1"
sid = create_resp["result"]["session_id"]
assert sid == created_sid["value"]
ws.send_json({
"jsonrpc": "2.0",
"id": "e1",
"method": sentinel_emit,
"params": {"session_id": sid, "n": 7},
})
# Event fires synchronously inside the handler, so it should
# arrive before the response.
frame1 = ws.receive_json()
frame2 = ws.receive_json()
event_frame = frame1 if frame1.get("method") == "event" else frame2
resp_frame = frame2 if frame2.get("id") == "e1" else frame1
assert event_frame["params"]["type"] == "demo.event"
assert event_frame["params"]["session_id"] == sid
assert event_frame["params"]["payload"] == {"n": 7}
assert resp_frame["result"] == {"ok": True}
finally:
server._methods.pop(sentinel_create, None)
server._methods.pop(sentinel_emit, None)
server._sessions.pop(created_sid["value"], None)
def test_ws_disconnect_resets_session_transport(self):
"""After a WS hangs up, sessions it owned fall back to stdio so stray emits don't crash."""
from tui_gateway import server
from tui_gateway.transport import current_transport
sentinel = "_ws_disconnect_test"
captured = {"sid": "", "transport": None}
def create(rid, params):
sid = "ws-disconnect-sid"
captured["sid"] = sid
captured["transport"] = current_transport()
server._sessions[sid] = {
"session_key": sid,
"transport": captured["transport"],
}
return server._ok(rid, {"session_id": sid})
server._methods[sentinel] = create
try:
with self.client.websocket_connect(self._url()) as ws:
self._drain_ready(ws)
ws.send_json({"jsonrpc": "2.0", "id": "c1", "method": sentinel})
ws.receive_json()
# Give the server a moment to run the finally-block cleanup.
import time
for _ in range(50):
if server._sessions.get(captured["sid"], {}).get("transport") is not captured["transport"]:
break
time.sleep(0.02)
sess = server._sessions.get(captured["sid"])
assert sess is not None
assert sess["transport"] is server._stdio_transport
finally:
server._methods.pop(sentinel, None)
server._sessions.pop(captured["sid"], None)
# ---------------------------------------------------------------------------
# Transport parity — same RPC, stdio vs WS, byte-identical envelopes
# ---------------------------------------------------------------------------
class TestTuiGatewayTransportParity:
"""The whole point of the transport abstraction is that handlers don't
know what's on the other end. These tests lock that in: the response
envelope produced by ``server.handle_request`` directly (stdio fast path)
must match what a WS client receives for the same request.
"""
@pytest.fixture(autouse=True)
def _setup(self):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app, _SESSION_TOKEN
self.client = TestClient(app)
self.token = _SESSION_TOKEN
def _ws_roundtrip(self, req: dict) -> dict:
with self.client.websocket_connect(f"/api/ws?token={self.token}") as ws:
ready = ws.receive_json()
assert ready["params"]["type"] == "gateway.ready"
ws.send_json(req)
return ws.receive_json()
def test_parity_unknown_method(self):
from tui_gateway import server
req = {"jsonrpc": "2.0", "id": "p-unk", "method": "no.such.method"}
assert self._ws_roundtrip(req) == server.handle_request(req)
def test_parity_inline_handler(self):
from tui_gateway import server
sentinel = "_parity_inline"
server._methods[sentinel] = lambda rid, params: server._ok(rid, {
"echo": params,
"const": 42,
"nested": {"a": [1, 2, 3], "b": None},
})
try:
req = {
"jsonrpc": "2.0",
"id": "p-inline",
"method": sentinel,
"params": {"hello": "world", "n": 1},
}
assert self._ws_roundtrip(req) == server.handle_request(req)
finally:
server._methods.pop(sentinel, None)
def test_parity_error_envelope(self):
from tui_gateway import server
sentinel = "_parity_err"
server._methods[sentinel] = lambda rid, params: server._err(rid, 4242, "nope")
try:
req = {"jsonrpc": "2.0", "id": "p-err", "method": sentinel}
assert self._ws_roundtrip(req) == server.handle_request(req)
finally:
server._methods.pop(sentinel, None)
def test_parity_stdio_transport_also_works(self):
"""Calling dispatch() with the stdio transport explicitly must match the default."""
from tui_gateway import server
sentinel = "_parity_stdio"
server._methods[sentinel] = lambda rid, params: server._ok(rid, {"ok": True, "p": params})
try:
req = {"jsonrpc": "2.0", "id": "p-std", "method": sentinel, "params": {"x": 1}}
# Default (no transport arg)
default_resp = server.dispatch(dict(req))
# Explicit stdio transport
explicit_resp = server.dispatch(dict(req), server._stdio_transport)
assert default_resp == explicit_resp
assert default_resp["result"] == {"ok": True, "p": {"x": 1}}
finally:
server._methods.pop(sentinel, None)
# ---------------------------------------------------------------------------
# E2E: drive the "Ink --tui" JSON-RPC surface over ANY transport
# ---------------------------------------------------------------------------
class TestTuiGatewayE2EAnyPort:
"""Scripted multi-message conversations that exercise the real dispatcher.
The same scripted sequence runs over (a) direct ``handle_request`` calls
and (b) a live WebSocket. Both must produce the same response envelopes
in the same order. This is the "hermes --tui in any port" check.
"""
@pytest.fixture(autouse=True)
def _setup(self):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app, _SESSION_TOKEN
self.client = TestClient(app)
self.token = _SESSION_TOKEN
def _install_scripted_methods(self):
"""Install a tiny surface that mimics what Ink exercises on startup:
- commands.ping returns a deterministic pong
- session.sim_create creates a fake session (no real agent)
- session.sim_close tears down the session
- config.sim_get_value reads a key
"""
from tui_gateway import server
from tui_gateway.transport import current_transport
added = []
def ping(rid, params):
return server._ok(rid, {"pong": True, "id": rid})
server._methods["commands.ping"] = ping
added.append("commands.ping")
def sim_create(rid, params):
import uuid
sid = f"sim-{uuid.uuid4().hex[:6]}"
server._sessions[sid] = {
"session_key": sid,
"transport": current_transport(),
"agent": None,
}
return server._ok(rid, {"session_id": sid})
server._methods["session.sim_create"] = sim_create
added.append("session.sim_create")
def sim_close(rid, params):
sid = params.get("session_id", "")
removed = server._sessions.pop(sid, None) is not None
return server._ok(rid, {"closed": removed})
server._methods["session.sim_close"] = sim_close
added.append("session.sim_close")
def sim_get_value(rid, params):
return server._ok(rid, {"value": "deterministic", "key": params.get("key", "")})
server._methods["config.sim_get_value"] = sim_get_value
added.append("config.sim_get_value")
return added
def _uninstall(self, added):
from tui_gateway import server
for name in added:
server._methods.pop(name, None)
def _script(self):
return [
{"jsonrpc": "2.0", "id": "s1", "method": "commands.ping"},
{"jsonrpc": "2.0", "id": "s2", "method": "session.sim_create"},
{"jsonrpc": "2.0", "id": "s3", "method": "config.sim_get_value",
"params": {"key": "display.skin"}},
]
def test_script_over_direct_and_ws_match(self):
from tui_gateway import server
added = self._install_scripted_methods()
try:
script = self._script()
# Run over direct dispatch
direct_resps = [server.handle_request(dict(req)) for req in script]
# Clean up the session.create we just made so we don't leak into
# the WS run.
for r in direct_resps:
sid = (r.get("result") or {}).get("session_id")
if sid:
server._sessions.pop(sid, None)
# Run over WS
with self.client.websocket_connect(f"/api/ws?token={self.token}") as ws:
ready = ws.receive_json()
assert ready["params"]["type"] == "gateway.ready"
ws_resps = []
for req in script:
ws.send_json(req)
ws_resps.append(ws.receive_json())
# Result shapes (stripping session-identity fields) should match.
def normalize(r):
r = dict(r)
if "result" in r and isinstance(r["result"], dict):
result = dict(r["result"])
# session ids are random — compare only structure
if "session_id" in result:
result["session_id"] = "<random>"
r["result"] = result
return r
assert [normalize(r) for r in direct_resps] == [normalize(r) for r in ws_resps]
# And both surfaces ACTUALLY executed their handlers.
assert all("result" in r for r in ws_resps)
assert ws_resps[0]["result"]["pong"] is True
assert ws_resps[2]["result"]["value"] == "deterministic"
finally:
# Clean up any sessions created during the WS run.
for sid in [
sid for sid, sess in list(server._sessions.items()) if sid.startswith("sim-")
]:
server._sessions.pop(sid, None)
self._uninstall(added)
def test_session_lifecycle_over_ws(self):
"""Open a session, then close it — via WS only."""
from tui_gateway import server
added = self._install_scripted_methods()
try:
with self.client.websocket_connect(f"/api/ws?token={self.token}") as ws:
ready = ws.receive_json()
assert ready["params"]["type"] == "gateway.ready"
ws.send_json({"jsonrpc": "2.0", "id": "c1", "method": "session.sim_create"})
create = ws.receive_json()
sid = create["result"]["session_id"]
assert sid in server._sessions
ws.send_json({
"jsonrpc": "2.0", "id": "x1", "method": "session.sim_close",
"params": {"session_id": sid},
})
close = ws.receive_json()
assert close["result"] == {"closed": True}
assert sid not in server._sessions
finally:
self._uninstall(added)