319 lines
15 KiB
Python
319 lines
15 KiB
Python
"""Exercise radio.py: class rules, the store shared with news.py, radio silence, the three hooks and the listener."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import tempfile
|
||
|
|
import time
|
||
|
|
import unittest
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
RADIO = REPO_ROOT / "skills" / "kvb-org-radio" / "scripts" / "radio.py"
|
||
|
|
CLASSES = ("BROADCAST", "DIRECT", "SECURITY", "PAN-PAN", "MAYDAY")
|
||
|
|
LISTENER = (
|
||
|
|
"import importlib.util, sys\n"
|
||
|
|
"spec = importlib.util.spec_from_file_location('radio', sys.argv[1])\n"
|
||
|
|
"radio = importlib.util.module_from_spec(spec)\n"
|
||
|
|
"spec.loader.exec_module(radio)\n"
|
||
|
|
"radio.POLL_SECONDS = 0.05\n"
|
||
|
|
"sys.argv = [sys.argv[1], 'listen']\n"
|
||
|
|
"sys.exit(radio.main())\n"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def strip(line: str) -> str:
|
||
|
|
parts = line.split(" ", 3)
|
||
|
|
if parts[0] != "RADIO" or parts[1] not in CLASSES:
|
||
|
|
return line
|
||
|
|
return f"{parts[1]} {parts[3]}"
|
||
|
|
|
||
|
|
|
||
|
|
class TestRadio(unittest.TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
tmp = tempfile.TemporaryDirectory()
|
||
|
|
self.addCleanup(tmp.cleanup)
|
||
|
|
self.home = Path(tmp.name) / "home"
|
||
|
|
self.root = Path(tmp.name) / "council"
|
||
|
|
self.config = Path(tmp.name) / "config.json"
|
||
|
|
sessions = self.home / ".claude" / "sessions"
|
||
|
|
sessions.mkdir(parents=True)
|
||
|
|
for sid, name in (("s1", "One"), ("s2", "Two"), ("s3", "Three")):
|
||
|
|
(sessions / f"{sid}.json").write_text(json.dumps({"sessionId": sid, "name": name}), encoding="utf-8")
|
||
|
|
|
||
|
|
def _env(self, sid: str | None) -> dict[str, str]:
|
||
|
|
env = {
|
||
|
|
"PATH": "/usr/bin:/bin",
|
||
|
|
"HOME": str(self.home),
|
||
|
|
"AGENTS_COUNCIL_ROOT": str(self.root),
|
||
|
|
"AGENTS_COUNCIL_CONFIG": str(self.config),
|
||
|
|
}
|
||
|
|
if sid:
|
||
|
|
env["CLAUDE_CODE_SESSION_ID"] = sid
|
||
|
|
return env
|
||
|
|
|
||
|
|
def _exec(self, sid: str | None, stdin: str, args: tuple[str, ...]) -> subprocess.CompletedProcess:
|
||
|
|
return subprocess.run(
|
||
|
|
[sys.executable, str(RADIO), *args],
|
||
|
|
input=stdin, capture_output=True, text=True, env=self._env(sid), check=False, timeout=30,
|
||
|
|
)
|
||
|
|
|
||
|
|
def _cli(self, sid: str | None, *args: str) -> subprocess.CompletedProcess:
|
||
|
|
return self._exec(sid, "", args)
|
||
|
|
|
||
|
|
def _send(self, sid: str, *args: str) -> list[str]:
|
||
|
|
result = self._cli(sid, *args)
|
||
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||
|
|
return result.stdout.splitlines()
|
||
|
|
|
||
|
|
def _hook(self, command: str, sid: str, **extra: str) -> str:
|
||
|
|
result = self._exec(None, json.dumps({"session_id": sid, **extra}), (command,))
|
||
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||
|
|
return result.stdout
|
||
|
|
|
||
|
|
def _prompt(self, sid: str) -> list[str]:
|
||
|
|
return [strip(line) for line in self._hook("hook", sid).splitlines()]
|
||
|
|
|
||
|
|
def _tool(self, sid: str) -> list[str]:
|
||
|
|
out = self._hook("tool", sid)
|
||
|
|
if not out:
|
||
|
|
return []
|
||
|
|
output = json.loads(out)["hookSpecificOutput"]
|
||
|
|
self.assertEqual(output["hookEventName"], "PostToolBatch")
|
||
|
|
return [strip(line) for line in output["additionalContext"].splitlines()]
|
||
|
|
|
||
|
|
def _rows(self) -> list[dict]:
|
||
|
|
return [json.loads(line) for line in (self.root / "news.jsonl").read_text(encoding="utf-8").splitlines()]
|
||
|
|
|
||
|
|
def _pidfile(self, sid: str) -> Path:
|
||
|
|
return self.root / "radio-listen" / f"{sid}.pid"
|
||
|
|
|
||
|
|
def _listen(self, sid: str) -> subprocess.Popen:
|
||
|
|
proc = subprocess.Popen(
|
||
|
|
[sys.executable, "-c", LISTENER, str(RADIO)],
|
||
|
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=self._env(None),
|
||
|
|
)
|
||
|
|
self.addCleanup(self._stop, proc)
|
||
|
|
proc.stdin.write(json.dumps({"session_id": sid}))
|
||
|
|
proc.stdin.close()
|
||
|
|
deadline = time.monotonic() + 10
|
||
|
|
while time.monotonic() < deadline and proc.poll() is None:
|
||
|
|
pidfile = self._pidfile(sid)
|
||
|
|
if pidfile.is_file() and pidfile.read_text(encoding="utf-8") == str(proc.pid):
|
||
|
|
return proc
|
||
|
|
time.sleep(0.02)
|
||
|
|
self.fail(f"listener for {sid} did not start")
|
||
|
|
|
||
|
|
def _stop(self, proc: subprocess.Popen) -> None:
|
||
|
|
if proc.poll() is None:
|
||
|
|
proc.kill()
|
||
|
|
proc.communicate()
|
||
|
|
|
||
|
|
def _finish(self, proc: subprocess.Popen) -> tuple[int, str]:
|
||
|
|
_, err = proc.communicate(timeout=10)
|
||
|
|
return proc.returncode, err
|
||
|
|
|
||
|
|
def test_classes_are_validated(self):
|
||
|
|
for args in (
|
||
|
|
("send", "broadcast", "x", "--to", "Two"),
|
||
|
|
("send", "direct", "x"),
|
||
|
|
("send", "mayday", "x", "--to", "Two"),
|
||
|
|
("send", "security", "x", "--wake"),
|
||
|
|
("send", "broadcast", "x", "--wake"),
|
||
|
|
("send", "bogus", "x"),
|
||
|
|
("send", "pan-pan", " "),
|
||
|
|
):
|
||
|
|
with self.subTest(args=args):
|
||
|
|
self.assertEqual(self._cli("s1", *args).returncode, 2)
|
||
|
|
self.assertEqual(self._cli(None, "send", "broadcast", "x").returncode, 2)
|
||
|
|
self.assertEqual(
|
||
|
|
self._send("s1", "send", "direct", "a b", "--to", "Two", "--to", "Three"), ["sent direct to Two, Three"]
|
||
|
|
)
|
||
|
|
self.assertEqual(self._send("s1", "post", "board"), ["sent broadcast to all"])
|
||
|
|
self.assertEqual(self._send("s1", "post", "one peer", "--to", "Two"), ["sent direct to Two"])
|
||
|
|
self.assertEqual(
|
||
|
|
[(r["class"], r["to"], r["text"], "wake" in r) for r in self._rows()],
|
||
|
|
[("direct", ["Two", "Three"], "a b", False), ("broadcast", [], "board", False), ("direct", ["Two"], "one peer", False)],
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_old_rows_are_read_and_new_rows_keep_the_old_keys(self):
|
||
|
|
self.root.mkdir()
|
||
|
|
now = time.time()
|
||
|
|
old = [
|
||
|
|
{"ts": now - 10, "from": "Three", "to": [], "text": "old board"},
|
||
|
|
{"ts": now - 5, "from": "Three", "to": ["Two"], "text": "old direct"},
|
||
|
|
]
|
||
|
|
(self.root / "news.jsonl").write_text("".join(json.dumps(r) + "\n" for r in old), encoding="utf-8")
|
||
|
|
self.assertEqual(self._prompt("s2"), ["BROADCAST Three: old board", "DIRECT Three to Two: old direct"])
|
||
|
|
self.assertEqual(float((self.root / "news-seen" / "s2").read_text(encoding="utf-8")), now - 5)
|
||
|
|
self._send("s1", "send", "security", "new")
|
||
|
|
rows = self._rows()
|
||
|
|
self.assertEqual(rows[:2], old)
|
||
|
|
for row in rows:
|
||
|
|
self.assertIsInstance(row["ts"], float)
|
||
|
|
self.assertIsInstance(row["from"], str)
|
||
|
|
self.assertIsInstance(row["to"], list)
|
||
|
|
self.assertIsInstance(row["text"], str)
|
||
|
|
self.assertEqual(rows[2]["class"], "security")
|
||
|
|
|
||
|
|
def test_ts_strictly_increases_under_the_lock(self):
|
||
|
|
self.root.mkdir()
|
||
|
|
row = {"ts": time.time() + 1000, "from": "Three", "to": [], "text": "clock ahead"}
|
||
|
|
(self.root / "news.jsonl").write_text(json.dumps(row) + "\n", encoding="utf-8")
|
||
|
|
self._send("s1", "post", "first")
|
||
|
|
self._send("s1", "post", "second")
|
||
|
|
stamps = [r["ts"] for r in self._rows()]
|
||
|
|
self.assertGreater(stamps[1], stamps[0])
|
||
|
|
self.assertGreater(stamps[2], stamps[1])
|
||
|
|
|
||
|
|
def test_mayday_sets_silence_and_only_its_sender_clears_it(self):
|
||
|
|
self.assertEqual(self._send("s1", "clear"), ["no radio silence active"])
|
||
|
|
self.assertEqual(self._send("s1", "send", "mayday", "disk full")[0], "sent mayday to all")
|
||
|
|
silence = self.root / "radio-silence.json"
|
||
|
|
state = json.loads(silence.read_text(encoding="utf-8"))
|
||
|
|
self.assertEqual(sorted(state), ["by", "since", "text", "until"])
|
||
|
|
self.assertEqual((state["by"], state["text"], state["until"] - state["since"]), ("One", "disk full", 7200))
|
||
|
|
self.assertEqual(state["since"], self._rows()[-1]["ts"])
|
||
|
|
held = self._cli("s2", "clear")
|
||
|
|
self.assertEqual(held.returncode, 3)
|
||
|
|
self.assertIn("held by One", held.stdout)
|
||
|
|
self.assertTrue(silence.exists())
|
||
|
|
self.assertEqual(self._send("s1", "clear"), ["cleared radio silence by One"])
|
||
|
|
self.assertFalse(silence.exists())
|
||
|
|
self._send("s1", "send", "mayday", "again")
|
||
|
|
self.assertEqual(self._send("s2", "clear", "--force"), ["cleared radio silence by One, forced by Two"])
|
||
|
|
self.assertFalse(silence.exists())
|
||
|
|
|
||
|
|
def test_a_wake_names_every_addressee_without_a_live_listener(self):
|
||
|
|
self._pidfile("s2").parent.mkdir(parents=True)
|
||
|
|
self._pidfile("s2").write_text(str(os.getpid()), encoding="utf-8")
|
||
|
|
self.assertEqual(self._send("s1", "send", "mayday", "stop"), ["sent mayday to all", "wake: SendMessage to Three"])
|
||
|
|
self.assertEqual(
|
||
|
|
self._send("s1", "send", "direct", "answer", "--to", "Two", "--to", "Three", "--wake"),
|
||
|
|
["sent direct to Two, Three", "wake: SendMessage to Three"],
|
||
|
|
)
|
||
|
|
dead = subprocess.Popen([sys.executable, "-c", "pass"])
|
||
|
|
dead.wait()
|
||
|
|
self._pidfile("s2").write_text(str(dead.pid), encoding="utf-8")
|
||
|
|
self.assertEqual(
|
||
|
|
self._send("s1", "send", "direct", "answer", "--to", "Two", "--wake"),
|
||
|
|
["sent direct to Two", "wake: SendMessage to Two"],
|
||
|
|
)
|
||
|
|
self.assertEqual(self._send("s1", "send", "direct", "no wake", "--to", "Three"), ["sent direct to Three"])
|
||
|
|
self.assertEqual([r.get("wake") for r in self._rows()], [None, True, True, None])
|
||
|
|
|
||
|
|
def test_prompt_hook_puts_urgent_first_and_repeats_the_silence(self):
|
||
|
|
self._send("s1", "post", "board")
|
||
|
|
self._send("s1", "post", "for two", "--to", "Two")
|
||
|
|
self._send("s1", "send", "security", "sec")
|
||
|
|
self._send("s1", "send", "pan-pan", "pan")
|
||
|
|
self._send("s1", "send", "mayday", "may")
|
||
|
|
lines = self._prompt("s2")
|
||
|
|
self.assertRegex(lines[0], r"^MAYDAY ACTIVE since \d\d:\d\d by One: may; no new heavy jobs until radio\.py clear$")
|
||
|
|
self.assertEqual(
|
||
|
|
lines[1:],
|
||
|
|
["MAYDAY One: may", "PAN-PAN One: pan", "SECURITY One: sec", "BROADCAST One: board", "DIRECT One to Two: for two"],
|
||
|
|
)
|
||
|
|
self.assertEqual(len(self._prompt("s2")), 1)
|
||
|
|
self.assertNotIn("DIRECT One to Two: for two", self._prompt("s3"))
|
||
|
|
self.assertEqual(len(self._prompt("s1")), 1)
|
||
|
|
|
||
|
|
def test_prompt_hook_shows_ten_and_counts_the_rest(self):
|
||
|
|
self._send("s1", "send", "security", "early warning")
|
||
|
|
for n in range(12):
|
||
|
|
self._send("s1", "post", f"item {n}")
|
||
|
|
lines = self._prompt("s2")
|
||
|
|
self.assertEqual(lines[0], "SECURITY One: early warning")
|
||
|
|
self.assertEqual(lines[1:-1], [f"BROADCAST One: item {n}" for n in range(3, 12)])
|
||
|
|
self.assertEqual(lines[-1], "RADIO 3 more items: radio.py show --all")
|
||
|
|
self.assertEqual(self._prompt("s2"), [])
|
||
|
|
|
||
|
|
def test_tool_hook_delivers_urgent_rows_once_and_the_prompt_skips_them(self):
|
||
|
|
self._send("s1", "post", "board")
|
||
|
|
self.assertEqual(self._tool("s2"), [])
|
||
|
|
self._send("s1", "send", "pan-pan", "pan")
|
||
|
|
self.assertEqual(self._tool("s2"), ["PAN-PAN One: pan"])
|
||
|
|
self.assertEqual(self._tool("s2"), [])
|
||
|
|
self.assertEqual(self._prompt("s2"), ["BROADCAST One: board"])
|
||
|
|
self.assertEqual(self._tool("s3"), ["PAN-PAN One: pan"])
|
||
|
|
self.assertEqual(self._tool("s1"), [])
|
||
|
|
|
||
|
|
def test_tool_hook_skips_parsing_while_the_store_is_unchanged(self):
|
||
|
|
self._send("s1", "post", "x")
|
||
|
|
self.assertEqual(self._tool("s2"), [])
|
||
|
|
news = self.root / "news.jsonl"
|
||
|
|
before = news.stat()
|
||
|
|
row = {"ts": time.time(), "from": "Three", "to": [], "text": "hidden", "class": "security"}
|
||
|
|
with news.open("a", encoding="utf-8") as handle:
|
||
|
|
handle.write(json.dumps(row) + "\n")
|
||
|
|
os.utime(news, ns=(before.st_atime_ns, before.st_mtime_ns))
|
||
|
|
self.assertEqual(self._tool("s2"), [])
|
||
|
|
os.utime(news, ns=(before.st_atime_ns, before.st_mtime_ns + 10**9))
|
||
|
|
self.assertEqual(self._tool("s2"), ["SECURITY Three: hidden"])
|
||
|
|
|
||
|
|
def test_hooks_skip_subagents_and_never_fail(self):
|
||
|
|
self._send("s1", "send", "pan-pan", "pan")
|
||
|
|
self.assertEqual(self._hook("hook", "s2", agent_id="a1"), "")
|
||
|
|
self.assertEqual(self._hook("tool", "s2", agent_id="a1"), "")
|
||
|
|
listen = subprocess.run(
|
||
|
|
[sys.executable, "-c", LISTENER, str(RADIO)],
|
||
|
|
input=json.dumps({"session_id": "s2", "agent_id": "a1"}),
|
||
|
|
capture_output=True, text=True, env=self._env(None), check=False, timeout=30,
|
||
|
|
)
|
||
|
|
self.assertEqual((listen.returncode, listen.stderr), (0, ""))
|
||
|
|
self.assertFalse(self._pidfile("s2").exists())
|
||
|
|
self.assertFalse((self.root / "news-seen" / "s2").exists())
|
||
|
|
for command in ("hook", "tool"):
|
||
|
|
for stdin in ("not json", "{}", "[]"):
|
||
|
|
with self.subTest(command=command, stdin=stdin):
|
||
|
|
result = self._exec(None, stdin, (command,))
|
||
|
|
self.assertEqual((result.returncode, result.stdout), (0, ""))
|
||
|
|
|
||
|
|
def test_the_first_hook_notifies_the_operator_once(self):
|
||
|
|
log = self.root.parent / "notified.log"
|
||
|
|
script = "import sys; open(sys.argv[1], 'a').write(sys.argv[2] + '\\n')"
|
||
|
|
self.config.write_text(json.dumps({"notify": [sys.executable, "-c", script, str(log)]}), encoding="utf-8")
|
||
|
|
self._send("s1", "send", "mayday", "smoke")
|
||
|
|
self._prompt("s2")
|
||
|
|
self._prompt("s3")
|
||
|
|
self.assertEqual(log.read_text(encoding="utf-8"), "Agents radio MAYDAY by One: smoke\n")
|
||
|
|
|
||
|
|
def test_listener_wakes_on_mayday(self):
|
||
|
|
proc = self._listen("s2")
|
||
|
|
self.assertEqual(
|
||
|
|
self._send("s1", "send", "mayday", "host down"), ["sent mayday to all", "wake: SendMessage to Three"]
|
||
|
|
)
|
||
|
|
code, err = self._finish(proc)
|
||
|
|
self.assertEqual(code, 2)
|
||
|
|
self.assertEqual([strip(line) for line in err.splitlines()], ["MAYDAY One: host down"])
|
||
|
|
self.assertEqual(self._tool("s2"), [])
|
||
|
|
self.assertEqual(len(self._prompt("s2")), 1)
|
||
|
|
|
||
|
|
def test_listener_wakes_only_on_a_direct_wake(self):
|
||
|
|
proc = self._listen("s2")
|
||
|
|
self._send("s1", "post", "board")
|
||
|
|
self._send("s1", "post", "plain", "--to", "Two")
|
||
|
|
self._send("s1", "send", "pan-pan", "pan")
|
||
|
|
self._send("s1", "send", "direct", "elsewhere", "--to", "Three", "--wake")
|
||
|
|
time.sleep(0.5)
|
||
|
|
self.assertIsNone(proc.poll())
|
||
|
|
self.assertEqual(self._send("s1", "send", "direct", "answer me", "--to", "Two", "--wake"), ["sent direct to Two"])
|
||
|
|
code, err = self._finish(proc)
|
||
|
|
self.assertEqual(code, 2)
|
||
|
|
self.assertEqual([strip(line) for line in err.splitlines()], ["PAN-PAN One: pan", "DIRECT One to Two: answer me"])
|
||
|
|
self.assertEqual(self._prompt("s2"), ["BROADCAST One: board", "DIRECT One to Two: plain"])
|
||
|
|
|
||
|
|
def test_listener_yields_to_a_newer_one(self):
|
||
|
|
first = self._listen("s2")
|
||
|
|
second = self._listen("s2")
|
||
|
|
self.assertEqual(self._finish(first), (0, ""))
|
||
|
|
self.assertIsNone(second.poll())
|
||
|
|
self._pidfile("s2").write_text("0", encoding="utf-8")
|
||
|
|
self.assertEqual(self._finish(second), (0, ""))
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|