diff --git a/scripts/make-detection-fixture.py b/scripts/make-detection-fixture.py index 8b4399604..a5b4e9110 100755 --- a/scripts/make-detection-fixture.py +++ b/scripts/make-detection-fixture.py @@ -71,6 +71,30 @@ def canonical_tail(content: str, limit: int = DETECTOR_TAIL_LIMIT) -> str: return "\n".join(lines[start:]) +# The runtime names `DetectedAgent` carries, which are also the `` path +# component of a fixture. Two of them are not the case name (`cursor-agent`, +# `qodercli`), so an unconstrained flag is easy to get wrong — and every wrong +# value would take the bounded-tail branch below without saying so. +# `test_agent_vocabulary_matches_the_swift_enum` fails when this list drifts. +DETECTED_AGENTS = ( + "pi", + "omp", + "claude", + "codex", + "gemini", + "cursor-agent", + "cline", + "opencode", + "copilot", + "kimi", + "droid", + "amp", + "qodercli", + "qwen", + "grok", +) + + def detection_screen_text(text: str, agent: str) -> str: """Port of `DetectedAgent.detectionScreenText(from:)`. @@ -234,10 +258,11 @@ def main() -> int: parser.add_argument( "--agent", required=True, + choices=DETECTED_AGENTS, metavar="AGENT", help="detector the fixture targets (the path component, e.g. claude " "or codex); claude keeps the full screen, every other agent takes the " - "bounded 24-line tail", + "bounded 24-line tail. One of: " + ", ".join(DETECTED_AGENTS), ) parser.add_argument( "--redact", diff --git a/scripts/test_make_detection_fixture.py b/scripts/test_make_detection_fixture.py index 90875f899..da3707619 100644 --- a/scripts/test_make_detection_fixture.py +++ b/scripts/test_make_detection_fixture.py @@ -15,12 +15,16 @@ import importlib.util import json import pathlib +import re import subprocess import sys import tempfile import unittest SCRIPT = pathlib.Path(__file__).resolve().parent / "make-detection-fixture.py" +DETECTED_AGENT_SWIFT = ( + SCRIPT.parent.parent / "supacode/Domain/AgentDetection/DetectedAgent.swift" +) _spec = importlib.util.spec_from_file_location("make_detection_fixture", SCRIPT) fixture = importlib.util.module_from_spec(_spec) @@ -116,6 +120,18 @@ def test_several_amounts_on_one_row(self): self.assertEqual(len(applied), 2) + +class AgentVocabulary(unittest.TestCase): + def test_agent_vocabulary_matches_the_swift_enum(self): + # The generator mirrors the detector's dispatch, so its agent names have + # to be the detector's. A hand-copied list drifts silently; this fails. + source = DETECTED_AGENT_SWIFT.read_text(encoding="utf-8") + body = source.split("enum DetectedAgent", 1)[1].split("var id:", 1)[0] + cases = re.findall(r'^\s*case (\w+)(?:\s*=\s*"([^"]+)")?\s*$', body, re.M) + self.assertTrue(cases, "no cases parsed from DetectedAgent.swift") + self.assertEqual(list(fixture.DETECTED_AGENTS), [raw or name for name, raw in cases]) + + class Reduction(unittest.TestCase): def test_claude_keeps_the_full_screen(self): # Mirrors `DetectedAgent.detectionScreenText(from:)`: trimming a Claude @@ -175,6 +191,13 @@ def test_summary_never_names_what_it_replaced(self): self.assertIn("/Users/usr", result.stderr) self.assertIn("1 line", result.stderr) + def test_unknown_agent_is_rejected(self): + # Before the flag was constrained, "Claude" was not "claude" and so took + # the bounded-tail branch, silently dropping rows from a Claude capture. + result = self.run_script("row", agent="Claude") + self.assertEqual(result.returncode, 2) + self.assertIn("invalid choice", result.stderr) + def test_undecidable_replacement_fails_the_run(self): result = self.run_script("│ owner name │", "--redact", "owner=❤️") self.assertEqual(result.returncode, 1)