diff --git a/ringer.py b/ringer.py index 062615b7..e3840db6 100755 --- a/ringer.py +++ b/ringer.py @@ -9430,6 +9430,10 @@ def parse_env_file(path: Path) -> dict[str, str]: def parse_token_count(text: str, token_regex: str | None = DEFAULT_TOKEN_REGEX) -> int | None: + # Engines colorize stdout when FORCE_COLOR is set (Claude Code, most CI), + # leaving escape bytes between the label and the number that no practical + # regex bridges — strip them once here so every regex sees plain text. + text = ANSI_RE.sub("", text) if token_regex: matches = list(re.finditer(token_regex, text, flags=re.IGNORECASE)) for match in reversed(matches): @@ -9454,6 +9458,9 @@ def parse_token_count(text: str, token_regex: str | None = DEFAULT_TOKEN_REGEX) def parse_reported_model(text: str, model_report_regex: str | None) -> str | None: if not model_report_regex: return None + # Same reason as parse_token_count: a colorized "model:" header breaks + # the ^model: anchor unless the ANSI escapes are stripped first. + text = ANSI_RE.sub("", text) match = re.search(model_report_regex, text, flags=re.IGNORECASE) if match is None or match.lastindex is None: return None diff --git a/tests/test_identity_evidence.py b/tests/test_identity_evidence.py index 0d8ed3ce..1043cb59 100644 --- a/tests/test_identity_evidence.py +++ b/tests/test_identity_evidence.py @@ -105,6 +105,14 @@ def test_codex_report_regex_captures_synthetic_header(self) -> None: output = "OpenAI Codex v0.144.0\n--------\nmodel: gpt-5.6-sol\nprovider: openai\n" self.assertEqual("gpt-5.6-sol", parse_reported_model(output, engine.model_report_regex)) + def test_codex_report_regex_captures_ansi_colorized_header(self) -> None: + # Real bytes from codex exec run with FORCE_COLOR set (as Claude Code + # and most CI systems do): the header is bold-wrapped, so the ^model: + # anchor never matches without stripping ANSI first. + engine = load_engines(None)["codex"] + output = "OpenAI Codex v0.146.0\n--------\n\x1b[1mmodel:\x1b[0m gpt-5.6-sol\nprovider: openai\n" + self.assertEqual("gpt-5.6-sol", parse_reported_model(output, engine.model_report_regex)) + def test_reported_model_wins_and_resolved_model_is_fallback(self) -> None: rows = self.log_attempts( WorkerResult(0, False, 12, reported_model="gpt-5.7"), diff --git a/tests/test_ringer.py b/tests/test_ringer.py index 16fa3ee9..8ab4d218 100644 --- a/tests/test_ringer.py +++ b/tests/test_ringer.py @@ -595,6 +595,19 @@ def test_token_count_parser_accepts_colon_and_newline_formats(self) -> None: self.assertEqual(ringer.parse_token_count("tokens used: 1,234", r"tokens\s+used\s*:?\s*([0-9][0-9,]*)"), 1234) self.assertEqual(ringer.parse_token_count("tokens used\n5,678", r"tokens\s+used\s*:?\s*([0-9][0-9,]*)"), 5678) + def test_token_count_parser_sees_through_ansi_colorized_output(self) -> None: + # Real bytes from codex exec run with FORCE_COLOR set (as Claude Code + # and most CI systems do): the label is dim-wrapped and the number is + # on the following line, so the escape sequences sit between "used" + # and the digits and an ANSI-blind \s* cannot bridge them. + colorized = "\x1b[2mtokens used\x1b[0m\n17,270\n" + self.assertEqual(ringer.parse_token_count(colorized, ringer.DEFAULT_TOKEN_REGEX), 17270) + # User-supplied custom regexes get the same stripping — the fix is in + # the parser, not the shipped pattern. + self.assertEqual(ringer.parse_token_count(colorized, r"tokens\s+used\s*:?\s*([0-9][0-9,]*)"), 17270) + # Uncolorized output keeps parsing exactly as before. + self.assertEqual(ringer.parse_token_count("tokens used: 1,234", ringer.DEFAULT_TOKEN_REGEX), 1234) + if __name__ == "__main__": unittest.main()