Adapters in src/glue for all ports of agent/ports.h (store, runtime, shell, ui, mcp, skills, plexer, tui, ops, scheduler db), llm log sink and box, repl::ReplHost over agent::Agent, cap-speak, serve-mcp, telegram and tui-local seams. main.cpp dispatches the flags in tai.py main() order. Tests: tests/glue_persist.py (real model, restart persistence, one-shot shell call), tests/glue_flags.py, tests/glue_ports.cpp. Spec: docs/glue.md, gaps: docs/glue-issues.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
233 lines
11 KiB
Python
233 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""tests/glue_flags.py: the flag dispatch of build/detz that needs no model (docs/glue.md).
|
|
|
|
Same harness as glue_persist.py (long-lived bash pane, throwaway TAI_HOME, tai_test.TestCase). Everything here is
|
|
plumbing: slash commands that dispatch tools through the ports, the long-running modes starting and stopping
|
|
cleanly, and the served MCP endpoint answering. The model-driven proof is in glue_persist.py.
|
|
|
|
GlueSlashCommands /tools /sysinfo /install status /skills /models /agents /schedules /env /profiles /bots /ctx
|
|
GlueSchedulerFlag --scheduler comes online and stops on SIGINT with exit code 0
|
|
GlueTelegramFlag --telegram without a token refuses loudly (exit code != 0)
|
|
GlueServeMcp --serve-mcp KEY answers initialize and tools/list over HTTP, refuses a wrong key
|
|
GlueCapFlags --cap-manifest, --cap help, --demo-shot, argument errors
|
|
GlueCapSpeak --cap-speak with a real model: system, assistant and result frames, vault session
|
|
|
|
Usage: python3 tests/glue_flags.py [--filter NAME]
|
|
"""
|
|
import json
|
|
import os
|
|
import shlex
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE))
|
|
from glue_persist import DETZ, PaneCase, RC_CAPTURE, RC_RE, TAICPP # noqa: E402
|
|
from tai_test import TestAssertionFailed, main_cli # noqa: E402
|
|
|
|
|
|
class GlueSlashCommands(PaneCase):
|
|
name = "GlueSlashCommands"
|
|
timeout = 120.0
|
|
|
|
async def run(self):
|
|
pane = await self.spawn()
|
|
rc, output = await self.pane_run(pane, ["/tools", "/sysinfo", "/install status", "/skills", "/agents", "/schedules", "/env", "/profiles", "/bots", "/ctx", "/tags", "/records nothing", "/quit"])
|
|
return {"rc": rc, "output": output}
|
|
|
|
async def verify(self, result):
|
|
out = result["output"]
|
|
wanted = {
|
|
"/tools": "core, always loaded:",
|
|
"/sysinfo": "cpu:",
|
|
"/install status": "container:",
|
|
"/skills": "blueprints, built on first load:",
|
|
"/agents": "(no agents)",
|
|
"/env": "current environment: home",
|
|
"/profiles": "* default",
|
|
"/bots": "* main",
|
|
"/ctx": "ctx",
|
|
}
|
|
missing = [cmd for cmd, text in wanted.items() if text not in out]
|
|
problems = ["%s: expected %r in the output" % (cmd, wanted[cmd]) for cmd in missing]
|
|
if "unknown command" in out:
|
|
problems.append("a slash command was reported unknown")
|
|
if result["rc"] != 0:
|
|
problems.append("exit code %d" % result["rc"])
|
|
if problems:
|
|
raise TestAssertionFailed("; ".join(problems) + "\n" + out[-1500:])
|
|
|
|
|
|
class GlueSchedulerFlag(PaneCase):
|
|
name = "GlueSchedulerFlag"
|
|
timeout = 60.0
|
|
|
|
async def run(self):
|
|
pane = await self.spawn()
|
|
command = "%s --scheduler & P=$!; sleep 4; kill -INT $P; wait $P; echo __RC=$?" % shlex.quote(DETZ)
|
|
before = len(pane.transcript)
|
|
await pane.send_line(command)
|
|
output = await pane.wait_for_pattern(RC_RE, timeout=40)
|
|
text = "".join(item.text for item in pane.transcript[before:])
|
|
return {"rc": int(RC_CAPTURE.findall(text)[-1]), "output": output}
|
|
|
|
async def verify(self, result):
|
|
problems = []
|
|
if "scheduler online, press Ctrl-C to stop" not in result["output"]:
|
|
problems.append("no online line: %r" % result["output"][-300:])
|
|
if result["rc"] != 0:
|
|
problems.append("exit code %d after SIGINT" % result["rc"])
|
|
if problems:
|
|
raise TestAssertionFailed("; ".join(problems))
|
|
|
|
|
|
class GlueTelegramFlag(PaneCase):
|
|
name = "GlueTelegramFlag"
|
|
timeout = 60.0
|
|
|
|
async def run(self):
|
|
pane = await self.spawn()
|
|
command = "%s --telegram; echo __RC=$?" % shlex.quote(DETZ)
|
|
before = len(pane.transcript)
|
|
await pane.send_line(command)
|
|
output = await pane.wait_for_pattern(RC_RE, timeout=40)
|
|
text = "".join(item.text for item in pane.transcript[before:])
|
|
return {"rc": int(RC_CAPTURE.findall(text)[-1]), "output": output}
|
|
|
|
async def verify(self, result):
|
|
if result["rc"] == 0 or "TELEGRAM_BOT_TOKEN" not in result["output"]:
|
|
raise TestAssertionFailed("--telegram without a token must refuse naming TELEGRAM_BOT_TOKEN: rc=%s output=%r" % (result["rc"], result["output"][-300:]))
|
|
|
|
|
|
class GlueServeMcp(PaneCase):
|
|
name = "GlueServeMcp"
|
|
timeout = 90.0
|
|
port = 18943
|
|
|
|
def call(self, key, body, sid=""):
|
|
headers = {"Content-Type": "application/json", "Authorization": "Bearer " + key, "Accept": "application/json, text/event-stream"}
|
|
if sid:
|
|
headers["Mcp-Session-Id"] = sid
|
|
request = urllib.request.Request("http://127.0.0.1:%d/mcp" % self.port, data=json.dumps(body).encode(), method="POST", headers=headers)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=60) as response:
|
|
return response.status, response.read().decode(), response.headers.get("Mcp-Session-Id", "")
|
|
except urllib.error.HTTPError as exc:
|
|
return exc.code, exc.read().decode(), ""
|
|
|
|
async def run(self):
|
|
pane = await self.spawn()
|
|
await pane.send_line("%s --serve-mcp secretkey --mcp-port %d & echo __SERVER=$!" % (shlex.quote(DETZ), self.port))
|
|
import asyncio
|
|
results = {}
|
|
for _ in range(40):
|
|
await asyncio.sleep(0.5)
|
|
try:
|
|
results["init"] = self.call("secretkey", {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}})
|
|
break
|
|
except OSError:
|
|
continue
|
|
if "init" in results:
|
|
sid = results["init"][2]
|
|
results["wrong"] = self.call("nope", {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, sid)
|
|
results["tools"] = self.call("secretkey", {"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, sid)
|
|
results["call"] = self.call("secretkey", {"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "sysinfo", "arguments": {"checks": ["cpu"]}}}, sid)
|
|
await pane.send_line("kill %1; wait; echo __RC=$?")
|
|
await pane.wait_for_pattern(RC_RE, timeout=30)
|
|
return results
|
|
|
|
async def verify(self, result):
|
|
problems = []
|
|
if "init" not in result:
|
|
raise TestAssertionFailed("--serve-mcp never answered on the port")
|
|
if result["init"][0] != 200 or "serverInfo" not in result["init"][1]:
|
|
problems.append("initialize: %s" % (result["init"],))
|
|
if result["wrong"][0] not in (401, 403):
|
|
problems.append("a wrong key must be refused, got %s" % (result["wrong"],))
|
|
body = result["tools"][1]
|
|
names = [t["name"] for t in json.loads(body[body.index("{"):])["result"]["tools"]] if result["tools"][0] == 200 else []
|
|
if "shell" not in names or len(names) < 61:
|
|
problems.append("tools/list returned %d tools" % len(names))
|
|
if "cpu:" not in result["call"][1]:
|
|
problems.append("tools/call sysinfo through the served agent: %s" % (result["call"][1][:200],))
|
|
if problems:
|
|
raise TestAssertionFailed("; ".join(problems))
|
|
|
|
|
|
class GlueCapSpeak(PaneCase):
|
|
name = "GlueCapSpeak"
|
|
timeout = 300.0
|
|
|
|
async def run(self):
|
|
pane = await self.spawn()
|
|
frame = json.dumps({"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "what is 2+3? answer with just the number"}]}})
|
|
rc, output = await self.pane_run(pane, [frame], flags=("--cap-speak",))
|
|
return {"rc": rc, "output": output}
|
|
|
|
async def verify(self, result):
|
|
frames = []
|
|
for line in result["output"].splitlines():
|
|
if line.startswith("{"):
|
|
try:
|
|
frames.append(json.loads(line))
|
|
except ValueError:
|
|
pass
|
|
types = [f.get("type") for f in frames]
|
|
problems = []
|
|
for needed in ("system", "assistant", "result"):
|
|
if needed not in types:
|
|
problems.append("no %s frame, saw %s" % (needed, types))
|
|
done = [f for f in frames if f.get("type") == "result"]
|
|
if done and "5" not in str(done[-1].get("text", "")):
|
|
problems.append("result frame does not carry the answer: %r" % (done[-1],))
|
|
sessions = self.sessions()
|
|
if len(sessions) != 1 or not any(m["role"] == "user" and "2+3" in (m.get("content") or "") for m in self.load(sessions[0])):
|
|
problems.append("the vault session does not hold the user turn: %s" % sessions)
|
|
if result["rc"] != 0:
|
|
problems.append("exit code %d" % result["rc"])
|
|
if problems:
|
|
raise TestAssertionFailed("; ".join(problems))
|
|
|
|
|
|
class GlueCapFlags(PaneCase):
|
|
name = "GlueCapFlags"
|
|
timeout = 90.0
|
|
|
|
async def run(self):
|
|
pane = await self.spawn()
|
|
outputs = {}
|
|
for key, flags in (("manifest", ["--cap-manifest"]), ("cap", ["--cap"]), ("shot", ["--demo-shot"]), ("env", ["--env", "bogus", "hello"]),
|
|
("profile", ["--profile", "bad name!", "hello"]), ("serve", ["--serve-mcp", ""])):
|
|
command = "%s %s; echo __RC=$?" % (shlex.quote(DETZ), " ".join(shlex.quote(f) for f in flags))
|
|
before = len(pane.transcript)
|
|
await pane.send_line(command)
|
|
outputs[key] = await pane.wait_for_pattern(RC_RE, timeout=40)
|
|
text = "".join(item.text for item in pane.transcript[before:])
|
|
outputs[key + "_rc"] = int(RC_CAPTURE.findall(text)[-1])
|
|
return outputs
|
|
|
|
async def verify(self, result):
|
|
problems = []
|
|
if "[agent]" not in result["manifest"] and "name" not in result["manifest"]:
|
|
problems.append("manifest: %r" % result["manifest"][:100])
|
|
if result["cap_rc"] not in (0, 2):
|
|
problems.append("--cap exit %d" % result["cap_rc"])
|
|
if "tai TUI demo" not in result["shot"]:
|
|
problems.append("--demo-shot: %r" % result["shot"][:100])
|
|
if result["env_rc"] != 1 or "use --env home or --env sandbox" not in result["env"]:
|
|
problems.append("--env bogus: rc=%s %r" % (result["env_rc"], result["env"][-100:]))
|
|
if result["profile_rc"] != 1 or "invalid profile name" not in result["profile"]:
|
|
problems.append("bad profile: rc=%s %r" % (result["profile_rc"], result["profile"][-100:]))
|
|
if result["serve_rc"] != 2 or "--serve-mcp needs a KEY" not in result["serve"]:
|
|
problems.append("empty serve key: rc=%s %r" % (result["serve_rc"], result["serve"][-100:]))
|
|
if problems:
|
|
raise TestAssertionFailed("; ".join(problems))
|
|
|
|
|
|
ALL_CASES = [GlueSlashCommands(), GlueSchedulerFlag(), GlueTelegramFlag(), GlueServeMcp(), GlueCapFlags(), GlueCapSpeak()]
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main_cli(ALL_CASES, default_log_dir=str(TAICPP / "build" / "test-logs")))
|