feat(operator): Phase 1 — Claude-driven room bridge (join/read/say)
Invert the agent model: a headless OperatorBridge(Client) owns the websocket while a Claude Code session drives it via a unix control socket. Reuses Client SRP/Fernet and AgentBridge's reconnect/serve shape. Ships an hh-bridge CLI (up/read/say/roster/status/down) with a seq'd in-RAM inbox + asyncio.Condition long-poll (read --wait) for in-turn autonomy. ACL/sandbox-status frames are recorded for later phases. Sandbox drive, delegation and nesting are out of scope here. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"""Offline tests for the operator bridge (no server, no real websocket).
|
||||
|
||||
Each test builds the bridge *inside* its own ``asyncio.run`` so the bridge's
|
||||
``asyncio.Condition`` binds to that test's loop (it is loop-bound on first use).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from cmd_chat.operator.bridge import OperatorBridge
|
||||
|
||||
|
||||
def _bridge(name="oracle", trigger=None):
|
||||
b = OperatorBridge("h", 1, name=name, password="pw", no_tls=True, trigger=trigger)
|
||||
b.room_fernet = Fernet(Fernet.generate_key())
|
||||
return b
|
||||
|
||||
|
||||
def _enc(b, text):
|
||||
return b.room_fernet.encrypt(text.encode()).decode()
|
||||
|
||||
|
||||
def _msg_frame(b, sender, text):
|
||||
"""A server 'message' envelope carrying an encrypted line from `sender`."""
|
||||
return json.dumps({"type": "message",
|
||||
"data": {"username": sender, "text": _enc(b, text)}})
|
||||
|
||||
|
||||
class FakeWS:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
|
||||
async def send(self, data):
|
||||
self.sent.append(data)
|
||||
|
||||
async def close(self):
|
||||
pass
|
||||
|
||||
|
||||
# ── addressing ────────────────────────────────────────────────────────────
|
||||
def test_is_addressed():
|
||||
b = _bridge("oracle", trigger="@bot")
|
||||
assert b._is_addressed("hey oracle you there")
|
||||
assert b._is_addressed("@oracle ping")
|
||||
assert b._is_addressed("ORACLE up?") # case-insensitive
|
||||
assert b._is_addressed("ask @bot to help") # custom trigger
|
||||
assert not b._is_addressed("oracleX is a var") # word boundary
|
||||
assert not b._is_addressed("just chatting")
|
||||
|
||||
|
||||
# ── inbox: seq + long-poll wake ───────────────────────────────────────────
|
||||
def test_emit_seq_monotonic_and_ring():
|
||||
async def go():
|
||||
b = _bridge()
|
||||
for i in range(5):
|
||||
await b._emit("message", **{"from": "a"}, text=f"m{i}", addressed=False)
|
||||
seqs = [e["seq"] for e in b.events]
|
||||
assert seqs == [1, 2, 3, 4, 5]
|
||||
assert b.seq == 5
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_read_long_poll_wakes_on_emit():
|
||||
async def go():
|
||||
b = _bridge()
|
||||
|
||||
async def reader():
|
||||
return await b._op_read({"since": 0, "wait": True, "timeout": 5})
|
||||
|
||||
async def writer():
|
||||
await asyncio.sleep(0.05)
|
||||
await b._emit("message", **{"from": "alice"}, text="hi oracle", addressed=True)
|
||||
|
||||
rd, _ = await asyncio.gather(reader(), writer())
|
||||
assert rd["ok"] and len(rd["events"]) == 1
|
||||
assert rd["events"][0]["text"] == "hi oracle"
|
||||
assert rd["events"][0]["addressed"] is True
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_read_since_filters():
|
||||
async def go():
|
||||
b = _bridge()
|
||||
for i in range(3):
|
||||
await b._emit("message", **{"from": "a"}, text=str(i), addressed=False)
|
||||
rd = await b._op_read({"since": 2, "wait": False})
|
||||
assert [e["seq"] for e in rd["events"]] == [3]
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
# ── frame routing ─────────────────────────────────────────────────────────
|
||||
def test_message_frame_emits_addressed():
|
||||
async def go():
|
||||
b = _bridge("oracle")
|
||||
await b._handle_frame(None, _msg_frame(b, "alice", "hey oracle help"))
|
||||
msgs = [e for e in b.events if e["kind"] == "message"]
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["from"] == "alice" and msgs[0]["addressed"] is True
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_own_message_ignored():
|
||||
async def go():
|
||||
b = _bridge("oracle")
|
||||
await b._handle_frame(None, _msg_frame(b, "oracle", "/say echo"))
|
||||
assert [e for e in b.events if e["kind"] == "message"] == []
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_control_acl_recorded():
|
||||
async def go():
|
||||
b = _bridge("oracle")
|
||||
ctrl = json.dumps({"_perm": "acl", "owner": "alice",
|
||||
"drivers": ["alice", "oracle"], "sudoers": ["alice"]})
|
||||
await b._handle_frame(None, _msg_frame(b, "alice", ctrl))
|
||||
assert b.granted is True
|
||||
acl = [e for e in b.events if e["kind"] == "acl"]
|
||||
assert acl and acl[0]["granted"] is True
|
||||
# an ACL control frame must NOT surface as a chat 'message'
|
||||
assert [e for e in b.events if e["kind"] == "message"] == []
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_control_sandbox_status_recorded():
|
||||
async def go():
|
||||
b = _bridge("oracle")
|
||||
ctrl = json.dumps({"_sbx": "status", "state": "ready",
|
||||
"engine": "podman", "name": "hack-house", "backend": "podman"})
|
||||
await b._handle_frame(None, _msg_frame(b, "alice", ctrl))
|
||||
assert b.sbx_engine == "podman" and b.sbx_name == "hack-house"
|
||||
assert any(e["kind"] == "sandbox" for e in b.events)
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_init_backfill_once():
|
||||
async def go():
|
||||
b = _bridge("oracle")
|
||||
init = json.dumps({
|
||||
"type": "init",
|
||||
"users": [{"username": "alice"}, {"username": "oracle"}],
|
||||
"messages": [
|
||||
{"username": "alice", "text": _enc(b, "older line")},
|
||||
{"username": "oracle", "text": _enc(b, "my own old line")}, # skipped
|
||||
],
|
||||
})
|
||||
await b._handle_frame(None, init)
|
||||
backfilled = [e for e in b.events if e.get("backfill")]
|
||||
assert len(backfilled) == 1 and backfilled[0]["from"] == "alice"
|
||||
assert backfilled[0]["addressed"] is False
|
||||
# a reconnect re-sends init; we must not duplicate history
|
||||
await b._handle_frame(None, init)
|
||||
assert len([e for e in b.events if e.get("backfill")]) == 1
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
# ── control ops ───────────────────────────────────────────────────────────
|
||||
def test_say_requires_connection():
|
||||
async def go():
|
||||
b = _bridge()
|
||||
resp = await b._op_say({"text": "hello"})
|
||||
assert resp["ok"] is False and "not connected" in resp["error"]
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_say_sends_and_records():
|
||||
async def go():
|
||||
b = _bridge()
|
||||
ws = FakeWS()
|
||||
b._ws = ws
|
||||
resp = await b._op_say({"text": "yes operator online"})
|
||||
assert resp["ok"] is True
|
||||
assert len(ws.sent) == 1
|
||||
# round-trips through the room key
|
||||
assert b.room_fernet.decrypt(ws.sent[0].encode()).decode() == "yes operator online"
|
||||
sent = [e for e in b.events if e["kind"] == "sent"]
|
||||
assert sent and sent[0]["text"] == "yes operator online"
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_dispatch_status_and_roster():
|
||||
async def go():
|
||||
b = _bridge("oracle")
|
||||
b.users = [{"username": "oracle"}, {"username": "alice"}]
|
||||
st = await b._dispatch({"op": "status"})
|
||||
assert st["ok"] and st["name"] == "oracle" and st["connected"] is False
|
||||
assert set(st["users"]) == {"oracle", "alice"}
|
||||
ping = await b._dispatch({"op": "ping"})
|
||||
assert ping["pong"] is True
|
||||
unknown = await b._dispatch({"op": "frobnicate"})
|
||||
assert unknown["ok"] is False
|
||||
asyncio.run(go())
|
||||
Reference in New Issue
Block a user