merge: ae8675943507c4843
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
// capmcp_cli: a thin test driver around the CAP and MCP classes. It stands in
|
||||
// for main.cpp (which the integrator owns) so the same argv can be run through
|
||||
// tai.py and through the C++ classes and the outputs diffed.
|
||||
//
|
||||
// capmcp_cli --cap-manifest
|
||||
// capmcp_cli --cap COMMAND ...
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "cap_agent_manifest.h"
|
||||
#include "cap_cli.h"
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
std::vector<std::string> args(argv + 1, argv + argc);
|
||||
if (args.empty()) {
|
||||
std::fputs("usage: capmcp_cli --cap-manifest | --cap COMMAND ...\n", stderr);
|
||||
return 2;
|
||||
}
|
||||
if (args[0] == "--cap-manifest") {
|
||||
// print(TAI_CAP_MANIFEST)
|
||||
std::fputs((detz::cap::CapAgentManifest::TAI_CAP_MANIFEST() + "\n").c_str(), stdout);
|
||||
return 0;
|
||||
}
|
||||
if (args[0] == "--cap") {
|
||||
return detz::cap::CapCli::capCliMain(std::vector<std::string>(args.begin() + 1, args.end()));
|
||||
}
|
||||
std::fputs("unknown mode\n", stderr);
|
||||
return 2;
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runs the same `--cap-manifest` / `--cap ...` argv through tai.py and through
|
||||
the C++ test driver (build/capmcp/capmcp_cli) and diffs stdout, stderr and the
|
||||
exit code. Every case runs in a throwaway HOME / TAI_HOME / cwd.
|
||||
|
||||
python3 -I tests/capmcp_cli_diff.py CPP_DRIVER [--tai PATH_TO_TAI_PY] [--filter TEXT] [--verbose]
|
||||
|
||||
Exit 0 only when all cases are byte-identical."""
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import threading
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
TAI = os.path.join(os.path.dirname(os.path.dirname(HERE)), "tai.py")
|
||||
|
||||
FAKE_STREAM_AGENT = r'''
|
||||
import json, sys
|
||||
print(json.dumps({"type": "system", "subtype": "init", "session_id": "fake-1"}), flush=True)
|
||||
n = 0
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
frame = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
n += 1
|
||||
text = frame.get("message")
|
||||
print(json.dumps({"type": "assistant", "message": {"id": "m%d" % n, "role": "assistant", "content": [
|
||||
{"type": "text", "text": "echo %d: %s" % (n, text)},
|
||||
{"type": "tool_use", "id": "t%d" % n, "name": "Read", "input": {"path": "x"}}]}}), flush=True)
|
||||
print(json.dumps({"type": "user", "message": {"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t%d" % n, "content": "ok", "is_error": False}]}}), flush=True)
|
||||
print(json.dumps({"type": "result", "subtype": "success", "usage": {"input_tokens": 3, "output_tokens": 5}, "duration_ms": 7, "total_cost_usd": 0.5}), flush=True)
|
||||
'''
|
||||
|
||||
FAKE_ACP_AGENT = r'''
|
||||
import json, sys
|
||||
def send(obj):
|
||||
print(json.dumps(obj), flush=True)
|
||||
for line in sys.stdin:
|
||||
msg = json.loads(line)
|
||||
if "method" in msg and "id" in msg and msg["method"] == "initialize":
|
||||
send({"jsonrpc": "2.0", "id": msg["id"], "result": {"protocolVersion": 1, "agentCapabilities": {}}})
|
||||
elif msg.get("method") == "session/new":
|
||||
send({"jsonrpc": "2.0", "id": msg["id"], "result": {"sessionId": "s1"}})
|
||||
elif msg.get("method") == "session/prompt":
|
||||
text = msg["params"]["prompt"][0]["text"]
|
||||
send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s1", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": "acp says " + text}}}})
|
||||
send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s1", "update": {"sessionUpdate": "tool_call", "toolCallId": "c1", "title": "Read file"}}})
|
||||
send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s1", "update": {"sessionUpdate": "tool_call_update", "toolCallId": "c1", "status": "completed", "content": [{"type": "text", "text": "done"}]}}})
|
||||
send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "s1", "update": {"sessionUpdate": "usage_update", "usage": {"inputTokens": 10, "outputTokens": 4}}}})
|
||||
send({"jsonrpc": "2.0", "id": msg["id"], "result": {"stopReason": "end_turn"}})
|
||||
'''
|
||||
|
||||
FAKE_PTY_AGENT = r'''
|
||||
import sys
|
||||
sys.stdout.write("fake agent ready\nfake> ")
|
||||
sys.stdout.flush()
|
||||
for line in sys.stdin:
|
||||
text = line.strip()
|
||||
sys.stdout.write("you said: %s\n" % text)
|
||||
sys.stdout.write("fake> ")
|
||||
sys.stdout.flush()
|
||||
'''
|
||||
|
||||
MANIFEST_STREAM = '''[agent]
|
||||
name = "fake"
|
||||
binary = "fake"
|
||||
profiles = ["chat"]
|
||||
[startup]
|
||||
command = ["python3", "-c", "pass"]
|
||||
ready_when = { pattern = "ready" }
|
||||
[fast_path]
|
||||
stream_json = ["python3", "{agent}"]
|
||||
[pty]
|
||||
cols = 80
|
||||
rows = 24
|
||||
[capabilities]
|
||||
streaming_output = true
|
||||
'''
|
||||
|
||||
MANIFEST_ACP = '''[agent]
|
||||
name = "acpfake"
|
||||
binary = "acpfake"
|
||||
profiles = ["chat"]
|
||||
[startup]
|
||||
command = ["python3", "-c", "pass"]
|
||||
ready_when = { pattern = "ready" }
|
||||
[fast_path]
|
||||
acp_stdio = true
|
||||
[pty]
|
||||
cols = 80
|
||||
rows = 24
|
||||
[capabilities]
|
||||
streaming_output = true
|
||||
'''
|
||||
|
||||
MANIFEST_PTY = '''[agent]
|
||||
name = "ptyfake"
|
||||
binary = "ptyfake"
|
||||
profiles = ["chat"]
|
||||
[startup]
|
||||
command = ["python3", "{agent}"]
|
||||
ready_when = { pattern = "fake agent ready" }
|
||||
init_timeout_seconds = 20
|
||||
[pty]
|
||||
cols = 100
|
||||
rows = 30
|
||||
[parse]
|
||||
idle = ["^fake> $"]
|
||||
[capabilities]
|
||||
streaming_output = true
|
||||
'''
|
||||
|
||||
MANIFEST_A2A = '''[agent]
|
||||
name = "a2afake"
|
||||
binary = "a2afake"
|
||||
profiles = ["chat"]
|
||||
[startup]
|
||||
command = ["python3", "-c", "pass"]
|
||||
ready_when = { pattern = "ready" }
|
||||
[fast_path]
|
||||
a2a_serve_at = "http://127.0.0.1:{port}"
|
||||
[pty]
|
||||
cols = 80
|
||||
rows = 24
|
||||
[capabilities]
|
||||
streaming_output = true
|
||||
'''
|
||||
|
||||
FLEET_TOML = '''[fleet]
|
||||
start = "cap://a"
|
||||
task = "begin"
|
||||
budget_usd = 100
|
||||
|
||||
[[sessions]]
|
||||
name = "a"
|
||||
agent = "fake"
|
||||
|
||||
[[sessions]]
|
||||
name = "b"
|
||||
agent = "fake"
|
||||
|
||||
[[routes]]
|
||||
when = "cap://a.done"
|
||||
route_to = "cap://b"
|
||||
'''
|
||||
|
||||
|
||||
class A2aHandler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/.well-known/agent-card.json":
|
||||
body = json.dumps({"name": "a2a-card", "extensions": [{"uri": "cap-protocol/v1"}]}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
raw = self.rfile.read(length)
|
||||
if self.path != "/message/send":
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
data = json.loads(raw)
|
||||
text = data["message"]["parts"][0]["text"]
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.end_headers()
|
||||
frames = [
|
||||
{"task": {"id": "t1", "contextId": "c1", "status": {"state": "working"}}},
|
||||
{"message": {"messageId": "mm1", "parts": [{"kind": "text", "text": "a2a heard " + text}]}},
|
||||
{"task": {"id": "t1", "contextId": "c1", "status": {"state": "completed"}}},
|
||||
]
|
||||
for frame in frames:
|
||||
self.wfile.write(("data: " + json.dumps(frame) + "\n\n").encode())
|
||||
self.wfile.flush()
|
||||
|
||||
|
||||
def normalize(text, root):
|
||||
text = text.replace(root, "<ROOT>")
|
||||
text = re.sub(r"cap_[0-9a-f]{12}", "cap_<ID>", text)
|
||||
return text
|
||||
|
||||
|
||||
def run(argv, env, cwd, timeout=60, stdin=None):
|
||||
done = subprocess.run(argv, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout,
|
||||
stdin=subprocess.DEVNULL if stdin is None else None, input=stdin)
|
||||
return done.returncode, done.stdout.decode("utf-8", "replace"), done.stderr.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
if not args:
|
||||
print(__doc__)
|
||||
return 2
|
||||
cpp = os.path.abspath(args[0])
|
||||
tai = TAI
|
||||
if "--tai" in args:
|
||||
tai = args[args.index("--tai") + 1]
|
||||
only = args[args.index("--filter") + 1] if "--filter" in args else None
|
||||
root = tempfile.mkdtemp(prefix="capmcp-cli-")
|
||||
server = http.server.HTTPServer(("127.0.0.1", 0), A2aHandler)
|
||||
port = server.server_address[1]
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
try:
|
||||
home = os.path.join(root, "home")
|
||||
agents = os.path.join(home, ".config", "cap", "agents")
|
||||
os.makedirs(agents)
|
||||
work = os.path.join(root, "work")
|
||||
os.makedirs(work)
|
||||
files = {
|
||||
"fake_stream.py": FAKE_STREAM_AGENT, "fake_acp.py": FAKE_ACP_AGENT, "fake_pty.py": FAKE_PTY_AGENT,
|
||||
}
|
||||
for name, text in files.items():
|
||||
with open(os.path.join(root, name), "w") as handle:
|
||||
handle.write(text)
|
||||
manifests = {
|
||||
"fake.toml": MANIFEST_STREAM.replace("{agent}", os.path.join(root, "fake_stream.py")),
|
||||
"acpfake.toml": MANIFEST_ACP,
|
||||
"ptyfake.toml": MANIFEST_PTY.replace("{agent}", os.path.join(root, "fake_pty.py")),
|
||||
"a2afake.toml": MANIFEST_A2A.replace("{port}", str(port)),
|
||||
"broken.toml": "[agent]\nname = \"broken\"\n",
|
||||
"wrongname.toml": MANIFEST_STREAM.replace('name = "fake"', 'name = "other"').replace("{agent}", "x"),
|
||||
"badre.toml": MANIFEST_STREAM.replace('name = "fake"', 'name = "badre"').replace("{agent}", "x") + "\n[parse]\nidle = [\"(?<=a)b\"]\n",
|
||||
"notoml.toml": "this is not toml = = =\n",
|
||||
}
|
||||
for name, text in manifests.items():
|
||||
with open(os.path.join(agents, name), "w") as handle:
|
||||
handle.write(text)
|
||||
fleet = os.path.join(root, "fleet.toml")
|
||||
with open(fleet, "w") as handle:
|
||||
handle.write(FLEET_TOML)
|
||||
badfleet = os.path.join(root, "badfleet.toml")
|
||||
with open(badfleet, "w") as handle:
|
||||
handle.write("[fleet]\nstart = \"cap://zzz\"\n[[sessions]]\nname = \"a\"\nagent = \"fake\"\n")
|
||||
nofleet = os.path.join(root, "nofleet.toml")
|
||||
with open(nofleet, "w") as handle:
|
||||
handle.write("[fleet]\n")
|
||||
garbage = os.path.join(root, "garbage.toml")
|
||||
with open(garbage, "w") as handle:
|
||||
handle.write("a = = 1")
|
||||
env = dict(os.environ, HOME=home, TAI_HOME=os.path.join(root, "taihome"), COLUMNS="80", PYTHONDONTWRITEBYTECODE="1")
|
||||
env.pop("TAI_PASSPHRASE", None)
|
||||
cases = [
|
||||
["--cap"], ["--cap", "help"], ["--cap", "-h"], ["--cap", "--help"], ["--cap", "bogus"], ["--cap", "resolve"],
|
||||
["--cap", "resolve", "-h"], ["--cap", "resolve", "nosuchagent"], ["--cap", "resolve", "fake"],
|
||||
["--cap", "resolve", "acpfake"], ["--cap", "resolve", "ptyfake"], ["--cap", "resolve", "a2afake"],
|
||||
["--cap", "resolve", "broken"], ["--cap", "resolve", "wrongname"], ["--cap", "resolve", "badre"],
|
||||
["--cap", "resolve", "notoml"], ["--cap", "resolve", "x", "--binary"], ["--cap", "resolve", "x", "y"],
|
||||
["--cap", "chat"], ["--cap", "chat", "-h"], ["--cap", "chat", "--driver", "fake", "--max-risk", "bad"],
|
||||
["--cap", "chat", "--driver", "nosuch"], ["--cap", "chat", "--driver", "fake", "--a", "x"],
|
||||
["--cap", "run"], ["--cap", "run", "-h"], ["--cap", "run", os.path.join(root, "missing.toml")],
|
||||
["--cap", "run", garbage], ["--cap", "run", nofleet], ["--cap", "run", badfleet],
|
||||
["--cap", "run", fleet, "--task", " "],
|
||||
["--cap", "chat", "--driver", "fake", "--task", "hello world"],
|
||||
["--cap", "chat", "--driver", "ptyfake", "--task", "hello pty"],
|
||||
["--cap", "chat", "--driver", "acpfake", "--argv", "python3 %s" % os.path.join(root, "fake_acp.py"), "--task", "hi acp"],
|
||||
["--cap", "chat", "--driver", "acpfake", "--task", "no argv"],
|
||||
["--cap", "chat", "--driver", "acpfake", "--argv", "python3 'unterminated", "--task", "x"],
|
||||
["--cap", "chat", "--driver", "a2afake", "--task", "hi a2a"],
|
||||
["--cap", "run", fleet],
|
||||
["--cap", "run", fleet, "--task", "override task"],
|
||||
]
|
||||
for columns in ("40", "60", "100", "132"):
|
||||
for sub in ("resolve", "chat", "run"):
|
||||
cases.append(("COLUMNS=" + columns, ["--cap", sub, "-h"]))
|
||||
cases.append(("COLUMNS=" + columns, ["--cap", sub, "--bogus-flag"]))
|
||||
failures = 0
|
||||
total = 0
|
||||
for case in cases:
|
||||
extra = {}
|
||||
if isinstance(case, tuple):
|
||||
key, value = case[0].split("=", 1)
|
||||
extra[key] = value
|
||||
case = case[1]
|
||||
if only and only not in " ".join(case):
|
||||
continue
|
||||
total += 1
|
||||
cenv = dict(env, **extra)
|
||||
want = run([sys.executable, "-I", tai] + case, cenv, work)
|
||||
got = run([cpp] + case, cenv, work)
|
||||
want = (want[0], normalize(want[1], root), normalize(want[2], root))
|
||||
got = (got[0], normalize(got[1], root), normalize(got[2], root))
|
||||
if "--verbose" in args:
|
||||
print("CASE %s -> rc %s stdout %r stderr %r" % (" ".join(case).replace(root, "<ROOT>"), want[0], want[1][:200], want[2][:160]))
|
||||
if want != got:
|
||||
failures += 1
|
||||
print("MISMATCH %s" % " ".join(case).replace(root, "<ROOT>"))
|
||||
for label, a, b in (("rc", want[0], got[0]), ("stdout", want[1], got[1]), ("stderr", want[2], got[2])):
|
||||
if a != b:
|
||||
print(" %s py : %r" % (label, a if label == "rc" else a[:600]))
|
||||
print(" %s cpp: %r" % (label, b if label == "rc" else b[:600]))
|
||||
# worktree dirs created by coding profiles must not leak between cases
|
||||
shutil.rmtree(os.path.join(work, ".cap-worktrees"), ignore_errors=True)
|
||||
want = run([sys.executable, "-I", tai, "--cap-manifest"], env, work)
|
||||
got = run([cpp, "--cap-manifest"], env, work)
|
||||
total += 1
|
||||
if want != got:
|
||||
failures += 1
|
||||
print("MISMATCH --cap-manifest")
|
||||
print("%d cases, %d mismatches" % (total, failures))
|
||||
return 1 if failures else 0
|
||||
finally:
|
||||
server.shutdown()
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Differential test of the C++ wire layer (JSON, TOML, str(), float repr,
|
||||
regex) against CPython. Builds nothing: pass the compiled probe.
|
||||
|
||||
python3 -I tests/capmcp_diff.py PROBE_BINARY [--seed N] [--fuzz N]
|
||||
|
||||
Exit 0 only when every case prints the same line in both programs."""
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import tomllib
|
||||
|
||||
JSON_CASES = [
|
||||
"", " ", "x", "{", "{\"a\"", "{\"a\":", "{\"a\":1", "{\"a\":1,", "{\"a\":1,}", "[1,]", "[1", "[", "[1 2]",
|
||||
"{\"a\" 1}", "{a:1}", "{'a':1}", "\"abc", "\"a\\x\"", "\"a\\u12\"", "\"a\nb\"", "01", "-", "-x", "1.", "1e",
|
||||
"1.5e3", "nul", "tru", "NaN", "-Infinity", "Infinity", "{\"a\":1} x", "[] []", "\"\\ud83d\\ude00\"",
|
||||
"123456789012345678901234567890", " {\"a\": [1, 2.5, \"x\", null, true, false]} ", "{\"a\":1,\"a\":2}",
|
||||
"\"\\u00e9\"", "[1,\n2,\n x]", "\ufeff{}", "{\"a\":1 \"b\":2}", "{,}", "[,1]", "{\"a\":}", "\"\\", "\"\\u",
|
||||
"\"\\u12", "{\"a\":1,,}", "tru e", "-0", "-0.0", "1E5", "1e400", "-1e400", "\"é\"", "\"日本語\"", "[\"😀\"]",
|
||||
"{\"b\":1,\"a\":{\"z\":[],\"y\":{}}}", "0.1", "1e-7", "1e22", "123456789012345680000.0", "5e-324", "1.7976931348623157e308",
|
||||
"9223372036854775807", "9223372036854775808", "-9223372036854775808", "-9223372036854775809",
|
||||
"\"\\/\\b\\f\\n\\r\\t\\\"\\\\\"", "\"\x7f\"", "\"\\u007f\"", "\"\\u2028\"", "{\"a\":\n\n [\n1\n,\n2] }",
|
||||
]
|
||||
|
||||
TOML_CASES = [
|
||||
"", "a = 1", "a = 1\nb = 2.5\nc = \"x\"\nd = true\ne = [1, 2, 3]\nf = { g = 1, h = \"i\" }",
|
||||
"[t]\na = 1\n[t.u]\nb = 2", "[[s]]\nname = \"a\"\n[[s]]\nname = \"b\"",
|
||||
"a = ", "a", "= 1", "a = 1 b = 2", "a = 1\na = 2", "[t]\n[t]", "a = [1, 2", "a = {x = 1", "a = \"abc",
|
||||
"a = 'abc'\nb = '''multi\nline'''\nc = \"\"\"x\ny\"\"\"", "a = \"\"\"line \\\n continued\"\"\"",
|
||||
"a = 0x1F\nb = 0o17\nc = 0b101\nd = 1_000\ne = +5\nf = -7", "a = 1e3\nb = 1.5e-3\nc = inf\nd = -inf\ne = nan",
|
||||
"a = 01", "a = 1__0", "a = 0x", "a = .5", "a = 5.", "a = 1979-05-27", "a = 1979-05-27T07:32:00Z",
|
||||
"a = 1979-05-27 07:32:00.5-08:00", "a = 07:32:00", "a = 1979-02-30", "a = 2024-02-29",
|
||||
"a.b.c = 1\na.b.d = 2", "a.b = 1\n[a]\nc = 2", "[a]\nb = 1\n[a.b]", "[a.b]\nc = 1\n[a]\nd = 2",
|
||||
"a = [[1], [2, 3], []]", "a = [\n 1, # one\n 2,\n]\n", "a = { b = 1, }", "a = [ ,]",
|
||||
"# comment only\n", "a = 1 # trailing\n", "a = \"x\" y", "\"quoted key\" = 1\n'lit key' = 2",
|
||||
"a = \"\\u00e9\\U0001F600\"", "a = \"\\x\"", "a = \"\\ud800\"", "a = \"tab\there\"", "a = \"nl\nx\"",
|
||||
"[ a . b ]\nc = 1", "[a]\nb = 1\n[[a.c]]\nd = 1\n[[a.c]]\nd = 2", "x = {a.b = 1, a.c = 2}",
|
||||
"x = {a = 1}\n[x.b]", "a = [1, \"two\", 3.0]", "[a]\n[a.b]\n[a]", "a = 1\n[a]", "[a]\nb = 1\n[a]\nc = 2",
|
||||
"\r\na = 1\r\nb = 2\r\n", "a = 1\rb = 2", "a = \"\"\"\"x\"\"\"\"", "a = '''x'''''", "a=\"\"\"\\ \n x\"\"\"",
|
||||
"a = tru", "a = True", "[]", "[a", "[[a]", "[[a]]\n[a]", "[a]\n[[a]]", "a = [\n", "key = 'unterminated",
|
||||
"bad key = 1", "a = \"\x01\"", "# \x01", "a = 1_000.000_1", "a = 1e+3_0", "a = 0e0", "a = -0.0",
|
||||
"a = 123456789012345678", "a = 9223372036854775807", "a = 9223372036854775808",
|
||||
"[agent]\nname = \"tai\"\nprofiles = [\"coding\"]\n[probe]\ncommand = [\"tai.py\", \"--version\"]\nversion_pattern = \"tai (\\\\d+)\"",
|
||||
]
|
||||
|
||||
REGEX_CASES = [
|
||||
("^tai\\[.*\\]› $", "tai[main]› "), ("^tai\\[.*\\]› $", "tai[main]›"), ("^>\\s*$", "> "), ("^❯\\s*$", "❯"),
|
||||
("(?P<tool>\\w+)\\((?P<args>.*)\\)", "run(ls -l)"), ("(?P<status>ok|failed)?x", "x"),
|
||||
("github\\.com/([^/\\s?#]+)/([^/\\s?#]+?)(?:\\.git)?(?:[/?#]|$)", "https://github.com/o/r.git"),
|
||||
("github\\.com/([^/\\s?#]+)/([^/\\s?#]+?)(?:\\.git)?(?:[/?#]|$)", "https://github.com/o/r/tree/x"),
|
||||
("(npx|uvx)\\s+([^\\s\"'`]+)", "run: npx -y pkg-name now"), ("@(cap://[A-Za-z0-9_.-]+)", "hi @cap://a-1 and @cap://b"),
|
||||
("\\$\\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\\}", "x ${FOO:-bar} y"),
|
||||
("\\$\\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\\}", "x ${FOO} y"),
|
||||
("resource_metadata=\"([^\"]+)\"", "Bearer resource_metadata=\"https://x/y\", realm=x"),
|
||||
("\\bno\\b", "say no way"), ("\\byes\\b", "yes."), ("seal error:", "seal error: boom"),
|
||||
("^seal error:", "x\nseal error:"), ("end$", "the end\n"), ("end$", "the end\nmore"), ("a{2}", "caab aab"),
|
||||
("a{,2}b", "aab"), ("x{", "x{"), ("}", "a}"), ("[]a]+", "]]a"), ("[^]a]+", "bc]"), ("\\d+\\.\\d+", "v 1.44.2"),
|
||||
("tai (\\d+\\.\\d+\\.\\d+)", "tai 1.44.2"), ("(a)|(b)", "b"), ("é+", "ééé"), ("\\w+", "日本語 x"),
|
||||
("(?:ab)+", "ababab"), ("a(?=b)", "ab"), ("a(?!b)", "ac"), ("\\Aab", "ab"), ("ab\\Z", "ab"),
|
||||
]
|
||||
|
||||
REPR_CASES = [
|
||||
"{\"a\":\"it's\",\"b\":[1,null,true,1.5,\"x\\n\"]}", "[\"a\\\"b'c\"]", "[\"\\u00e9\\u2028\\u00a0\\u0007\"]",
|
||||
"{}", "[]", "[1e22, 1e-7, 0.1, 100.0, 1e16, 123456.789]", "\"plain\"", "null", "[true,false]", "{\"k\":{\"n\":[{}]}}",
|
||||
"\"日本語\"", "\"\\ud83d\\ude00\"", "[NaN, Infinity, -Infinity]", "\"\\\\\"",
|
||||
]
|
||||
|
||||
FLOAT_CASES = ["0", "1", "0.1", "1e22", "1e-7", "100", "1e16", "1e15", "123456789.123456789", "5e-324", "1.7976931348623157e308",
|
||||
"0.0001", "0.00001", "12345678901234567890", "-2.5", "3.14159", "1e300", "2.5e-5", "0.30000000000000004"]
|
||||
|
||||
|
||||
def write_cases(path, cases):
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(b"\0".join(c.encode("utf-8", "surrogatepass") for c in cases))
|
||||
|
||||
|
||||
def py_json(i, text):
|
||||
try:
|
||||
return "%d OK %s" % (i, json.dumps(json.loads(text)))
|
||||
except ValueError as exc:
|
||||
return "%d ERR %s" % (i, exc)
|
||||
|
||||
|
||||
def py_toml(i, text):
|
||||
try:
|
||||
return "%d OK %s" % (i, json.dumps(tomllib.loads(text), default=str))
|
||||
except ValueError as exc:
|
||||
return "%d ERR %s" % (i, exc)
|
||||
|
||||
|
||||
def py_repr(i, text):
|
||||
try:
|
||||
return "%d OK %s" % (i, repr(json.loads(text)))
|
||||
except ValueError as exc:
|
||||
return "%d ERR %s" % (i, exc)
|
||||
|
||||
|
||||
def py_float(i, text):
|
||||
return "%d OK %s" % (i, repr(float(text)))
|
||||
|
||||
|
||||
def py_regex(i, text):
|
||||
pattern, subject = text.split("\x01", 1)
|
||||
try:
|
||||
found = re.search(pattern, subject)
|
||||
except re.error as exc:
|
||||
return "%d ERR %s" % (i, exc)
|
||||
if not found:
|
||||
return "%d NONE" % i
|
||||
groups = [found.group(0)] + list(found.groups())
|
||||
return "%d OK %d %d %s %s" % (i, found.start(), found.end(), json.dumps(groups), json.dumps(found.groupdict()))
|
||||
|
||||
|
||||
def fuzz_json(rng, count):
|
||||
out = []
|
||||
atoms = ["null", "true", "false", "0", "-1", "12", "3.5", "-2.5e3", "\"s\"", "\"\\u00e9\"", "\"a b\"", "[]", "{}", "NaN", "\"\\n\""]
|
||||
for _ in range(count):
|
||||
depth = rng.randint(0, 3)
|
||||
|
||||
def build(d):
|
||||
if d == 0:
|
||||
return rng.choice(atoms)
|
||||
if rng.random() < 0.5:
|
||||
return "[" + ", ".join(build(d - 1) for _ in range(rng.randint(0, 3))) + "]"
|
||||
return "{" + ", ".join("\"k%d\": %s" % (rng.randint(0, 5), build(d - 1)) for _ in range(rng.randint(0, 3))) + "}"
|
||||
|
||||
text = build(depth)
|
||||
out.append(text)
|
||||
if text:
|
||||
cut = rng.randint(0, len(text))
|
||||
out.append(text[:cut])
|
||||
pos = rng.randint(0, len(text) - 1) if len(text) > 1 else 0
|
||||
out.append(text[:pos] + rng.choice([",", "]", "}", "\"", "\\", " ", "x", ":"]) + text[pos + 1:])
|
||||
return out
|
||||
|
||||
|
||||
def run(probe, mode, cases, pyfn, label):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "cases.bin")
|
||||
write_cases(path, cases)
|
||||
done = subprocess.run([probe, mode, path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
||||
got = done.stdout.decode("utf-8", "replace").splitlines()
|
||||
want = [pyfn(i, c) for i, c in enumerate(cases)]
|
||||
bad = 0
|
||||
for i, (g, w) in enumerate(zip(got, want)):
|
||||
if g != w:
|
||||
bad += 1
|
||||
if bad <= 8:
|
||||
print("MISMATCH %s case %d %r\n cpp: %s\n py : %s" % (label, i, cases[i][:120], g[:300], w[:300]))
|
||||
if len(got) != len(want):
|
||||
bad += 1
|
||||
print("MISMATCH %s: %d lines from probe, %d cases (stderr: %s)" % (label, len(got), len(want), done.stderr.decode()[:300]))
|
||||
print("%-8s %4d cases, %d mismatches" % (label, len(cases), bad))
|
||||
return bad
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
if not args:
|
||||
print(__doc__)
|
||||
return 2
|
||||
probe = args[0]
|
||||
seed = int(args[args.index("--seed") + 1]) if "--seed" in args else 7
|
||||
fuzz = int(args[args.index("--fuzz") + 1]) if "--fuzz" in args else 300
|
||||
rng = random.Random(seed)
|
||||
bad = 0
|
||||
jcases = JSON_CASES + fuzz_json(rng, fuzz)
|
||||
bad += run(probe, "json", jcases, py_json, "json")
|
||||
bad += run(probe, "repr", REPR_CASES, py_repr, "repr")
|
||||
bad += run(probe, "float", FLOAT_CASES, py_float, "float")
|
||||
bad += run(probe, "toml", TOML_CASES, py_toml, "toml")
|
||||
bad += run(probe, "regex", ["%s\x01%s" % c for c in REGEX_CASES], py_regex, "regex")
|
||||
print("TOTAL mismatches: %d" % bad)
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,72 @@
|
||||
// capmcp_probe: differential probe for the wire layer. Reads NUL separated
|
||||
// cases from a file and prints one result line per case; tests/capmcp_diff.py
|
||||
// runs the same cases through CPython and diffs the two outputs.
|
||||
//
|
||||
// capmcp_probe json CASES json.loads + json.dumps
|
||||
// capmcp_probe toml CASES tomllib.loads + json.dumps
|
||||
// capmcp_probe repr CASES json.loads + str() of the value
|
||||
// capmcp_probe regex CASES PATTERN\x01TEXT -> re.search groups
|
||||
// capmcp_probe float CASES text -> repr(float(text))
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <iterator>
|
||||
#include <sstream>
|
||||
|
||||
#include "wire_json.h"
|
||||
#include "wire_regex.h"
|
||||
#include "wire_text.h"
|
||||
#include "wire_toml.h"
|
||||
|
||||
using detz::wire::Json;
|
||||
using detz::wire::WireText;
|
||||
|
||||
static std::vector<std::string> loadCases(const char* path) {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
std::string all((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||||
std::vector<std::string> out;
|
||||
size_t pos = 0;
|
||||
while (pos <= all.size()) {
|
||||
size_t at = all.find('\0', pos);
|
||||
if (at == std::string::npos) {
|
||||
if (pos < all.size()) out.push_back(all.substr(pos));
|
||||
break;
|
||||
}
|
||||
out.push_back(all.substr(pos, at - pos));
|
||||
pos = at + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 3) return 2;
|
||||
const std::string mode = argv[1];
|
||||
const auto cases = loadCases(argv[2]);
|
||||
for (size_t i = 0; i < cases.size(); ++i) {
|
||||
try {
|
||||
if (mode == "json") {
|
||||
std::printf("%zu OK %s\n", i, Json::parse(cases[i]).dumps().c_str());
|
||||
} else if (mode == "toml") {
|
||||
std::printf("%zu OK %s\n", i, detz::wire::WireToml::loads(cases[i]).dumps().c_str());
|
||||
} else if (mode == "repr") {
|
||||
std::printf("%zu OK %s\n", i, Json::parse(cases[i]).pyRepr().c_str());
|
||||
} else if (mode == "float") {
|
||||
std::printf("%zu OK %s\n", i, WireText::reprFloat(std::strtod(cases[i].c_str(), nullptr)).c_str());
|
||||
} else if (mode == "regex") {
|
||||
const size_t sep = cases[i].find('\x01');
|
||||
detz::wire::WireRegex re(cases[i].substr(0, sep));
|
||||
auto m = re.search(cases[i].substr(sep + 1));
|
||||
if (!m) {
|
||||
std::printf("%zu NONE\n", i);
|
||||
} else {
|
||||
Json groups = Json::array();
|
||||
for (size_t g = 0; g <= m.groupCount(); ++g) groups.push(m.hasGroup(g) ? Json(m.group(g)) : Json());
|
||||
std::printf("%zu OK %zu %zu %s %s\n", i, m.start(), m.end(), groups.dumps().c_str(), m.groupdict().dumps().c_str());
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& exc) {
|
||||
std::printf("%zu ERR %s\n", i, exc.what());
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// capmcp_unit: table checks, drift checks against ../cap.md and ../mcp.md,
|
||||
// the MCP vault tables, the normalizer and the served endpoint (in process).
|
||||
// Run from taicpp/: build/capmcp/capmcp_unit
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <sstream>
|
||||
|
||||
#include "cap_agent_manifest.h"
|
||||
#include "cap_binding.h"
|
||||
#include "cap_constants.h"
|
||||
#include "cap_error.h"
|
||||
#include "cap_event.h"
|
||||
#include "cap_manifest.h"
|
||||
#include "cap_stream_json.h"
|
||||
#include "mcp_constants.h"
|
||||
#include "mcp_error.h"
|
||||
#include "mcp_normalizer.h"
|
||||
#include "mcp_serve_handler.h"
|
||||
#include "mcp_sqlite_store.h"
|
||||
|
||||
using namespace detz;
|
||||
using detz::wire::Json;
|
||||
|
||||
static int failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \
|
||||
++failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static std::string slurp(const char* path) {
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
return std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
class FakeAgent : public mcp::McpServeAgent {
|
||||
public:
|
||||
std::string dispatch(const std::string& name, const Json& args) override {
|
||||
if (name == "echo") return args.dumps();
|
||||
if (name == "deny") throw mcp::McpToolDenied();
|
||||
return "error: unknown tool " + name;
|
||||
}
|
||||
std::string runTurn(const std::string& message, int steps, double) override {
|
||||
return "reply:" + message + ":" + std::to_string(steps);
|
||||
}
|
||||
};
|
||||
|
||||
class FakeBackend : public mcp::McpServeBackend {
|
||||
public:
|
||||
std::vector<Json> toolSchemas() override {
|
||||
return {Json::object({{"type", Json("function")},
|
||||
{"function", Json::object({{"name", Json("echo")}, {"description", Json("d")},
|
||||
{"parameters", Json::object({{"type", Json("object")}})}})}})};
|
||||
}
|
||||
std::shared_ptr<mcp::McpServeAgent> createAgent() override { return std::make_shared<FakeAgent>(); }
|
||||
int maxSteps() override { return 25; }
|
||||
std::string complete(const std::vector<Json>&) override { return "done"; }
|
||||
};
|
||||
|
||||
class TestStore : public mcp::McpSqliteStore {
|
||||
public:
|
||||
using McpSqliteStore::McpSqliteStore;
|
||||
std::optional<std::string> loadSecret(const std::string&, const std::string&) override { return std::nullopt; }
|
||||
void saveSecret(const std::string&, const std::string&, const Json&, const std::string&) override {}
|
||||
void deleteSecret(const std::string&, const std::string&) override {}
|
||||
Json secretMeta(const std::string&, const std::string&) override { return Json::object(); }
|
||||
std::string spillRecord(const std::string&, const std::string&, const std::string&) override { return "mem:1"; }
|
||||
void auditEvent(const std::string&, const std::string&, const std::string&, const std::string&,
|
||||
const std::string&) override {}
|
||||
};
|
||||
|
||||
int main() {
|
||||
// --- CAP tables
|
||||
CHECK(cap::CAP_ERROR_CODES.size() == 15);
|
||||
CHECK(cap::CapError("cap_budget_exceeded", "x").numeric == -32004);
|
||||
CHECK(cap::CapError("cap_custom").numeric == 0);
|
||||
CHECK(std::string(cap::CapError("cap_custom").what()) == "cap_custom");
|
||||
CHECK(cap::CapEvent::KINDS().size() == 19);
|
||||
for (const auto& kind : cap::CapEvent::KINDS()) CHECK(cap::CapEvent::DIRECTIONS(kind) != nullptr);
|
||||
Json ev = cap::CapEvent::make("cap.text_chunk", Json::object({{"message_id", Json("m1")}, {"text", Json("hi")},
|
||||
{"channel", Json("assistant")}}));
|
||||
CHECK(ev.dumps() == "{\"kind\": \"cap.text_chunk\", \"message_id\": \"m1\", \"text\": \"hi\", \"channel\": \"assistant\"}");
|
||||
bool threw = false;
|
||||
try {
|
||||
cap::CapEvent::make("cap.text_chunk", Json::object({{"message_id", Json("m1")}, {"text", Json("hi")}}));
|
||||
} catch (const wire::ValueError&) {
|
||||
threw = true;
|
||||
}
|
||||
CHECK(threw);
|
||||
CHECK(cap::CapBinding::select(Json::object({{"fast_path", Json::object({{"grpc", Json(true)}})}})) == "pty");
|
||||
cap::CapManifest manifest = cap::CapManifest::fromToml(cap::CapAgentManifest::TAI_CAP_MANIFEST(), "own");
|
||||
CHECK(manifest.name() == "tai");
|
||||
CHECK(manifest.binding() == "stream_json");
|
||||
CHECK(manifest.get("parse.idle").items().size() == 1);
|
||||
CHECK(cap::CapStreamJson::parseUserText("{\"type\":\"user\",\"message\":\"x\"}") == "x");
|
||||
|
||||
// --- drift check against ../cap.md
|
||||
const std::string capDoc = slurp("../cap.md");
|
||||
if (!capDoc.empty()) {
|
||||
for (const auto& kind : cap::CapEvent::KINDS()) CHECK(capDoc.find("`" + kind + "`") != std::string::npos);
|
||||
for (const auto& entry : cap::CAP_ERROR_CODES) CHECK(capDoc.find(std::string(entry.second)) != std::string::npos);
|
||||
for (const auto& code : cap::CAP_ERROR_STRINGS) CHECK(capDoc.find("`" + std::string(code) + "`") != std::string::npos);
|
||||
CHECK(capDoc.find("\xE2\x80\x94") == std::string::npos);
|
||||
} else {
|
||||
std::printf("note: ../cap.md not found, doc drift check skipped\n");
|
||||
}
|
||||
const std::string mcpDoc = slurp("../mcp.md");
|
||||
if (!mcpDoc.empty()) {
|
||||
for (const char* name : {"MCP_CONNECT_TIMEOUT", "MCP_CALL_TIMEOUT", "MCP_RESULT_MAX", "MCP_HTTP_MAX_BODY",
|
||||
"MCP_MAX_SERVERS", "MCP_MAX_TOOLS", "MCP_SESSION_IDLE", "MCP_CHAT_TIMEOUT",
|
||||
"MCP_RESOLVER_TIMEOUT", "MCP_RESOLVER_STEPS", "MCP_OAUTH_TIMEOUT"})
|
||||
CHECK(mcpDoc.find(name) != std::string::npos);
|
||||
}
|
||||
|
||||
// --- normalizer
|
||||
CHECK(mcp::McpNormalizer::mcpSanitizeName("Foo Bar!") == "foo_bar");
|
||||
CHECK(mcp::McpNormalizer::mcpSanitizeName("!!") == "server");
|
||||
auto expanded = mcp::McpNormalizer::mcpExpandVars("a ${X} ${Y:-d} ${Z}", [](const std::string& n) -> std::optional<std::string> {
|
||||
if (n == "X") return std::string("1");
|
||||
return std::nullopt;
|
||||
});
|
||||
CHECK(expanded.first == "a 1 d ${Z}");
|
||||
CHECK(expanded.second.size() == 1 && expanded.second[0] == "Z");
|
||||
auto norm = mcp::McpNormalizer::mcpNormalize("{\"mcpServers\": {\"s\": {\"command\": \"npx\", \"args\": [\"-y\", \"p\"]}}}");
|
||||
CHECK(norm.entry.get("transport").str() == "stdio");
|
||||
CHECK(norm.entry.get("source").str() == "config-json");
|
||||
threw = false;
|
||||
try {
|
||||
mcp::McpNormalizer::mcpNormalize("https://github.com/o/r");
|
||||
} catch (const mcp::McpError&) {
|
||||
threw = true;
|
||||
}
|
||||
CHECK(threw);
|
||||
CHECK(mcp::McpNormalizer::mcpBridgeName("My Srv", "do-it") == "mcp_my_srv_do_it");
|
||||
|
||||
// --- vault tables
|
||||
sqlite3* db = nullptr;
|
||||
sqlite3_open(":memory:", &db);
|
||||
mcp::McpPlainSeal seal;
|
||||
TestStore store(db, &seal);
|
||||
store.ensureSchema();
|
||||
store.mcpSaveServer(Json::object({{"name", Json("srv")}, {"transport", Json("http")}, {"url", Json("https://x.test/mcp")},
|
||||
{"headers", Json::object({{"Authorization", Json("Bearer s")}})}}));
|
||||
CHECK(store.mcpGetServer("srv").get("url").str() == "https://x.test/mcp");
|
||||
CHECK(!store.mcpServerEnabled("srv", "t"));
|
||||
store.mcpSetEnabled("srv", "t", true);
|
||||
CHECK(store.mcpEnabledServers("t").size() == 1);
|
||||
store.mcpStoreTools("srv", {Json::object({{"name", Json("b")}, {"description", Json("bee")}}),
|
||||
Json::object({{"name", Json("a")}})});
|
||||
CHECK(store.mcpCachedTools("srv")[0].get("tool").str() == "a");
|
||||
CHECK(store.mcpToolPins("srv").size() == 2);
|
||||
threw = false;
|
||||
try {
|
||||
store.mcpSaveServer(Json::object({{"name", Json("bad name!")}}));
|
||||
} catch (const wire::ValueError&) {
|
||||
threw = true;
|
||||
}
|
||||
CHECK(threw);
|
||||
store.mcpRemoveServer("srv");
|
||||
CHECK(store.mcpGetServer("srv").isNull());
|
||||
sqlite3_close(db);
|
||||
|
||||
// --- served endpoint, in process
|
||||
FakeBackend backend;
|
||||
mcp::McpServeContext ctx(backend, "k");
|
||||
mcp::McpServeHandler handler(ctx);
|
||||
wire::WireHttpServer::Request req;
|
||||
wire::WireHttpServer::Response resp;
|
||||
req.method = "POST";
|
||||
req.path = "/mcp";
|
||||
req.headers["authorization"] = "Bearer k";
|
||||
req.body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}";
|
||||
req.headers["content-length"] = std::to_string(req.body.size());
|
||||
CHECK(handler.handle(req, resp) && resp.status == 200);
|
||||
CHECK(Json::parse(resp.body).get("result").get("protocolVersion").str() == "2025-11-25");
|
||||
std::string sid;
|
||||
for (const auto& h : resp.headers)
|
||||
if (h.first == "Mcp-Session-Id") sid = h.second;
|
||||
CHECK(sid.size() == 32);
|
||||
req.headers["mcp-session-id"] = sid;
|
||||
req.body = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"deny\",\"arguments\":{}}}";
|
||||
req.headers["content-length"] = std::to_string(req.body.size());
|
||||
handler.handle(req, resp);
|
||||
CHECK(Json::parse(resp.body).get("result").get("isError").truthy());
|
||||
req.headers["authorization"] = "Bearer wrong";
|
||||
handler.handle(req, resp);
|
||||
CHECK(resp.status == 401);
|
||||
|
||||
std::printf(failures ? "%d FAILURES\n" : "capmcp_unit: all checks passed\n", failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user