mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
c95c6bdb7c
commit
25ba6783b8
16 changed files with 2913 additions and 48 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue