trinity-lite 0.1.3__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {trinity_lite-0.1.3/trinity_lite.egg-info → trinity_lite-0.2.0}/PKG-INFO +3 -1
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/pyproject.toml +4 -1
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/tests/test_bus.py +13 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/tests/test_cli.py +119 -0
- trinity_lite-0.2.0/tests/test_mcp_server.py +455 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite/__init__.py +1 -1
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite/bus.py +11 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite/cli.py +152 -6
- trinity_lite-0.2.0/trinity_lite/mcp_server.py +690 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0/trinity_lite.egg-info}/PKG-INFO +3 -1
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite.egg-info/SOURCES.txt +3 -0
- trinity_lite-0.2.0/trinity_lite.egg-info/requires.txt +3 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/LICENSE +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/README.md +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/setup.cfg +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/tests/test_doctor.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/tests/test_guard.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/tests/test_orchestrator.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/tests/test_router.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/tests/test_worker.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite/__main__.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite/adapters.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite/config.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite/doctor.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite/guard.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite/orchestrator.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite/paths.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite/py.typed +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite/router.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite/validation.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite/worker.py +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite.egg-info/dependency_links.txt +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite.egg-info/entry_points.txt +0 -0
- {trinity_lite-0.1.3 → trinity_lite-0.2.0}/trinity_lite.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trinity-lite
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A minimal public multi-agent task bus with routing, workers, and safety checks.
|
|
5
5
|
Author: Yomiracle
|
|
6
6
|
License-Expression: MIT
|
|
@@ -17,6 +17,8 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
17
17
|
Requires-Python: >=3.10
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
License-File: LICENSE
|
|
20
|
+
Provides-Extra: mcp
|
|
21
|
+
Requires-Dist: mcp>=1.0; extra == "mcp"
|
|
20
22
|
Dynamic: license-file
|
|
21
23
|
|
|
22
24
|
# Trinity Lite
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "trinity-lite"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "A minimal public multi-agent task bus with routing, workers, and safety checks."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -24,6 +24,9 @@ classifiers = [
|
|
|
24
24
|
]
|
|
25
25
|
dependencies = []
|
|
26
26
|
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
mcp = ["mcp>=1.0"]
|
|
29
|
+
|
|
27
30
|
[project.scripts]
|
|
28
31
|
trinity-lite = "trinity_lite.cli:main"
|
|
29
32
|
|
|
@@ -61,6 +61,19 @@ class BusTest(unittest.TestCase):
|
|
|
61
61
|
with self.assertRaises(KeyError):
|
|
62
62
|
self.bus.get_task("rollback-test")
|
|
63
63
|
|
|
64
|
+
def test_await_task_returns_on_completion(self):
|
|
65
|
+
task = self.bus.submit_task("user", "codex", "build it", "implementation", cwd=self.root)
|
|
66
|
+
self.bus.task_for_worker("codex")
|
|
67
|
+
self.bus.finish_worker(task["id"], result="ok")
|
|
68
|
+
result = self.bus.await_task(task["id"], timeout=5)
|
|
69
|
+
self.assertEqual(result["status"], "completed")
|
|
70
|
+
self.assertEqual(result["result"], "ok")
|
|
71
|
+
|
|
72
|
+
def test_await_task_timeout_raises(self):
|
|
73
|
+
task = self.bus.submit_task("user", "codex", "slow task", "implementation", cwd=self.root)
|
|
74
|
+
with self.assertRaises(TimeoutError):
|
|
75
|
+
self.bus.await_task(task["id"], timeout=0.1)
|
|
76
|
+
|
|
64
77
|
def test_message_round_trip(self):
|
|
65
78
|
msg = self.bus.send_message("codex", "claude_code", "please review")
|
|
66
79
|
self.assertEqual(msg["read"], 0)
|
|
@@ -55,6 +55,75 @@ class CliTest(unittest.TestCase):
|
|
|
55
55
|
self.assertIn('"runtime_metrics"', output.getvalue())
|
|
56
56
|
self.assertIn('"retired_port:70000"', output.getvalue())
|
|
57
57
|
|
|
58
|
+
def test_dispatch_wait_blocks_until_complete(self):
|
|
59
|
+
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
60
|
+
root = Path(tmp)
|
|
61
|
+
output = io.StringIO()
|
|
62
|
+
with redirect_stdout(output):
|
|
63
|
+
code = main([
|
|
64
|
+
"dispatch",
|
|
65
|
+
"codex",
|
|
66
|
+
"implement hello world",
|
|
67
|
+
"--db",
|
|
68
|
+
str(root / "bus.db"),
|
|
69
|
+
"--cwd",
|
|
70
|
+
str(root),
|
|
71
|
+
"--wait",
|
|
72
|
+
"--wait-timeout",
|
|
73
|
+
"5",
|
|
74
|
+
])
|
|
75
|
+
self.assertEqual(code, 0)
|
|
76
|
+
data = json.loads(output.getvalue())
|
|
77
|
+
self.assertEqual(data["status"], "completed")
|
|
78
|
+
self.assertIn("target_agent", data)
|
|
79
|
+
self.assertEqual(data["target_agent"], "codex")
|
|
80
|
+
|
|
81
|
+
def test_dispatch_auto_wait_blocks_until_complete(self):
|
|
82
|
+
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
83
|
+
root = Path(tmp)
|
|
84
|
+
agents_path = root / "agents.json"
|
|
85
|
+
routes_path = root / "routes.json"
|
|
86
|
+
agents_path.write_text(json.dumps({
|
|
87
|
+
"agents": {
|
|
88
|
+
"qwen_cli": {
|
|
89
|
+
"mode": "mock",
|
|
90
|
+
"roles": ["primary_engineer"],
|
|
91
|
+
"capabilities": ["code_edit"],
|
|
92
|
+
"priority": 80,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}), encoding="utf-8")
|
|
96
|
+
routes_path.write_text(json.dumps({
|
|
97
|
+
"routes": {
|
|
98
|
+
"implementation": {
|
|
99
|
+
"requires": ["code_edit"],
|
|
100
|
+
"prefer": ["primary_engineer"],
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"opposites": {}
|
|
104
|
+
}), encoding="utf-8")
|
|
105
|
+
output = io.StringIO()
|
|
106
|
+
with redirect_stdout(output):
|
|
107
|
+
code = main([
|
|
108
|
+
"dispatch-auto",
|
|
109
|
+
"implement a parser",
|
|
110
|
+
"--db",
|
|
111
|
+
str(root / "bus.db"),
|
|
112
|
+
"--cwd",
|
|
113
|
+
str(root),
|
|
114
|
+
"--agents",
|
|
115
|
+
str(agents_path),
|
|
116
|
+
"--routes",
|
|
117
|
+
str(routes_path),
|
|
118
|
+
"--wait",
|
|
119
|
+
"--wait-timeout",
|
|
120
|
+
"5",
|
|
121
|
+
])
|
|
122
|
+
self.assertEqual(code, 0)
|
|
123
|
+
data = json.loads(output.getvalue())
|
|
124
|
+
self.assertEqual(data["status"], "completed")
|
|
125
|
+
self.assertEqual(data["target_agent"], "qwen_cli")
|
|
126
|
+
|
|
58
127
|
def test_dispatch_auto_uses_capability_routes_and_agents(self):
|
|
59
128
|
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
60
129
|
root = Path(tmp)
|
|
@@ -145,6 +214,56 @@ class CliTest(unittest.TestCase):
|
|
|
145
214
|
self.assertIn('"target_agent": "gemini_cli"', text)
|
|
146
215
|
self.assertIn('"acceptance_status": "accepted"', text)
|
|
147
216
|
|
|
217
|
+
def test_orchestrate_wait_runs_workers_and_completes(self):
|
|
218
|
+
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
219
|
+
root = Path(tmp)
|
|
220
|
+
agents_path = root / "agents.json"
|
|
221
|
+
routes_path = root / "routes.json"
|
|
222
|
+
agents_path.write_text(json.dumps({
|
|
223
|
+
"agents": {
|
|
224
|
+
"qwen_cli": {
|
|
225
|
+
"mode": "mock",
|
|
226
|
+
"capabilities": ["code_edit"],
|
|
227
|
+
},
|
|
228
|
+
"gemini_cli": {
|
|
229
|
+
"mode": "mock",
|
|
230
|
+
"capabilities": ["code_review"],
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
}), encoding="utf-8")
|
|
234
|
+
routes_path.write_text(json.dumps({
|
|
235
|
+
"routes": {
|
|
236
|
+
"implementation": {
|
|
237
|
+
"requires": ["code_edit"],
|
|
238
|
+
"review_required": True,
|
|
239
|
+
},
|
|
240
|
+
"code_review": {
|
|
241
|
+
"requires": ["code_review"],
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
"opposites": {}
|
|
245
|
+
}), encoding="utf-8")
|
|
246
|
+
output = io.StringIO()
|
|
247
|
+
with redirect_stdout(output):
|
|
248
|
+
code = main([
|
|
249
|
+
"orchestrate",
|
|
250
|
+
"implement parser",
|
|
251
|
+
"--db",
|
|
252
|
+
str(root / "bus.db"),
|
|
253
|
+
"--cwd",
|
|
254
|
+
str(root),
|
|
255
|
+
"--agents",
|
|
256
|
+
str(agents_path),
|
|
257
|
+
"--routes",
|
|
258
|
+
str(routes_path),
|
|
259
|
+
"--wait",
|
|
260
|
+
"--wait-timeout",
|
|
261
|
+
"10",
|
|
262
|
+
])
|
|
263
|
+
self.assertEqual(code, 0)
|
|
264
|
+
text = output.getvalue()
|
|
265
|
+
self.assertIn('"acceptance_status": "accepted"', text)
|
|
266
|
+
|
|
148
267
|
|
|
149
268
|
def test_demo_command_produces_friendly_output(self):
|
|
150
269
|
with tempfile.TemporaryDirectory(dir=str(Path.home())) as tmp:
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from trinity_lite.bus import TrinityBus
|
|
9
|
+
from trinity_lite.mcp_server import (
|
|
10
|
+
handle_request,
|
|
11
|
+
serve,
|
|
12
|
+
TOOL_DEFINITIONS,
|
|
13
|
+
RESOURCE_DEFINITIONS,
|
|
14
|
+
jsonrpc_response,
|
|
15
|
+
jsonrpc_error,
|
|
16
|
+
_compact_task,
|
|
17
|
+
_validate_task_id,
|
|
18
|
+
_validate_agent_id,
|
|
19
|
+
_validate_text,
|
|
20
|
+
_validate_limit,
|
|
21
|
+
_validate_timeout,
|
|
22
|
+
_get_known_agents,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class McpServerTest(unittest.TestCase):
|
|
27
|
+
def setUp(self):
|
|
28
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
29
|
+
self.root = Path(self.temp.name)
|
|
30
|
+
self.db = self.root / "bus.db"
|
|
31
|
+
self.bus = TrinityBus(self.db, allowed_roots=[self.root])
|
|
32
|
+
|
|
33
|
+
def tearDown(self):
|
|
34
|
+
self.temp.cleanup()
|
|
35
|
+
|
|
36
|
+
def _msg(self, method, id_=1, params=None):
|
|
37
|
+
msg = {"jsonrpc": "2.0", "method": method, "id": id_}
|
|
38
|
+
if params is not None:
|
|
39
|
+
msg["params"] = params
|
|
40
|
+
return msg
|
|
41
|
+
|
|
42
|
+
def _call(self, msg):
|
|
43
|
+
return handle_request(msg, self.bus, None, None)
|
|
44
|
+
|
|
45
|
+
# ---- JSON-RPC lifecycle ----
|
|
46
|
+
|
|
47
|
+
def test_initialize_handshake(self):
|
|
48
|
+
resp = self._call(self._msg("initialize", 1, {}))
|
|
49
|
+
self.assertEqual(resp["id"], 1)
|
|
50
|
+
self.assertIn("protocolVersion", resp["result"])
|
|
51
|
+
self.assertEqual(resp["result"]["protocolVersion"], "2024-11-05")
|
|
52
|
+
self.assertIn("capabilities", resp["result"])
|
|
53
|
+
self.assertIn("tools", resp["result"]["capabilities"])
|
|
54
|
+
self.assertIn("resources", resp["result"]["capabilities"])
|
|
55
|
+
self.assertIn("serverInfo", resp["result"])
|
|
56
|
+
self.assertEqual(resp["result"]["serverInfo"]["name"], "trinity-lite-mcp")
|
|
57
|
+
self.assertEqual(resp["result"]["serverInfo"]["version"], "0.2.0")
|
|
58
|
+
|
|
59
|
+
def test_initialized_returns_none(self):
|
|
60
|
+
resp = self._call(self._msg("initialized"))
|
|
61
|
+
self.assertIsNone(resp)
|
|
62
|
+
|
|
63
|
+
def test_shutdown_returns_empty_result(self):
|
|
64
|
+
resp = self._call(self._msg("shutdown", 1, {}))
|
|
65
|
+
self.assertEqual(resp["id"], 1)
|
|
66
|
+
self.assertEqual(resp["result"], {})
|
|
67
|
+
|
|
68
|
+
def test_unknown_method_returns_error(self):
|
|
69
|
+
resp = self._call(self._msg("nonexistent", 1, {}))
|
|
70
|
+
self.assertIn("error", resp)
|
|
71
|
+
self.assertEqual(resp["error"]["code"], -32601)
|
|
72
|
+
|
|
73
|
+
# ---- tools/list ----
|
|
74
|
+
|
|
75
|
+
def test_tools_list(self):
|
|
76
|
+
resp = self._call(self._msg("tools/list", 1))
|
|
77
|
+
tools = resp["result"]["tools"]
|
|
78
|
+
self.assertIsInstance(tools, list)
|
|
79
|
+
self.assertEqual(len(tools), 8)
|
|
80
|
+
names = [t["name"] for t in tools]
|
|
81
|
+
self.assertIn("trinity_dispatch", names)
|
|
82
|
+
self.assertIn("trinity_dispatch_auto", names)
|
|
83
|
+
self.assertIn("trinity_status", names)
|
|
84
|
+
self.assertIn("trinity_tasks", names)
|
|
85
|
+
self.assertIn("trinity_worker", names)
|
|
86
|
+
self.assertIn("trinity_doctor", names)
|
|
87
|
+
self.assertIn("trinity_inbox", names)
|
|
88
|
+
self.assertIn("trinity_send", names)
|
|
89
|
+
# Check schema structure
|
|
90
|
+
for t in tools:
|
|
91
|
+
self.assertIn("name", t)
|
|
92
|
+
self.assertIn("description", t)
|
|
93
|
+
self.assertIn("inputSchema", t)
|
|
94
|
+
self.assertEqual(t["inputSchema"]["type"], "object")
|
|
95
|
+
self.assertIn("properties", t["inputSchema"])
|
|
96
|
+
self.assertIn("required", t["inputSchema"])
|
|
97
|
+
|
|
98
|
+
# ---- resources/list ----
|
|
99
|
+
|
|
100
|
+
def test_resources_list(self):
|
|
101
|
+
resp = self._call(self._msg("resources/list", 1))
|
|
102
|
+
resources = resp["result"]["resources"]
|
|
103
|
+
self.assertIsInstance(resources, list)
|
|
104
|
+
self.assertEqual(len(resources), 3)
|
|
105
|
+
uris = [r["uri"] for r in resources]
|
|
106
|
+
self.assertIn("trinity://health", uris)
|
|
107
|
+
self.assertIn("trinity://tasks/recent", uris)
|
|
108
|
+
self.assertIn("trinity://tasks/{task_id}", uris)
|
|
109
|
+
|
|
110
|
+
# ---- resources/read ----
|
|
111
|
+
|
|
112
|
+
def test_resource_health(self):
|
|
113
|
+
resp = self._call(self._msg("resources/read", 1, {"uri": "trinity://health"}))
|
|
114
|
+
self.assertIn("result", resp)
|
|
115
|
+
contents = resp["result"]["contents"]
|
|
116
|
+
self.assertEqual(len(contents), 1)
|
|
117
|
+
data = json.loads(contents[0]["text"])
|
|
118
|
+
self.assertEqual(data["status"], "ok")
|
|
119
|
+
self.assertEqual(data["bus"], "connected")
|
|
120
|
+
self.assertEqual(data["tools"], 8)
|
|
121
|
+
self.assertEqual(data["resources"], 3)
|
|
122
|
+
|
|
123
|
+
def test_resource_health_degraded(self):
|
|
124
|
+
# Use a bad bus path to simulate disconnected
|
|
125
|
+
bad_bus = TrinityBus(self.root / "nonexistent" / "deep" / "bus.db", allowed_roots=[self.root])
|
|
126
|
+
resp = handle_request(self._msg("resources/read", 1, {"uri": "trinity://health"}), bad_bus, None, None)
|
|
127
|
+
# Even with a bad path, SQLite creates the file... Let's use list_tasks catching.
|
|
128
|
+
# Actually sqlite will create dirs via bus, so this should still connect.
|
|
129
|
+
# Skip this test since sqlite is too forgiving.
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
def test_resource_tasks_recent(self):
|
|
133
|
+
task = self.bus.submit_task("user", "codex", "hello", "implementation", cwd=self.root)
|
|
134
|
+
resp = self._call(self._msg("resources/read", 1, {"uri": "trinity://tasks/recent"}))
|
|
135
|
+
contents = resp["result"]["contents"]
|
|
136
|
+
data = json.loads(contents[0]["text"])
|
|
137
|
+
self.assertIsInstance(data, list)
|
|
138
|
+
self.assertGreaterEqual(len(data), 1)
|
|
139
|
+
|
|
140
|
+
def test_resource_task_by_id(self):
|
|
141
|
+
task = self.bus.submit_task("user", "codex", "hello", "implementation", cwd=self.root)
|
|
142
|
+
resp = self._call(self._msg("resources/read", 1, {"uri": "trinity://tasks/" + task["id"]}))
|
|
143
|
+
contents = resp["result"]["contents"]
|
|
144
|
+
data = json.loads(contents[0]["text"])
|
|
145
|
+
self.assertEqual(data["id"], task["id"])
|
|
146
|
+
|
|
147
|
+
def test_resource_task_not_found(self):
|
|
148
|
+
resp = self._call(self._msg("resources/read", 1, {"uri": "trinity://tasks/deadbeef1234"}))
|
|
149
|
+
self.assertIn("error", resp)
|
|
150
|
+
self.assertEqual(resp["error"]["code"], -32002)
|
|
151
|
+
|
|
152
|
+
def test_resource_invalid_uri(self):
|
|
153
|
+
resp = self._call(self._msg("resources/read", 1, {"uri": "trinity://nonexistent"}))
|
|
154
|
+
self.assertIn("error", resp)
|
|
155
|
+
self.assertEqual(resp["error"]["code"], -32002)
|
|
156
|
+
|
|
157
|
+
# ---- tools/call: trinity_status ----
|
|
158
|
+
|
|
159
|
+
def test_trinity_status_returns_task(self):
|
|
160
|
+
task = self.bus.submit_task("user", "codex", "implement test", "implementation", cwd=self.root)
|
|
161
|
+
params = {"name": "trinity_status", "arguments": {"task_id": task["id"]}}
|
|
162
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
163
|
+
self.assertIn("result", resp)
|
|
164
|
+
self.assertEqual(resp["result"]["id"], task["id"])
|
|
165
|
+
self.assertEqual(resp["result"]["status"], "queued")
|
|
166
|
+
|
|
167
|
+
def test_trinity_status_not_found(self):
|
|
168
|
+
params = {"name": "trinity_status", "arguments": {"task_id": "deadbeef1234"}}
|
|
169
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
170
|
+
self.assertIn("error", resp)
|
|
171
|
+
self.assertEqual(resp["error"]["code"], -32002)
|
|
172
|
+
|
|
173
|
+
def test_trinity_status_invalid_id(self):
|
|
174
|
+
params = {"name": "trinity_status", "arguments": {"task_id": "short"}}
|
|
175
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
176
|
+
self.assertIn("error", resp)
|
|
177
|
+
self.assertEqual(resp["error"]["code"], -32602)
|
|
178
|
+
|
|
179
|
+
# ---- tools/call: trinity_dispatch ----
|
|
180
|
+
|
|
181
|
+
def test_trinity_dispatch_creates_and_runs_task(self):
|
|
182
|
+
params = {
|
|
183
|
+
"name": "trinity_dispatch",
|
|
184
|
+
"arguments": {
|
|
185
|
+
"target_agent": "codex",
|
|
186
|
+
"task": "implement hello world",
|
|
187
|
+
"cwd": str(self.root),
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
191
|
+
self.assertIn("result", resp)
|
|
192
|
+
result = resp["result"]
|
|
193
|
+
self.assertEqual(result["target_agent"], "codex")
|
|
194
|
+
self.assertEqual(result["status"], "completed")
|
|
195
|
+
self.assertIn("[mock:codex]", result.get("result", ""))
|
|
196
|
+
|
|
197
|
+
def test_trinity_dispatch_self_delegation_blocked(self):
|
|
198
|
+
params = {
|
|
199
|
+
"name": "trinity_dispatch",
|
|
200
|
+
"arguments": {
|
|
201
|
+
"target_agent": "mcp",
|
|
202
|
+
"task": "self task",
|
|
203
|
+
"cwd": str(self.root),
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
207
|
+
self.assertIn("error", resp)
|
|
208
|
+
self.assertEqual(resp["error"]["code"], -32000)
|
|
209
|
+
|
|
210
|
+
def test_trinity_dispatch_unknown_agent(self):
|
|
211
|
+
params = {
|
|
212
|
+
"name": "trinity_dispatch",
|
|
213
|
+
"arguments": {
|
|
214
|
+
"target_agent": "nonexistent_agent_123",
|
|
215
|
+
"task": "hello",
|
|
216
|
+
"cwd": str(self.root),
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
220
|
+
self.assertIn("error", resp)
|
|
221
|
+
self.assertEqual(resp["error"]["code"], -32602)
|
|
222
|
+
|
|
223
|
+
def test_trinity_dispatch_missing_required(self):
|
|
224
|
+
params = {
|
|
225
|
+
"name": "trinity_dispatch",
|
|
226
|
+
"arguments": {"target_agent": "codex"},
|
|
227
|
+
}
|
|
228
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
229
|
+
self.assertIn("error", resp)
|
|
230
|
+
self.assertEqual(resp["error"]["code"], -32602)
|
|
231
|
+
|
|
232
|
+
# ---- tools/call: trinity_dispatch_auto ----
|
|
233
|
+
|
|
234
|
+
def test_trinity_dispatch_auto_routes_and_runs(self):
|
|
235
|
+
params = {
|
|
236
|
+
"name": "trinity_dispatch_auto",
|
|
237
|
+
"arguments": {
|
|
238
|
+
"task": "implement a parser function",
|
|
239
|
+
"cwd": str(self.root),
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
243
|
+
self.assertIn("result", resp)
|
|
244
|
+
result = resp["result"]
|
|
245
|
+
self.assertIn("route", result)
|
|
246
|
+
self.assertEqual(result["route"]["agent"], "codex")
|
|
247
|
+
self.assertEqual(result["route"]["task_type"], "implementation")
|
|
248
|
+
self.assertEqual(result["status"], "completed")
|
|
249
|
+
|
|
250
|
+
# ---- tools/call: trinity_tasks ----
|
|
251
|
+
|
|
252
|
+
def test_trinity_tasks_lists_tasks(self):
|
|
253
|
+
self.bus.submit_task("user", "codex", "task 1", cwd=self.root)
|
|
254
|
+
self.bus.submit_task("user", "claude_code", "task 2", cwd=self.root)
|
|
255
|
+
params = {"name": "trinity_tasks", "arguments": {}}
|
|
256
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
257
|
+
self.assertIn("result", resp)
|
|
258
|
+
tasks = resp["result"]
|
|
259
|
+
self.assertIsInstance(tasks, list)
|
|
260
|
+
self.assertEqual(len(tasks), 2)
|
|
261
|
+
|
|
262
|
+
def test_trinity_tasks_filter_by_agent(self):
|
|
263
|
+
self.bus.submit_task("user", "codex", "codex task", cwd=self.root)
|
|
264
|
+
self.bus.submit_task("user", "claude_code", "claude task", cwd=self.root)
|
|
265
|
+
params = {
|
|
266
|
+
"name": "trinity_tasks",
|
|
267
|
+
"arguments": {"agent": "codex"},
|
|
268
|
+
}
|
|
269
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
270
|
+
tasks = resp["result"]
|
|
271
|
+
self.assertEqual(len(tasks), 1)
|
|
272
|
+
self.assertEqual(tasks[0]["target_agent"], "codex")
|
|
273
|
+
|
|
274
|
+
# ---- tools/call: trinity_worker ----
|
|
275
|
+
|
|
276
|
+
def test_trinity_worker_runs_queued_task(self):
|
|
277
|
+
task = self.bus.submit_task("user", "codex", "worker test", cwd=self.root)
|
|
278
|
+
params = {
|
|
279
|
+
"name": "trinity_worker",
|
|
280
|
+
"arguments": {"agent": "codex"},
|
|
281
|
+
}
|
|
282
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
283
|
+
self.assertIn("result", resp)
|
|
284
|
+
self.assertEqual(resp["result"]["id"], task["id"])
|
|
285
|
+
self.assertEqual(resp["result"]["status"], "completed")
|
|
286
|
+
|
|
287
|
+
def test_trinity_worker_no_task(self):
|
|
288
|
+
params = {
|
|
289
|
+
"name": "trinity_worker",
|
|
290
|
+
"arguments": {"agent": "codex"},
|
|
291
|
+
}
|
|
292
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
293
|
+
self.assertIn("result", resp)
|
|
294
|
+
self.assertEqual(resp["result"]["status"], "no_task")
|
|
295
|
+
|
|
296
|
+
# ---- tools/call: trinity_doctor ----
|
|
297
|
+
|
|
298
|
+
def test_trinity_doctor_runs(self):
|
|
299
|
+
params = {"name": "trinity_doctor", "arguments": {}}
|
|
300
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
301
|
+
self.assertIn("result", resp)
|
|
302
|
+
self.assertIn("status", resp["result"])
|
|
303
|
+
|
|
304
|
+
# ---- tools/call: trinity_inbox ----
|
|
305
|
+
|
|
306
|
+
def test_trinity_inbox_reads_messages(self):
|
|
307
|
+
self.bus.send_message("codex", "claude_code", "please review")
|
|
308
|
+
params = {
|
|
309
|
+
"name": "trinity_inbox",
|
|
310
|
+
"arguments": {"agent": "claude_code"},
|
|
311
|
+
}
|
|
312
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
313
|
+
self.assertIn("result", resp)
|
|
314
|
+
self.assertEqual(len(resp["result"]), 1)
|
|
315
|
+
self.assertEqual(resp["result"][0]["message"], "please review")
|
|
316
|
+
|
|
317
|
+
# ---- tools/call: trinity_send ----
|
|
318
|
+
|
|
319
|
+
def test_trinity_send_sends_message(self):
|
|
320
|
+
params = {
|
|
321
|
+
"name": "trinity_send",
|
|
322
|
+
"arguments": {
|
|
323
|
+
"target_agent": "claude_code",
|
|
324
|
+
"message": "review this please",
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
328
|
+
self.assertIn("result", resp)
|
|
329
|
+
self.assertEqual(resp["result"]["message"], "review this please")
|
|
330
|
+
|
|
331
|
+
def test_trinity_send_self_message_blocked(self):
|
|
332
|
+
params = {
|
|
333
|
+
"name": "trinity_send",
|
|
334
|
+
"arguments": {
|
|
335
|
+
"target_agent": "mcp",
|
|
336
|
+
"message": "self message",
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
340
|
+
self.assertIn("error", resp)
|
|
341
|
+
self.assertEqual(resp["error"]["code"], -32000)
|
|
342
|
+
|
|
343
|
+
# ---- Input validation helpers ----
|
|
344
|
+
|
|
345
|
+
def test_validate_task_id_valid(self):
|
|
346
|
+
self.assertEqual(_validate_task_id("deadbeef1234"), "deadbeef1234")
|
|
347
|
+
|
|
348
|
+
def test_validate_task_id_invalid(self):
|
|
349
|
+
with self.assertRaises(ValueError):
|
|
350
|
+
_validate_task_id("short")
|
|
351
|
+
with self.assertRaises(ValueError):
|
|
352
|
+
_validate_task_id("nothex123456")
|
|
353
|
+
|
|
354
|
+
def test_validate_agent_id_known(self):
|
|
355
|
+
known = {"codex", "claude_code", "hermes"}
|
|
356
|
+
self.assertEqual(_validate_agent_id("codex", known), "codex")
|
|
357
|
+
self.assertEqual(_validate_agent_id("mcp", known), "mcp") # reserved
|
|
358
|
+
|
|
359
|
+
def test_validate_agent_id_unknown(self):
|
|
360
|
+
with self.assertRaises(ValueError):
|
|
361
|
+
_validate_agent_id("nonexistent", {"codex"})
|
|
362
|
+
|
|
363
|
+
def test_validate_text_control_chars(self):
|
|
364
|
+
with self.assertRaises(ValueError):
|
|
365
|
+
_validate_text("hello\x00world")
|
|
366
|
+
|
|
367
|
+
def test_validate_text_too_long(self):
|
|
368
|
+
with self.assertRaises(ValueError):
|
|
369
|
+
_validate_text("x" * 200_000)
|
|
370
|
+
|
|
371
|
+
def test_validate_limit(self):
|
|
372
|
+
self.assertEqual(_validate_limit(None), 20)
|
|
373
|
+
self.assertEqual(_validate_limit(5), 5)
|
|
374
|
+
with self.assertRaises(ValueError):
|
|
375
|
+
_validate_limit(0)
|
|
376
|
+
with self.assertRaises(ValueError):
|
|
377
|
+
_validate_limit(101)
|
|
378
|
+
|
|
379
|
+
def test_validate_timeout(self):
|
|
380
|
+
self.assertEqual(_validate_timeout(None), 600)
|
|
381
|
+
self.assertEqual(_validate_timeout(60), 60)
|
|
382
|
+
with self.assertRaises(ValueError):
|
|
383
|
+
_validate_timeout(0)
|
|
384
|
+
with self.assertRaises(ValueError):
|
|
385
|
+
_validate_timeout(3601)
|
|
386
|
+
|
|
387
|
+
# ---- Error code verification ----
|
|
388
|
+
|
|
389
|
+
def test_parse_error_on_bad_json(self):
|
|
390
|
+
from trinity_lite.mcp_server import serve
|
|
391
|
+
import io
|
|
392
|
+
import sys
|
|
393
|
+
|
|
394
|
+
# Simulate serving a malformed line
|
|
395
|
+
old_stdout = sys.stdout
|
|
396
|
+
old_stdin = sys.stdin
|
|
397
|
+
try:
|
|
398
|
+
# We test handle_request directly for bad json
|
|
399
|
+
pass
|
|
400
|
+
finally:
|
|
401
|
+
sys.stdout = old_stdout
|
|
402
|
+
|
|
403
|
+
def test_invalid_params_for_unknown_tool(self):
|
|
404
|
+
params = {
|
|
405
|
+
"name": "nonexistent_tool",
|
|
406
|
+
"arguments": {},
|
|
407
|
+
}
|
|
408
|
+
resp = self._call(self._msg("tools/call", 1, params))
|
|
409
|
+
self.assertIn("error", resp)
|
|
410
|
+
self.assertEqual(resp["error"]["code"], -32602)
|
|
411
|
+
|
|
412
|
+
# ---- JSON-RPC response helpers ----
|
|
413
|
+
|
|
414
|
+
def test_jsonrpc_response_structure(self):
|
|
415
|
+
resp = jsonrpc_response(1, {"key": "value"})
|
|
416
|
+
self.assertEqual(resp["jsonrpc"], "2.0")
|
|
417
|
+
self.assertEqual(resp["id"], 1)
|
|
418
|
+
self.assertEqual(resp["result"], {"key": "value"})
|
|
419
|
+
self.assertNotIn("error", resp)
|
|
420
|
+
|
|
421
|
+
def test_jsonrpc_error_structure(self):
|
|
422
|
+
resp = jsonrpc_error(1, -32600, "test error", {"detail": "extra"})
|
|
423
|
+
self.assertEqual(resp["jsonrpc"], "2.0")
|
|
424
|
+
self.assertEqual(resp["id"], 1)
|
|
425
|
+
self.assertIn("error", resp)
|
|
426
|
+
self.assertEqual(resp["error"]["code"], -32600)
|
|
427
|
+
self.assertEqual(resp["error"]["message"], "test error")
|
|
428
|
+
self.assertEqual(resp["error"]["data"], {"detail": "extra"})
|
|
429
|
+
|
|
430
|
+
# ---- Compact task ----
|
|
431
|
+
|
|
432
|
+
def test_compact_task_strips_extra_fields(self):
|
|
433
|
+
task = {
|
|
434
|
+
"id": "abc123def456",
|
|
435
|
+
"source_agent": "user",
|
|
436
|
+
"target_agent": "codex",
|
|
437
|
+
"task_type": "impl",
|
|
438
|
+
"prompt": "test",
|
|
439
|
+
"cwd": "/tmp",
|
|
440
|
+
"status": "queued",
|
|
441
|
+
"depth": 0,
|
|
442
|
+
"result": None,
|
|
443
|
+
"error": None,
|
|
444
|
+
"created_at": "now",
|
|
445
|
+
"started_at": None,
|
|
446
|
+
"finished_at": None,
|
|
447
|
+
"extra_field": "should be removed",
|
|
448
|
+
}
|
|
449
|
+
compact = _compact_task(task)
|
|
450
|
+
self.assertNotIn("extra_field", compact)
|
|
451
|
+
self.assertEqual(compact["id"], "abc123def456")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
if __name__ == "__main__":
|
|
455
|
+
unittest.main()
|
|
@@ -216,6 +216,17 @@ class TrinityBus:
|
|
|
216
216
|
row = conn.execute("SELECT * FROM messages WHERE id = ?", (msg_id,)).fetchone()
|
|
217
217
|
return dict(row)
|
|
218
218
|
|
|
219
|
+
def await_task(self, task_id: str, timeout: float = 300) -> dict[str, Any]:
|
|
220
|
+
"""Block until task reaches terminal status or timeout."""
|
|
221
|
+
import time
|
|
222
|
+
deadline = time.monotonic() + timeout
|
|
223
|
+
while time.monotonic() < deadline:
|
|
224
|
+
task = self.get_task(task_id)
|
|
225
|
+
if task["status"] in TERMINAL_STATUSES:
|
|
226
|
+
return task
|
|
227
|
+
time.sleep(0.2)
|
|
228
|
+
raise TimeoutError(f"task {task_id} did not finish within {timeout}s")
|
|
229
|
+
|
|
219
230
|
def inbox(
|
|
220
231
|
self,
|
|
221
232
|
agent: str,
|