gabriel / muse public
test_agent_json_schema.py python
452 lines 16.2 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 141 days ago
1 """Tests for the canonical ``muse agent`` JSON schema.
2
3 Coverage
4 --------
5 I keygen schema
6 I1 All required keys present in keygen response
7 I2 status is "ok"
8 I3 hd_seed_b64 decodes to exactly 64 bytes
9 I4 public_key_b64 decodes to exactly 32 bytes
10 I5 fingerprint is sha256 hex of public_key_b64 bytes
11 I6 name is null when --name not provided
12 I7 name reflects --name flag when provided
13 I8 msign_path contains the account index
14 I9 hub is the full URL passed via --hub
15
16 II list schema
17 II1 Returns a JSON array (not object)
18 II2 Empty array when no slots registered
19 II3 Each entry has all required keys
20 II4 Entries are sorted by account index (ascending)
21 II5 hub in each entry is hostname (not full URL)
22
23 III register schema
24 III1 All required keys present in register response
25 III2 status is "ok"
26 III3 hub is hostname (not full URL)
27 III4 msign_path contains the account index
28
29 IV Error paths — JSON errors when --json is passed
30 IV1 keygen with no identity → JSON error, exit 1
31 IV2 keygen with no mnemonic → JSON error, exit 1
32 IV3 keygen with negative account → JSON error, exit 1
33 IV4 keygen with no hub (no config) → JSON error, exit 1
34 IV5 Error responses include "error" key
35 IV6 Error responses include "message" key
36 """
37
38 from __future__ import annotations
39
40 import base64
41 import hashlib
42 import json
43 import pathlib
44
45 import pytest
46
47 from tests.cli_test_helper import CliRunner
48
49 cli = None
50 runner = CliRunner()
51
52 _TEST_HUB = "http://localhost:10003"
53 _TEST_HOSTNAME = "localhost:10003"
54 _TEST_MNEMONIC = (
55 "abandon abandon abandon abandon abandon abandon abandon abandon "
56 "abandon abandon abandon about"
57 )
58
59 _KEYGEN_REQUIRED_KEYS = {
60 "status", "hub", "account", "name", "msign_path",
61 "public_key_b64", "fingerprint", "hd_seed_b64",
62 }
63 _LIST_ENTRY_REQUIRED_KEYS = {"name", "account", "hub", "msign_path"}
64 _REGISTER_REQUIRED_KEYS = {"status", "name", "account", "hub", "msign_path"}
65
66
67 # ---------------------------------------------------------------------------
68 # Fixtures
69 # ---------------------------------------------------------------------------
70
71
72 @pytest.fixture()
73 def isolated_identity(
74 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
75 ) -> pathlib.Path:
76 fake_dir = tmp_path / "dot_muse"
77 fake_dir.mkdir()
78 fake_file = fake_dir / "identity.toml"
79 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir)
80 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_file)
81 return fake_dir
82
83
84 @pytest.fixture()
85 def isolated_slots(
86 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
87 ) -> pathlib.Path:
88 fake_dir = tmp_path / "dot_muse_slots"
89 fake_dir.mkdir()
90 fake_file = fake_dir / "agent-slots.toml"
91 monkeypatch.setattr("muse.core.agent_slots._SLOTS_DIR", fake_dir)
92 monkeypatch.setattr("muse.core.agent_slots._SLOTS_FILE", fake_file)
93 return fake_dir
94
95
96 @pytest.fixture()
97 def identity_with_mnemonic(isolated_identity: pathlib.Path) -> None:
98 from muse.core.identity import IdentityEntry, save_identity
99 entry: IdentityEntry = {
100 "type": "human",
101 "handle": "gabriel",
102 "mnemonic": _TEST_MNEMONIC,
103 "hd_path": "m/1075233755'/0'/0'/0'/0'/0'",
104 }
105 save_identity(_TEST_HUB, entry)
106
107
108 def _keygen(
109 *extra_args: str,
110 identity: None = None,
111 slots: None = None,
112 ) -> dict:
113 result = runner.invoke(
114 cli,
115 ["agent", "keygen", "--hub", _TEST_HUB, "--account", "1", "--json"] + list(extra_args),
116 )
117 assert result.exit_code == 0, f"keygen failed:\n{result.output}"
118 return json.loads(result.output.strip().splitlines()[0])
119
120
121 def _list_slots(slots: None = None) -> list:
122 result = runner.invoke(
123 cli, ["agent", "list", "--hub", _TEST_HUB, "--json"]
124 )
125 assert result.exit_code == 0, f"list failed:\n{result.output}"
126 return json.loads(result.output.strip().splitlines()[0])
127
128
129 def _register(name: str, account: int, slots: None = None) -> dict:
130 result = runner.invoke(
131 cli,
132 ["agent", "register", "--hub", _TEST_HUB,
133 "--account", str(account), "--name", name, "--json"],
134 )
135 assert result.exit_code == 0, f"register failed:\n{result.output}"
136 return json.loads(result.output.strip().splitlines()[0])
137
138
139 # ---------------------------------------------------------------------------
140 # I keygen schema
141 # ---------------------------------------------------------------------------
142
143
144 class TestKeygenSchemaI:
145 def test_I1_all_required_keys_present(
146 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
147 ) -> None:
148 data = _keygen()
149 missing = _KEYGEN_REQUIRED_KEYS - set(data.keys())
150 assert not missing, f"Missing keys in keygen response: {missing}"
151
152 def test_I2_status_is_ok(
153 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
154 ) -> None:
155 data = _keygen()
156 assert data["status"] == "ok"
157
158 def test_I3_hd_seed_b64_decodes_to_64_bytes(
159 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
160 ) -> None:
161 data = _keygen()
162 raw = base64.urlsafe_b64decode(data["hd_seed_b64"] + "==")
163 assert len(raw) == 64, f"Expected 64 bytes, got {len(raw)}"
164
165 def test_I4_public_key_b64_decodes_to_32_bytes(
166 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
167 ) -> None:
168 data = _keygen()
169 raw = base64.urlsafe_b64decode(data["public_key_b64"] + "==")
170 assert len(raw) == 32, f"Expected 32 bytes, got {len(raw)}"
171
172 def test_I5_fingerprint_is_sha256_of_public_key(
173 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
174 ) -> None:
175 data = _keygen()
176 pub_bytes = base64.urlsafe_b64decode(data["public_key_b64"] + "==")
177 expected = hashlib.sha256(pub_bytes).hexdigest()
178 assert data["fingerprint"] == expected, (
179 f"Fingerprint mismatch: {data['fingerprint']!r} != {expected!r}"
180 )
181
182 def test_I6_name_is_null_without_name_flag(
183 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
184 ) -> None:
185 data = _keygen()
186 assert data["name"] is None
187
188 def test_I7_name_reflects_name_flag(
189 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
190 ) -> None:
191 result = runner.invoke(
192 cli,
193 ["agent", "keygen", "--hub", _TEST_HUB, "--account", "1",
194 "--name", "orchestra", "--json"],
195 )
196 assert result.exit_code == 0
197 data = json.loads(result.output.strip().splitlines()[0])
198 assert data["name"] == "orchestra"
199
200 def test_I8_msign_path_contains_account_index(
201 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
202 ) -> None:
203 result = runner.invoke(
204 cli,
205 ["agent", "keygen", "--hub", _TEST_HUB, "--account", "7", "--json"],
206 )
207 assert result.exit_code == 0
208 data = json.loads(result.output.strip().splitlines()[0])
209 assert "7'" in data["msign_path"]
210 assert data["msign_path"].startswith("m/")
211
212 def test_I9_hub_is_full_url(
213 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
214 ) -> None:
215 data = _keygen()
216 assert data["hub"] == _TEST_HUB
217
218
219 # ---------------------------------------------------------------------------
220 # II list schema
221 # ---------------------------------------------------------------------------
222
223
224 class TestListSchemaII:
225 def test_II1_returns_json_array(
226 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
227 ) -> None:
228 result = runner.invoke(
229 cli, ["agent", "list", "--hub", _TEST_HUB, "--json"]
230 )
231 assert result.exit_code == 0
232 data = json.loads(result.output.strip().splitlines()[0])
233 assert isinstance(data, list)
234
235 def test_II2_empty_array_when_no_slots(
236 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
237 ) -> None:
238 result = runner.invoke(
239 cli, ["agent", "list", "--hub", _TEST_HUB, "--json"]
240 )
241 assert result.exit_code == 0
242 data = json.loads(result.output.strip().splitlines()[0])
243 assert data == []
244
245 def test_II3_each_entry_has_required_keys(
246 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
247 ) -> None:
248 from muse.core.agent_slots import register_slot
249 register_slot(_TEST_HUB, "orchestra", 1)
250 register_slot(_TEST_HUB, "mixer", 2)
251
252 result = runner.invoke(
253 cli, ["agent", "list", "--hub", _TEST_HUB, "--json"]
254 )
255 assert result.exit_code == 0
256 entries = json.loads(result.output.strip().splitlines()[0])
257 assert entries
258 for entry in entries:
259 missing = _LIST_ENTRY_REQUIRED_KEYS - set(entry.keys())
260 assert not missing, f"Missing keys in list entry: {missing}"
261
262 def test_II4_entries_sorted_by_account(
263 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
264 ) -> None:
265 from muse.core.agent_slots import register_slot
266 register_slot(_TEST_HUB, "z-agent", 5)
267 register_slot(_TEST_HUB, "a-agent", 2)
268 register_slot(_TEST_HUB, "m-agent", 9)
269
270 result = runner.invoke(
271 cli, ["agent", "list", "--hub", _TEST_HUB, "--json"]
272 )
273 assert result.exit_code == 0
274 entries = json.loads(result.output.strip().splitlines()[0])
275 accounts = [e["account"] for e in entries]
276 assert accounts == sorted(accounts)
277
278 def test_II5_hub_is_hostname_not_url(
279 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
280 ) -> None:
281 from muse.core.agent_slots import register_slot
282 register_slot(_TEST_HUB, "test-slot", 3)
283
284 result = runner.invoke(
285 cli, ["agent", "list", "--hub", _TEST_HUB, "--json"]
286 )
287 assert result.exit_code == 0
288 entries = json.loads(result.output.strip().splitlines()[0])
289 assert entries
290 for entry in entries:
291 assert entry["hub"] == _TEST_HOSTNAME, (
292 f"Expected hostname {_TEST_HOSTNAME!r}, got {entry['hub']!r}"
293 )
294
295
296 # ---------------------------------------------------------------------------
297 # III register schema
298 # ---------------------------------------------------------------------------
299
300
301 class TestRegisterSchemaIII:
302 def test_III1_all_required_keys_present(
303 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
304 ) -> None:
305 data = _register("orchestra", 1)
306 missing = _REGISTER_REQUIRED_KEYS - set(data.keys())
307 assert not missing, f"Missing keys in register response: {missing}"
308
309 def test_III2_status_is_ok(
310 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
311 ) -> None:
312 data = _register("orchestra", 1)
313 assert data["status"] == "ok"
314
315 def test_III3_hub_is_hostname(
316 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
317 ) -> None:
318 data = _register("test-agent", 4)
319 assert data["hub"] == _TEST_HOSTNAME, (
320 f"Expected hostname {_TEST_HOSTNAME!r}, got {data['hub']!r}"
321 )
322
323 def test_III4_msign_path_contains_account_index(
324 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
325 ) -> None:
326 data = _register("my-agent", 11)
327 assert "11'" in data["msign_path"]
328 assert data["msign_path"].startswith("m/")
329
330
331 # ---------------------------------------------------------------------------
332 # IV Error paths — JSON errors when --json is passed
333 # ---------------------------------------------------------------------------
334
335
336 class TestErrorPathsIV:
337 def test_IV1_keygen_no_identity_json_error(
338 self, isolated_identity: pathlib.Path, isolated_slots: pathlib.Path,
339 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
340 ) -> None:
341 """No identity registered → exit 1 + JSON error on stdout."""
342 result = runner.invoke(
343 cli,
344 ["agent", "keygen", "--hub", _TEST_HUB, "--account", "1", "--json"],
345 )
346 assert result.exit_code == 1
347 # The first JSON line on stdout must parse
348 json_line = next(
349 (ln for ln in result.output.splitlines() if ln.strip().startswith("{")),
350 None,
351 )
352 assert json_line is not None, f"No JSON in output:\n{result.output}"
353 data = json.loads(json_line)
354 assert "error" in data
355
356 def test_IV2_keygen_no_mnemonic_json_error(
357 self, isolated_identity: pathlib.Path, isolated_slots: pathlib.Path,
358 monkeypatch: pytest.MonkeyPatch
359 ) -> None:
360 """Identity exists but has no mnemonic → exit 1 + JSON error."""
361 # Disable keychain so no leftover entry from a previous test run leaks in.
362 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
363 from muse.core.identity import IdentityEntry, save_identity
364 entry: IdentityEntry = {"type": "human", "handle": "gabriel"}
365 save_identity(_TEST_HUB, entry)
366
367 result = runner.invoke(
368 cli,
369 ["agent", "keygen", "--hub", _TEST_HUB, "--account", "1", "--json"],
370 )
371 assert result.exit_code == 1
372 json_line = next(
373 (ln for ln in result.output.splitlines() if ln.strip().startswith("{")),
374 None,
375 )
376 assert json_line is not None, f"No JSON in output:\n{result.output}"
377 data = json.loads(json_line)
378 assert "error" in data
379
380 def test_IV3_keygen_negative_account_json_error(
381 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
382 ) -> None:
383 """Negative account index with --json → exit 1 + JSON error."""
384 result = runner.invoke(
385 cli,
386 ["agent", "keygen", "--hub", _TEST_HUB, "--account", "-1", "--json"],
387 )
388 assert result.exit_code == 1
389 json_line = next(
390 (ln for ln in result.output.splitlines() if ln.strip().startswith("{")),
391 None,
392 )
393 assert json_line is not None, f"No JSON in output:\n{result.output}"
394 data = json.loads(json_line)
395 assert "error" in data
396
397 def test_IV4_keygen_no_hub_json_error(
398 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path,
399 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
400 ) -> None:
401 """No hub configured, no --hub flag, --json → exit 1 + JSON error."""
402 monkeypatch.chdir(tmp_path)
403 result = runner.invoke(
404 cli,
405 ["agent", "keygen", "--account", "1", "--json"],
406 )
407 assert result.exit_code == 1
408 json_line = next(
409 (ln for ln in result.output.splitlines() if ln.strip().startswith("{")),
410 None,
411 )
412 assert json_line is not None, f"No JSON in output:\n{result.output}"
413 data = json.loads(json_line)
414 assert "error" in data
415
416 def test_IV5_error_has_error_key(
417 self, isolated_identity: pathlib.Path, isolated_slots: pathlib.Path
418 ) -> None:
419 """JSON error responses always have an 'error' key."""
420 result = runner.invoke(
421 cli,
422 ["agent", "keygen", "--hub", _TEST_HUB, "--account", "1", "--json"],
423 )
424 assert result.exit_code == 1
425 json_line = next(
426 (ln for ln in result.output.splitlines() if ln.strip().startswith("{")),
427 None,
428 )
429 assert json_line is not None
430 data = json.loads(json_line)
431 assert "error" in data, f"No 'error' key in: {data}"
432 assert isinstance(data["error"], str)
433 assert data["error"] # non-empty
434
435 def test_IV6_error_has_message_key(
436 self, isolated_identity: pathlib.Path, isolated_slots: pathlib.Path
437 ) -> None:
438 """JSON error responses always have a 'message' key."""
439 result = runner.invoke(
440 cli,
441 ["agent", "keygen", "--hub", _TEST_HUB, "--account", "1", "--json"],
442 )
443 assert result.exit_code == 1
444 json_line = next(
445 (ln for ln in result.output.splitlines() if ln.strip().startswith("{")),
446 None,
447 )
448 assert json_line is not None
449 data = json.loads(json_line)
450 assert "message" in data, f"No 'message' key in: {data}"
451 assert isinstance(data["message"], str)
452 assert data["message"] # non-empty
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 141 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 144 days ago