Skip to content

Commit 5712e2d

Browse files
test(harness): add the six-adapter parity guard; retire aider/gemini as test exemplars
test_adapter_parity.py holds the one literal six-set and asserts every layer that enumerates harnesses or sources equals it: RELEASE_HARNESSES, HARNESSES, SESSION_SOURCE_BY_HARNESS, doctor TOOL_AGENTS, installers._TOOL_LINKS, the exporter registry, the export CLI's SOURCE_CHOICES, the extractor's STUDY_SOURCES, the ACP subset, and the README's harness sentence. Before this, 80d24e4 could drop grok from the launch layers while the exporter kept it and nothing failed; a probe with grok removed from the ruling now fails five of six tests with the drifting layer named. Several tests used `aider` and `gemini` as throwaway exemplars -- a made-up custom adapter, a mock agent, a stubbed install-hint payload -- which read as support and polluted the residue scan. They are now `exampletool`, except the not-installed exemplar in test_agent_launcher.py, which must be a REGISTERED name to reach the branch it covers (detect_agents gates on `env_agent in AGENTS`), so it uses `grok` with shutil.which patched to None and says why. Negative guards that assert a retired name is rejected are kept unchanged. test_install_agent_contracts drops the dead `ignored_names = {"GEMINI.md"}` exclusion: no installer source produces that file any more.
1 parent 4bed875 commit 5712e2d

8 files changed

Lines changed: 131 additions & 29 deletions

‎packages/studyloop/tests/test_acp_normaliser.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def test_no_aliases_registered_today(self) -> None:
3636

3737
def test_rewrite_is_identity_when_no_alias(self) -> None:
3838
assert rewrite_outbound_method("session/new", "kiro") == "session/new"
39-
assert rewrite_outbound_method("session/prompt", "gemini") == "session/prompt"
39+
assert rewrite_outbound_method("session/prompt", "grok") == "session/prompt"
4040

4141
def test_rewrite_uses_alias_when_registered(self, monkeypatch: pytest.MonkeyPatch) -> None:
4242
"""Smoke-test the extension point for a future CLI that reverts."""

‎packages/studyloop/tests/test_adapter_custom.py‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,39 +11,47 @@ class TestCustomAgentFromConfig:
1111
def test_cli_flag_strategy(self, tmp_path):
1212
from studyloop.adapters._custom import build_custom_adapter
1313

14-
config = {"binary": "aider", "strategy": "cli-flag", "launch": "{binary} --read {persona}"}
15-
adapter = build_custom_adapter("aider", config)
16-
assert adapter.name == "aider"
17-
assert adapter.binary == "aider"
14+
config = {
15+
"binary": "exampletool",
16+
"strategy": "cli-flag",
17+
"launch": "{binary} --read {persona}",
18+
}
19+
adapter = build_custom_adapter("exampletool", config)
20+
assert adapter.name == "exampletool"
21+
assert adapter.binary == "exampletool"
1822
path = adapter.setup("# content", tmp_path)
1923
assert path.exists()
2024
assert stat.S_IMODE(path.stat().st_mode) == 0o600
2125

2226
def test_launch_cmd_interpolation(self, tmp_path):
2327
from studyloop.adapters._custom import build_custom_adapter
2428

25-
config = {"binary": "aider", "strategy": "cli-flag", "launch": "{binary} --read {persona}"}
26-
adapter = build_custom_adapter("aider", config)
29+
config = {
30+
"binary": "exampletool",
31+
"strategy": "cli-flag",
32+
"launch": "{binary} --read {persona}",
33+
}
34+
adapter = build_custom_adapter("exampletool", config)
2735
persona = tmp_path / "persona.md"
2836
persona.touch()
29-
with patch("shutil.which", return_value="/usr/local/bin/aider"):
37+
with patch("shutil.which", return_value="/usr/local/bin/exampletool"):
3038
cmd = adapter.launch_cmd(persona, False)
31-
assert "/usr/local/bin/aider" in cmd
39+
assert "/usr/local/bin/exampletool" in cmd
3240
assert str(persona) in cmd
3341

3442
def test_resume_template(self, tmp_path):
3543
from studyloop.adapters._custom import build_custom_adapter
3644

3745
config = {
38-
"binary": "aider",
46+
"binary": "exampletool",
3947
"strategy": "cli-flag",
4048
"launch": "{binary} --read {persona}",
4149
"resume": "{binary} --read {persona} --resume",
4250
}
43-
adapter = build_custom_adapter("aider", config)
51+
adapter = build_custom_adapter("exampletool", config)
4452
persona = tmp_path / "p.md"
4553
persona.touch()
46-
with patch("shutil.which", return_value="/usr/bin/aider"):
54+
with patch("shutil.which", return_value="/usr/bin/exampletool"):
4755
cmd = adapter.launch_cmd(persona, True)
4856
assert "--resume" in cmd
4957

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Parity guard: the six supported adapters agree at every layer.
2+
3+
Andy's 2026-09-10 ruling fixes the supported set at exactly kiro, claude,
4+
codex, opencode, pi and grok (Grok Build) -- see
5+
``docs/architecture/session-memory/receipts/adapter-scope-2026-09-10.md``.
6+
7+
Before this guard, the set lived in six places that could each drift alone:
8+
the harness contract, the doctor table, the installer links, the exporter
9+
registry, the export CLI's ``--sources`` choices, and the extractor's source
10+
filter. ``80d24e48`` dropped grok from the launch layers while the exporter
11+
kept it, and nothing failed. This file holds the ONE literal; every layer
12+
must equal it, so the next drift fails here with the layer named.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import re
18+
from pathlib import Path
19+
20+
from agent_session_tools.export_sessions import SOURCE_CHOICES
21+
from agent_session_tools.exporters import EXPORTERS
22+
from studyloop import installers
23+
from studyloop.doctor.agents import TOOL_AGENTS
24+
from studyloop.extractors.pipeline import STUDY_SOURCES
25+
from studyloop.harnesses import HARNESSES, RELEASE_HARNESSES, SESSION_SOURCE_BY_HARNESS
26+
from studyloop.web.services.session_start import ACP_CAPABLE_AGENTS
27+
28+
#: The ruling. Harness ids as used by ``harnesses.py`` and the exporter registry.
29+
EXPECTED_HARNESSES = frozenset({"kiro", "claude", "codex", "opencode", "pi", "grok"})
30+
31+
#: The ``sessions.source`` labels those six harnesses write. Two differ from
32+
#: the harness id (``kiro_cli``, ``claude_code``); the rest coincide.
33+
EXPECTED_SOURCES = frozenset({"kiro_cli", "claude_code", "codex", "opencode", "pi", "grok"})
34+
35+
REPO_ROOT = Path(__file__).resolve().parents[3]
36+
37+
38+
def _layer_diff(name: str, actual: set[str]) -> str:
39+
return (
40+
f"{name} disagrees with the six-adapter ruling: "
41+
f"missing {sorted(EXPECTED_HARNESSES - actual)}, "
42+
f"unexpected {sorted(actual - EXPECTED_HARNESSES)}"
43+
)
44+
45+
46+
def test_release_contract_is_exactly_the_six() -> None:
47+
assert len(RELEASE_HARNESSES) == len(set(RELEASE_HARNESSES)), "duplicate harness id"
48+
assert set(RELEASE_HARNESSES) == EXPECTED_HARNESSES, _layer_diff(
49+
"RELEASE_HARNESSES", set(RELEASE_HARNESSES)
50+
)
51+
52+
53+
def test_launch_layers_enumerate_the_same_six() -> None:
54+
layers = {
55+
"harnesses.HARNESSES": set(HARNESSES),
56+
"harnesses.SESSION_SOURCE_BY_HARNESS": set(SESSION_SOURCE_BY_HARNESS),
57+
"doctor.agents.TOOL_AGENTS": set(TOOL_AGENTS),
58+
"installers._TOOL_LINKS": set(installers._TOOL_LINKS),
59+
}
60+
for name, actual in layers.items():
61+
assert actual == EXPECTED_HARNESSES, _layer_diff(name, actual)
62+
63+
64+
def test_session_export_layers_enumerate_the_same_six() -> None:
65+
assert set(EXPORTERS) == EXPECTED_HARNESSES, _layer_diff("exporters.EXPORTERS", set(EXPORTERS))
66+
assert set(SOURCE_CHOICES) == EXPECTED_HARNESSES, _layer_diff(
67+
"export_sessions.SOURCE_CHOICES", set(SOURCE_CHOICES)
68+
)
69+
70+
71+
def test_source_labels_agree_between_contract_and_extractor() -> None:
72+
assert set(SESSION_SOURCE_BY_HARNESS.values()) == EXPECTED_SOURCES
73+
assert STUDY_SOURCES == EXPECTED_SOURCES, (
74+
"extractors.pipeline.STUDY_SOURCES must be derived from the harness contract: "
75+
f"missing {sorted(EXPECTED_SOURCES - STUDY_SOURCES)}, "
76+
f"unexpected {sorted(STUDY_SOURCES - EXPECTED_SOURCES)}"
77+
)
78+
79+
80+
def test_acp_capable_set_is_a_subset_of_the_six() -> None:
81+
assert ACP_CAPABLE_AGENTS <= EXPECTED_HARNESSES, sorted(ACP_CAPABLE_AGENTS - EXPECTED_HARNESSES)
82+
83+
84+
def test_readme_names_every_supported_harness_by_its_label() -> None:
85+
"""The README is the first thing a new user reads; it must name all six."""
86+
text = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
87+
missing = [
88+
harness.label
89+
for harness in HARNESSES.values()
90+
if not re.search(rf"\b{re.escape(harness.label)}\b", text)
91+
]
92+
assert not missing, f"README.md does not name these supported harnesses: {missing}"

‎packages/studyloop/tests/test_agent_launcher.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,12 @@ def test_detect_respects_env_var_override(self):
122122
def test_detect_env_var_not_installed_returns_empty(self):
123123
from studyloop.agent_launcher import detect_agents
124124

125+
# The name must be a REGISTERED harness: detect_agents() only takes the
126+
# env-override branch when STUDYLOOP_AGENT is in AGENTS, so an unknown
127+
# name would fall through and pass for the wrong reason.
125128
with (
126129
patch("studyloop.agent_launcher.shutil.which", return_value=None),
127-
patch.dict(os.environ, {"STUDYLOOP_AGENT": "gemini"}),
130+
patch.dict(os.environ, {"STUDYLOOP_AGENT": "grok"}),
128131
):
129132
agents = detect_agents()
130133
assert agents == []

‎packages/studyloop/tests/test_install_agent_contracts.py‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,12 @@ def _is_installed_source(source: str, installer_sources: set[str]) -> bool:
8383

8484
def _definition_sources_by_tool() -> dict[str, set[str]]:
8585
definition_names = {"AGENTS.md", "socratic-mentor.md", "study-mentor.json", "study-mentor.md"}
86-
ignored_names = {"GEMINI.md"}
8786
result: dict[str, set[str]] = {}
8887
for tool, sources in _installer_sources_by_tool().items():
8988
result[tool] = {
9089
source
9190
for source in sources
92-
if Path(source).name in definition_names
93-
and Path(source).name not in ignored_names
94-
and "/skills/" not in source
91+
if Path(source).name in definition_names and "/skills/" not in source
9592
}
9693
return result
9794

‎packages/studyloop/tests/test_session_cleanup.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,8 @@ def test_skips_when_adapter_has_no_teardown(self):
261261
"""Adapter without a teardown attr (None/falsy) is silently skipped."""
262262
mock_adapter = MagicMock()
263263
mock_adapter.teardown = None
264-
state = {"agent": "gemini", "session_dir": "/some/dir"}
265-
with patch("studyloop.agent_launcher.AGENTS", {"gemini": mock_adapter}):
264+
state = {"agent": "exampletool", "session_dir": "/some/dir"}
265+
with patch("studyloop.agent_launcher.AGENTS", {"exampletool": mock_adapter}):
266266
from studyloop.session.cleanup import _teardown_agent
267267

268268
_teardown_agent(state) # must not raise

‎packages/studyloop/tests/test_settings_custom.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -553,8 +553,8 @@ def test_custom_agents_parsed(tmp_path):
553553
"agents": {
554554
"priority": ["claude"],
555555
"custom": {
556-
"aider": {
557-
"binary": "aider",
556+
"exampletool": {
557+
"binary": "exampletool",
558558
"strategy": "cli-flag",
559559
"launch": "{binary} --read {persona}",
560560
}
@@ -564,8 +564,8 @@ def test_custom_agents_parsed(tmp_path):
564564
config_path = _write_config(tmp_path, config)
565565
s = _load(config_path)
566566

567-
assert "aider" in s.agents.custom
568-
assert s.agents.custom["aider"]["binary"] == "aider"
567+
assert "exampletool" in s.agents.custom
568+
assert s.agents.custom["exampletool"]["binary"] == "exampletool"
569569

570570

571571
def test_custom_defaults_to_empty():

‎packages/studyloop/tests/test_web_session_lifecycle.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -468,10 +468,12 @@ def test_503_install_hint_surfaces_to_picker_error(self, web_page: Page) -> None
468468
web_page,
469469
status=503,
470470
body={
471-
"error": "Agent 'gemini' binary not found: gemini",
472-
"agent": "gemini",
473-
"binary": "gemini",
474-
"install_hint": "Install the Gemini CLI: https://example.com",
471+
"error": "Agent 'exampletool' binary not found: exampletool",
472+
"agent": "exampletool",
473+
"binary": "exampletool",
474+
"install_hint": (
475+
"Install the 'exampletool' CLI and ensure 'exampletool' is on PATH."
476+
),
475477
},
476478
)
477479
_goto_picker(web_page)
@@ -489,8 +491,8 @@ def test_503_install_hint_surfaces_to_picker_error(self, web_page: Page) -> None
489491
err = web_page.locator(".picker-error")
490492
err.wait_for(state="visible", timeout=5000)
491493
text = err.text_content() or ""
492-
assert "gemini" in text
493-
assert "Install the Gemini CLI" in text
494+
assert "exampletool" in text
495+
assert "Install the 'exampletool' CLI" in text
494496

495497
def test_409_already_active_surfaces_cleanly(self, web_page: Page) -> None:
496498
_stub_options(web_page)

0 commit comments

Comments
 (0)