gabriel / muse public
test_core_agent_slots.py python
581 lines 22.0 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
1 """Comprehensive tests for ``muse.core.agent_slots``.
2
3 Covers all eight categories:
4 1. Unit — _toml_escape, _load_raw, _dump round-trip
5 2. Integration — get_next_account, register_slot, list_slots, peek_next_account
6 3. E2E — full read-write cycle through public API with real tmp files
7 4. Stress — 500 sequential accounts, 100-slot registry
8 5. Data integrity — monotonic counter, slot persistence, msign_path format
9 6. Performance — slot operations complete within budget
10 7. Security — symlink guard on write, 0o600 file mode, lock file created
11 8. Docstrings — all public callables and the module have docstrings
12 """
13
14 from __future__ import annotations
15
16 import pathlib
17 import stat
18 import time
19 from typing import Any
20
21 import pytest
22
23 # ---------------------------------------------------------------------------
24 # Constants
25 # ---------------------------------------------------------------------------
26
27 _TEST_HUB = "http://localhost:10003"
28 _TEST_HOSTNAME = "localhost:10003"
29 _TEST_HUB2 = "https://staging.musehub.ai"
30 _TEST_HOSTNAME2 = "staging.musehub.ai"
31
32
33 # ---------------------------------------------------------------------------
34 # Fixtures
35 # ---------------------------------------------------------------------------
36
37
38 @pytest.fixture()
39 def slots_dir(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
40 """Redirect the agent-slots store to a temp directory."""
41 fake_dir = tmp_path / "agent_slots"
42 fake_dir.mkdir()
43 fake_file = fake_dir / "agent-slots.toml"
44
45 monkeypatch.setattr("muse.core.agent_slots._SLOTS_DIR", fake_dir)
46 monkeypatch.setattr("muse.core.agent_slots._SLOTS_FILE", fake_file)
47
48 return fake_dir
49
50
51 @pytest.fixture()
52 def slots_file(slots_dir: pathlib.Path) -> pathlib.Path:
53 """Return the path to the isolated agent-slots.toml."""
54 return slots_dir / "agent-slots.toml"
55
56
57 # ---------------------------------------------------------------------------
58 # 1. Unit — pure helpers
59 # ---------------------------------------------------------------------------
60
61
62 class TestTomlEscape:
63 """Unit tests for _toml_escape."""
64
65 def test_escapes_backslash(self) -> None:
66 from muse.core.agent_slots import _toml_escape
67 assert _toml_escape("a\\b") == "a\\\\b"
68
69 def test_escapes_double_quote(self) -> None:
70 from muse.core.agent_slots import _toml_escape
71 assert _toml_escape('say "hello"') == 'say \\"hello\\"'
72
73 def test_plain_string_unchanged(self) -> None:
74 from muse.core.agent_slots import _toml_escape
75 assert _toml_escape("localhost:10003") == "localhost:10003"
76
77 def test_both_special_chars(self) -> None:
78 from muse.core.agent_slots import _toml_escape
79 raw = 'path\\to\\"file"'
80 escaped = _toml_escape(raw)
81 assert "\\\\" in escaped
82 assert '\\"' in escaped
83
84
85 class TestLoadRaw:
86 """Unit tests for _load_raw."""
87
88 def test_returns_empty_dict_when_absent(self, tmp_path: pathlib.Path) -> None:
89 from muse.core.agent_slots import _load_raw
90 assert _load_raw(tmp_path / "nonexistent.toml") == {}
91
92 def test_returns_empty_dict_on_corrupt_file(self, tmp_path: pathlib.Path) -> None:
93 from muse.core.agent_slots import _load_raw
94 p = tmp_path / "bad.toml"
95 p.write_bytes(b"\xff\xfe corrupt")
96 assert _load_raw(p) == {}
97
98 def test_loads_valid_toml(self, tmp_path: pathlib.Path) -> None:
99 from muse.core.agent_slots import _load_raw
100 p = tmp_path / "slots.toml"
101 p.write_text(
102 '["localhost:10003"]\nnext_account = 3\n',
103 encoding="utf-8",
104 )
105 data = _load_raw(p)
106 assert data["localhost:10003"]["next_account"] == 3
107
108
109 class TestDump:
110 """Unit tests for _dump round-trip."""
111
112 def test_empty_dict_produces_empty_string(self) -> None:
113 from muse.core.agent_slots import _dump
114 assert _dump({}) == ""
115
116 def test_round_trip_preserves_next_account(self) -> None:
117 from muse.core.agent_slots import _dump, _load_raw
118 import tempfile, pathlib as pl
119 data = {"localhost:10003": {"next_account": 7, "slots": {"orchestra": 1}}}
120 text = _dump(data)
121 with tempfile.NamedTemporaryFile(
122 mode="w", suffix=".toml", delete=False, encoding="utf-8"
123 ) as f:
124 f.write(text)
125 tmp = pl.Path(f.name)
126 try:
127 loaded = _load_raw(tmp)
128 assert loaded["localhost:10003"]["next_account"] == 7
129 assert loaded["localhost:10003"]["slots"]["orchestra"] == 1
130 finally:
131 tmp.unlink(missing_ok=True)
132
133 def test_slots_sorted_in_output(self) -> None:
134 from muse.core.agent_slots import _dump
135 data = {
136 "localhost:10003": {
137 "next_account": 5,
138 "slots": {"zzz": 3, "aaa": 1},
139 }
140 }
141 text = _dump(data)
142 pos_aaa = text.index("aaa")
143 pos_zzz = text.index("zzz")
144 assert pos_aaa < pos_zzz, "slots should be alphabetically sorted"
145
146 def test_hostnames_sorted_in_output(self) -> None:
147 from muse.core.agent_slots import _dump
148 data = {
149 "z.example.com": {"next_account": 1},
150 "a.example.com": {"next_account": 2},
151 }
152 text = _dump(data)
153 assert text.index("a.example.com") < text.index("z.example.com")
154
155
156 # ---------------------------------------------------------------------------
157 # 2. Integration — public API with isolated store
158 # ---------------------------------------------------------------------------
159
160
161 class TestGetNextAccount:
162 """Integration tests for get_next_account."""
163
164 def test_first_call_returns_1(self, slots_dir: pathlib.Path) -> None:
165 from muse.core.agent_slots import get_next_account
166 assert get_next_account(_TEST_HUB) == 1
167
168 def test_increments_on_each_call(self, slots_dir: pathlib.Path) -> None:
169 from muse.core.agent_slots import get_next_account
170 a = get_next_account(_TEST_HUB)
171 b = get_next_account(_TEST_HUB)
172 c = get_next_account(_TEST_HUB)
173 assert b == a + 1
174 assert c == b + 1
175
176 def test_independent_per_hub(self, slots_dir: pathlib.Path) -> None:
177 from muse.core.agent_slots import get_next_account
178 a1 = get_next_account(_TEST_HUB)
179 b1 = get_next_account(_TEST_HUB2)
180 a2 = get_next_account(_TEST_HUB)
181 b2 = get_next_account(_TEST_HUB2)
182 assert a1 == 1
183 assert b1 == 1
184 assert a2 == 2
185 assert b2 == 2
186
187 def test_minimum_account_is_1(self, slots_dir: pathlib.Path, slots_file: pathlib.Path) -> None:
188 """Even if the TOML contains 0, we clamp to 1."""
189 from muse.core.agent_slots import get_next_account
190 slots_file.write_text(
191 '["localhost:10003"]\nnext_account = 0\n', encoding="utf-8"
192 )
193 assert get_next_account(_TEST_HUB) == 1
194
195
196 class TestPeekNextAccount:
197 """Integration tests for peek_next_account."""
198
199 def test_does_not_increment(self, slots_dir: pathlib.Path) -> None:
200 from muse.core.agent_slots import peek_next_account, get_next_account
201 v1 = peek_next_account(_TEST_HUB)
202 v2 = peek_next_account(_TEST_HUB)
203 assert v1 == v2 == 1
204 # Now increment and verify peek catches up
205 actual = get_next_account(_TEST_HUB)
206 assert actual == 1
207 assert peek_next_account(_TEST_HUB) == 2
208
209 def test_returns_1_when_file_absent(self, slots_dir: pathlib.Path) -> None:
210 from muse.core.agent_slots import peek_next_account
211 assert peek_next_account(_TEST_HUB) == 1
212
213 def test_reflects_current_state(self, slots_dir: pathlib.Path) -> None:
214 from muse.core.agent_slots import get_next_account, peek_next_account
215 for _ in range(5):
216 get_next_account(_TEST_HUB)
217 assert peek_next_account(_TEST_HUB) == 6
218
219
220 class TestRegisterSlot:
221 """Integration tests for register_slot."""
222
223 def test_register_returns_slot_with_correct_fields(
224 self, slots_dir: pathlib.Path
225 ) -> None:
226 from muse.core.agent_slots import register_slot
227 slot = register_slot(_TEST_HUB, "orchestra", 1)
228 assert slot["name"] == "orchestra"
229 assert slot["account"] == 1
230 assert slot["hub"] == _TEST_HOSTNAME
231 assert "msign_path" in slot
232
233 def test_msign_path_contains_account(self, slots_dir: pathlib.Path) -> None:
234 from muse.core.agent_slots import register_slot
235 slot = register_slot(_TEST_HUB, "mixer", 7)
236 assert "7'" in slot["msign_path"]
237
238 def test_msign_path_starts_with_m(self, slots_dir: pathlib.Path) -> None:
239 from muse.core.agent_slots import register_slot
240 slot = register_slot(_TEST_HUB, "test", 2)
241 assert slot["msign_path"].startswith("m/")
242
243 def test_overwrite_updates_account(self, slots_dir: pathlib.Path) -> None:
244 from muse.core.agent_slots import register_slot, list_slots
245 register_slot(_TEST_HUB, "alpha", 1)
246 register_slot(_TEST_HUB, "alpha", 99)
247 slots = list_slots(_TEST_HUB)
248 matched = [s for s in slots if s["name"] == "alpha"]
249 assert len(matched) == 1
250 assert matched[0]["account"] == 99
251
252 def test_persists_to_file(self, slots_dir: pathlib.Path, slots_file: pathlib.Path) -> None:
253 from muse.core.agent_slots import register_slot
254 register_slot(_TEST_HUB, "persistent", 3)
255 assert slots_file.exists()
256 content = slots_file.read_text(encoding="utf-8")
257 assert "persistent" in content
258
259
260 class TestListSlots:
261 """Integration tests for list_slots."""
262
263 def test_empty_when_no_file(self, slots_dir: pathlib.Path) -> None:
264 from muse.core.agent_slots import list_slots
265 assert list_slots(_TEST_HUB) == []
266
267 def test_returns_registered_slots(self, slots_dir: pathlib.Path) -> None:
268 from muse.core.agent_slots import register_slot, list_slots
269 register_slot(_TEST_HUB, "a", 1)
270 register_slot(_TEST_HUB, "b", 2)
271 slots = list_slots(_TEST_HUB)
272 assert len(slots) == 2
273
274 def test_sorted_by_account_ascending(self, slots_dir: pathlib.Path) -> None:
275 from muse.core.agent_slots import register_slot, list_slots
276 register_slot(_TEST_HUB, "z", 10)
277 register_slot(_TEST_HUB, "a", 3)
278 register_slot(_TEST_HUB, "m", 7)
279 slots = list_slots(_TEST_HUB)
280 accounts = [s["account"] for s in slots]
281 assert accounts == sorted(accounts)
282
283 def test_isolated_per_hub(self, slots_dir: pathlib.Path) -> None:
284 from muse.core.agent_slots import register_slot, list_slots
285 register_slot(_TEST_HUB, "hub1-agent", 1)
286 register_slot(_TEST_HUB2, "hub2-agent", 2)
287 slots1 = list_slots(_TEST_HUB)
288 slots2 = list_slots(_TEST_HUB2)
289 assert len(slots1) == 1 and slots1[0]["name"] == "hub1-agent"
290 assert len(slots2) == 1 and slots2[0]["name"] == "hub2-agent"
291
292 def test_all_fields_present(self, slots_dir: pathlib.Path) -> None:
293 from muse.core.agent_slots import register_slot, list_slots
294 register_slot(_TEST_HUB, "full-check", 5)
295 slot = list_slots(_TEST_HUB)[0]
296 assert "name" in slot
297 assert "account" in slot
298 assert "hub" in slot
299 assert "msign_path" in slot
300
301
302 # ---------------------------------------------------------------------------
303 # 3. E2E — full read-write cycle with real tmp files
304 # ---------------------------------------------------------------------------
305
306
307 class TestE2EReadWriteCycle:
308 """End-to-end: write through public API, read back through public API."""
309
310 def test_get_register_list_roundtrip(self, slots_dir: pathlib.Path) -> None:
311 from muse.core.agent_slots import get_next_account, register_slot, list_slots
312
313 acct = get_next_account(_TEST_HUB)
314 register_slot(_TEST_HUB, "roundtrip", acct)
315 slots = list_slots(_TEST_HUB)
316 assert any(s["name"] == "roundtrip" and s["account"] == acct for s in slots)
317
318 def test_peek_before_and_after_register(self, slots_dir: pathlib.Path) -> None:
319 from muse.core.agent_slots import get_next_account, peek_next_account, register_slot
320
321 before = peek_next_account(_TEST_HUB)
322 acct = get_next_account(_TEST_HUB)
323 assert acct == before
324 register_slot(_TEST_HUB, "e2e", acct)
325 after = peek_next_account(_TEST_HUB)
326 assert after == acct + 1
327
328 def test_multiple_hubs_in_same_file(self, slots_dir: pathlib.Path) -> None:
329 from muse.core.agent_slots import register_slot, list_slots, get_next_account
330
331 register_slot(_TEST_HUB, "local-agent", get_next_account(_TEST_HUB))
332 register_slot(_TEST_HUB2, "staging-agent", get_next_account(_TEST_HUB2))
333
334 local = list_slots(_TEST_HUB)
335 staging = list_slots(_TEST_HUB2)
336 assert local[0]["name"] == "local-agent"
337 assert staging[0]["name"] == "staging-agent"
338
339 def test_file_contains_both_hubs(self, slots_dir: pathlib.Path, slots_file: pathlib.Path) -> None:
340 from muse.core.agent_slots import register_slot
341
342 register_slot(_TEST_HUB, "a", 1)
343 register_slot(_TEST_HUB2, "b", 1)
344 content = slots_file.read_text(encoding="utf-8")
345 assert _TEST_HOSTNAME in content
346 assert _TEST_HOSTNAME2 in content
347
348
349 # ---------------------------------------------------------------------------
350 # 4. Stress — many accounts and slots
351 # ---------------------------------------------------------------------------
352
353
354 class TestStress:
355 """Stress tests."""
356
357 def test_500_sequential_get_next_account(self, slots_dir: pathlib.Path) -> None:
358 from muse.core.agent_slots import get_next_account
359 accounts = [get_next_account(_TEST_HUB) for _ in range(500)]
360 assert accounts == list(range(1, 501))
361
362 def test_100_slots_registered_and_listed(self, slots_dir: pathlib.Path) -> None:
363 from muse.core.agent_slots import register_slot, list_slots
364 for i in range(1, 101):
365 register_slot(_TEST_HUB, f"agent-{i:03d}", i)
366 slots = list_slots(_TEST_HUB)
367 assert len(slots) == 100
368
369 def test_repeated_peek_is_stable(self, slots_dir: pathlib.Path) -> None:
370 from muse.core.agent_slots import peek_next_account
371 values = {peek_next_account(_TEST_HUB) for _ in range(100)}
372 assert values == {1}
373
374 def test_interleaved_hubs_stay_independent(self, slots_dir: pathlib.Path) -> None:
375 from muse.core.agent_slots import get_next_account
376 for i in range(50):
377 get_next_account(_TEST_HUB)
378 get_next_account(_TEST_HUB2)
379 from muse.core.agent_slots import peek_next_account
380 assert peek_next_account(_TEST_HUB) == 51
381 assert peek_next_account(_TEST_HUB2) == 51
382
383
384 # ---------------------------------------------------------------------------
385 # 5. Data integrity
386 # ---------------------------------------------------------------------------
387
388
389 class TestDataIntegrity:
390 """Data integrity tests."""
391
392 def test_counter_is_monotonic(self, slots_dir: pathlib.Path) -> None:
393 from muse.core.agent_slots import get_next_account
394 prev = 0
395 for _ in range(20):
396 cur = get_next_account(_TEST_HUB)
397 assert cur > prev
398 prev = cur
399
400 def test_peek_never_exceeds_next(self, slots_dir: pathlib.Path) -> None:
401 from muse.core.agent_slots import get_next_account, peek_next_account
402 for _ in range(10):
403 get_next_account(_TEST_HUB)
404 assert peek_next_account(_TEST_HUB) == 11
405
406 def test_slot_hub_field_is_hostname_not_url(self, slots_dir: pathlib.Path) -> None:
407 from muse.core.agent_slots import register_slot
408 slot = register_slot(_TEST_HUB, "test", 1)
409 assert slot["hub"] == _TEST_HOSTNAME
410 assert "http://" not in slot["hub"]
411
412 def test_msign_path_schema(self, slots_dir: pathlib.Path) -> None:
413 """msign_path must be m/purpose'/domain'/entity_agent'/account'."""
414 from muse.core.agent_slots import register_slot
415 from muse.core.hdkeys import DOMAIN_IDENTITY, ENTITY_AGENT, MUSE_PURPOSE
416 slot = register_slot(_TEST_HUB, "schema-check", 4)
417 expected = f"m/{MUSE_PURPOSE}'/{DOMAIN_IDENTITY}'/{ENTITY_AGENT}'/4'"
418 assert slot["msign_path"] == expected
419
420 def test_file_permissions_after_register(
421 self, slots_dir: pathlib.Path, slots_file: pathlib.Path
422 ) -> None:
423 from muse.core.agent_slots import register_slot
424 register_slot(_TEST_HUB, "perm", 1)
425 mode = stat.S_IMODE(slots_file.stat().st_mode)
426 assert mode == 0o600
427
428 def test_next_account_survives_restart(self, slots_dir: pathlib.Path) -> None:
429 """Simulate process restart: counter must be read back from file."""
430 from muse.core.agent_slots import get_next_account, _SLOTS_FILE, _load_raw
431 for _ in range(5):
432 get_next_account(_TEST_HUB)
433 # Reload from file as a fresh process would
434 data = _load_raw(_SLOTS_FILE)
435 assert data[_TEST_HOSTNAME]["next_account"] == 6
436
437
438 # ---------------------------------------------------------------------------
439 # 6. Performance
440 # ---------------------------------------------------------------------------
441
442
443 class TestPerformance:
444 """Performance tests."""
445
446 def test_100_get_next_account_under_3_seconds(
447 self, slots_dir: pathlib.Path
448 ) -> None:
449 from muse.core.agent_slots import get_next_account
450 start = time.monotonic()
451 for _ in range(100):
452 get_next_account(_TEST_HUB)
453 elapsed = time.monotonic() - start
454 assert elapsed < 3.0, f"100 increments took {elapsed:.3f}s"
455
456 def test_100_register_slot_under_3_seconds(
457 self, slots_dir: pathlib.Path
458 ) -> None:
459 from muse.core.agent_slots import register_slot
460 start = time.monotonic()
461 for i in range(1, 101):
462 register_slot(_TEST_HUB, f"perf-{i}", i)
463 elapsed = time.monotonic() - start
464 assert elapsed < 3.0, f"100 register_slot calls took {elapsed:.3f}s"
465
466 def test_list_100_slots_under_1_second(self, slots_dir: pathlib.Path) -> None:
467 from muse.core.agent_slots import register_slot, list_slots
468 for i in range(1, 101):
469 register_slot(_TEST_HUB, f"agent-{i}", i)
470 start = time.monotonic()
471 slots = list_slots(_TEST_HUB)
472 elapsed = time.monotonic() - start
473 assert len(slots) == 100
474 assert elapsed < 1.0, f"list_slots for 100 entries took {elapsed:.3f}s"
475
476
477 # ---------------------------------------------------------------------------
478 # 7. Security
479 # ---------------------------------------------------------------------------
480
481
482 class TestSecurity:
483 """Security tests."""
484
485 def test_symlink_guard_blocks_write(
486 self, slots_dir: pathlib.Path, slots_file: pathlib.Path
487 ) -> None:
488 """Writing must refuse if agent-slots.toml is a symlink."""
489 decoy = slots_dir / "decoy.toml"
490 decoy.write_text("", encoding="utf-8")
491 slots_file.symlink_to(decoy)
492
493 from muse.core.agent_slots import register_slot
494 with pytest.raises(OSError, match="symlink"):
495 register_slot(_TEST_HUB, "evil", 1)
496
497 def test_file_mode_is_0600(
498 self, slots_dir: pathlib.Path, slots_file: pathlib.Path
499 ) -> None:
500 from muse.core.agent_slots import register_slot
501 register_slot(_TEST_HUB, "mode-check", 1)
502 mode = stat.S_IMODE(slots_file.stat().st_mode)
503 assert mode == 0o600, f"Expected 0o600, got {oct(mode)}"
504
505 def test_lock_file_created_in_slots_dir(
506 self, slots_dir: pathlib.Path
507 ) -> None:
508 from muse.core.agent_slots import get_next_account
509 get_next_account(_TEST_HUB)
510 lock = slots_dir / ".agent-slots.lock"
511 assert lock.exists()
512
513 def test_concurrent_writes_no_data_loss(self, slots_dir: pathlib.Path) -> None:
514 """Two threads incrementing the counter must not produce duplicates."""
515 import threading
516 from muse.core.agent_slots import get_next_account
517
518 results: list[int] = []
519 lock = threading.Lock()
520
521 def worker() -> None:
522 for _ in range(25):
523 v = get_next_account(_TEST_HUB)
524 with lock:
525 results.append(v)
526
527 threads = [threading.Thread(target=worker) for _ in range(4)]
528 for t in threads:
529 t.start()
530 for t in threads:
531 t.join()
532
533 assert len(results) == 100
534 assert len(set(results)) == 100, "Concurrent increments produced duplicates!"
535 assert sorted(results) == list(range(1, 101))
536
537 def test_toml_injection_in_hub_hostname_escaped(
538 self, slots_dir: pathlib.Path
539 ) -> None:
540 """Hub hostnames with TOML special chars must be escaped in output."""
541 from muse.core.agent_slots import register_slot, list_slots, _dump, _load_raw, _SLOTS_FILE
542 # Use a hostname containing a double-quote (pathological but valid to escape)
543 tricky_hub = 'host"with"quotes:8080'
544 register_slot(tricky_hub, "injector", 1)
545 # File must be loadable (no TOML parse error)
546 data = _load_raw(_SLOTS_FILE)
547 assert tricky_hub in data
548
549
550 # ---------------------------------------------------------------------------
551 # 8. Docstrings
552 # ---------------------------------------------------------------------------
553
554
555 class TestDocstrings:
556 """Verify every public callable in agent_slots.py has a docstring."""
557
558 def _public_names(self) -> list[tuple[str, Any]]:
559 import inspect
560 import muse.core.agent_slots as mod
561 return [
562 (name, obj)
563 for name, obj in inspect.getmembers(mod)
564 if not name.startswith("_")
565 and (inspect.isfunction(obj) or inspect.isclass(obj))
566 and obj.__module__ == mod.__name__
567 ]
568
569 def test_all_public_functions_have_docstrings(self) -> None:
570 for name, obj in self._public_names():
571 assert obj.__doc__, (
572 f"muse.core.agent_slots.{name} is missing a docstring"
573 )
574
575 def test_module_has_docstring(self) -> None:
576 import muse.core.agent_slots as mod
577 assert mod.__doc__, "muse.core.agent_slots module is missing a docstring"
578
579 def test_agent_slot_typed_dict_has_docstring(self) -> None:
580 from muse.core.agent_slots import AgentSlot
581 assert AgentSlot.__doc__
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 143 days ago