diff --git a/scripts/make-detection-fixture.py b/scripts/make-detection-fixture.py index 2200cfa0..8b439960 100755 --- a/scripts/make-detection-fixture.py +++ b/scripts/make-detection-fixture.py @@ -3,8 +3,15 @@ Implements steps 3, 6, and 7 of the capture-and-promotion procedure in `supacodeTests/Fixtures/AgentScreenDetection/README.md`: it rejects a capture -that is not detector-faithful, reduces the screen to the same tail the detector -reads, and applies redactions without changing the visible width of any line. +that is not detector-faithful, reduces the screen to the exact slice the +detector reads for the given agent, and applies redactions without changing +the visible width of any line. + +The reduction mirrors `DetectedAgent.detectionScreenText(from:)`: `claude` +consumes the full active screen and is passed through untrimmed, while every +other agent consumes the bounded 24-line tail. `--agent` selects between the +two, and is required because the capture does not record which detector will +consume it. Width matters. A fixture exists to pin how the classifier reads a real screen, and the agent CLIs wrap, shorten, and truncate their rows to the terminal width. @@ -15,6 +22,7 @@ either preserves the line's visible width or is refused. Usage: scripts/make-detection-fixture.py capture.json \\ + --agent claude \\ --redact /Users/me=/Users/usr \\ --redact 'Acme Inc=' \\ > supacodeTests/Fixtures/AgentScreenDetection/claude/2.1.226/idle/composer.txt @@ -63,6 +71,16 @@ def canonical_tail(content: str, limit: int = DETECTOR_TAIL_LIMIT) -> str: return "\n".join(lines[start:]) +def detection_screen_text(text: str, agent: str) -> str: + """Port of `DetectedAgent.detectionScreenText(from:)`. + + `claude` consumes the full active screen; every other agent consumes the + bounded tail. The special case is deliberate on the Swift side: trimming a + Claude capture can delete the very row that reproduces a bug. + """ + return text if agent == "claude" else canonical_tail(text) + + class FixtureError(Exception): """A capture could not be turned into a faithful fixture.""" @@ -213,6 +231,14 @@ def main() -> int: parser.add_argument( "capture", help="JSON emitted by `prowl read --source detection --json`" ) + parser.add_argument( + "--agent", + required=True, + 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", + ) parser.add_argument( "--redact", action="append", @@ -247,7 +273,8 @@ def main() -> int: # text back into the repository the redaction was protecting. applied: dict[str, int] = {} out_lines = [] - for number, line in enumerate(canonical_tail(data["text"]).split("\n"), start=1): + reduced = detection_screen_text(data["text"], args.agent) + for number, line in enumerate(reduced.split("\n"), start=1): for old, new in args.redact: try: replaced = substitute(line, old, new) diff --git a/scripts/test_make_detection_fixture.py b/scripts/test_make_detection_fixture.py index ab2f8a62..90875f89 100644 --- a/scripts/test_make_detection_fixture.py +++ b/scripts/test_make_detection_fixture.py @@ -116,14 +116,38 @@ class MoneyMasking(unittest.TestCase): self.assertEqual(len(applied), 2) +class Reduction(unittest.TestCase): + def test_claude_keeps_the_full_screen(self): + # Mirrors `DetectedAgent.detectionScreenText(from:)`: trimming a Claude + # capture can delete the very row that reproduces a bug. + text = "\n".join(f"line {index}" for index in range(40)) + self.assertEqual(fixture.detection_screen_text(text, "claude"), text) + + def test_other_agents_take_the_bounded_tail(self): + text = "\n".join(f"line {index}" for index in range(40)) + reduced = fixture.detection_screen_text(text, "codex") + self.assertEqual(reduced.split("\n"), [f"line {index}" for index in range(16, 40)]) + + def test_blank_lines_do_not_count_toward_the_tail_budget(self): + rows = [f"line {index}" for index in range(30)] + rows.insert(28, "") + reduced = fixture.detection_screen_text("\n".join(rows), "codex") + self.assertEqual(len(reduced.split("\n")), 25) + self.assertIn("", reduced.split("\n")) + + def test_short_screens_pass_through_for_every_agent(self): + text = "a\n\nb" + self.assertEqual(fixture.detection_screen_text(text, "codex"), text) + + class EndToEnd(unittest.TestCase): - def run_script(self, text, *args): + def run_script(self, text, *args, agent="claude"): with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle: json.dump({"data": {"source": "detection", "text": text}}, handle) path = handle.name try: return subprocess.run( - [sys.executable, str(SCRIPT), path, *args], + [sys.executable, str(SCRIPT), path, "--agent", agent, *args], capture_output=True, text=True, ) @@ -156,13 +180,24 @@ class EndToEnd(unittest.TestCase): self.assertEqual(result.returncode, 1) self.assertIn("no width terminals agree on", result.stderr) + def test_agent_selects_the_reduction(self): + text = "\n".join(f"row {index}" for index in range(30)) + full = self.run_script(text) + tail = self.run_script(text, agent="codex") + self.assertEqual(full.stdout, text) + self.assertEqual(tail.stdout, "\n".join(f"row {index}" for index in range(6, 30))) + def test_viewport_capture_is_still_rejected(self): with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle: json.dump({"data": {"source": "screen", "text": "x"}}, handle) path = handle.name try: + # `--agent` is passed so exit 2 comes from the source check, not + # from argparse rejecting a missing required flag with the same code. result = subprocess.run( - [sys.executable, str(SCRIPT), path], capture_output=True, text=True + [sys.executable, str(SCRIPT), path, "--agent", "claude"], + capture_output=True, + text=True, ) finally: pathlib.Path(path).unlink() diff --git a/supacodeTests/Fixtures/AgentScreenDetection/README.md b/supacodeTests/Fixtures/AgentScreenDetection/README.md index b13d2389..311c4fe4 100644 --- a/supacodeTests/Fixtures/AgentScreenDetection/README.md +++ b/supacodeTests/Fixtures/AgentScreenDetection/README.md @@ -56,11 +56,17 @@ steps and refuses a substitution it cannot fit: ```bash scripts/make-detection-fixture.py .local/agent-screen-captures/capture.json \ + --agent claude \ --redact "/Users/me=/Users/usr" \ --redact "Acme Inc=" \ > claude/2.1.226/idle/composer.txt ``` +`--agent` is required and selects the step 6 reduction: `claude` keeps the full active +screen, and every other agent value takes the bounded 24-line tail. The flag mirrors +`DetectedAgent.detectionScreenText(from:)` rather than reading the agent from the +capture, because the capture does not record which detector will consume it. + Each replacement is padded or trimmed in the spaces immediately following it, so every later column on the row — a closing box border, a second column of chrome — keeps its captured position. A replacement too long for the space after it fails with the maximum