diff --git a/scripts/agent_exec_adapters.py b/scripts/agent_exec_adapters.py index dee9f352..26aac68f 100644 --- a/scripts/agent_exec_adapters.py +++ b/scripts/agent_exec_adapters.py @@ -429,22 +429,39 @@ def _build_bounded_unit_prompt(packet_text: str) -> str: def extract_terminal_record(text: str | None) -> dict[str, Any] | None: - """Return the last valid terminal record embedded in one final message.""" + """Return the sole terminal record that ends one final message. + + Only whitespace may follow the record, and no other valid record may + precede it; anything else fails closed. + """ if not isinstance(text, str) or not text: return None - text = text[-_MAX_TERMINAL_TEXT_CHARS:] + text = text[-_MAX_TERMINAL_TEXT_CHARS:].rstrip() decoder = json.JSONDecoder() + + def decode_at(pos: int) -> tuple[object, int]: + try: + return decoder.raw_decode(text, pos) + except json.JSONDecodeError: + return None, -1 + pos = text.rfind("{") while pos != -1: - try: - candidate, _ = decoder.raw_decode(text, pos) - except json.JSONDecodeError: - candidate = None - record = _valid_terminal_record(candidate) - if record is not None: - return record + candidate, end = decode_at(pos) + if end == len(text) and isinstance(candidate, dict): + break pos = text.rfind("{", 0, pos) - return None + else: + return None + record = _valid_terminal_record(candidate) + if record is None: + return None + earlier = text.rfind("{", 0, pos) + while earlier != -1: + if _valid_terminal_record(decode_at(earlier)[0]) is not None: + return None + earlier = text.rfind("{", 0, earlier) + return record def _valid_terminal_record(obj: object) -> dict[str, Any] | None: diff --git a/tests/test_agent_exec.py b/tests/test_agent_exec.py index 029375d9..0981c541 100644 --- a/tests/test_agent_exec.py +++ b/tests/test_agent_exec.py @@ -2325,7 +2325,7 @@ class AgentExecControllerTests(unittest.TestCase): "pi", _pi_help(), _pi_json_events( - "ok\n```json\n" + _terminal_json() + "\n```", + "ok\n" + _terminal_json() + "\n", agent_end_padding=2 * agent_exec.TERMINAL_LINE_MAX_BYTES, ), "success", @@ -2411,12 +2411,35 @@ class TerminalRecordDecodingTests(unittest.TestCase): ) def test_cursor_stream_json_result_is_decoded(self) -> None: - lines = _cursor_stream_events("Done.\n" + _terminal_json() + "\nThanks.") + lines = _cursor_stream_events("Done.\n" + _terminal_json() + "\n \n") record = self._decode("cursor", lines) assert record is not None self.assertEqual(record["terminal_status"], "completed") self.assertEqual(record["changed_files"], ["src/marker.txt"]) + def test_terminal_record_must_be_sole_final_content(self) -> None: + invalid = json.dumps({"terminal_status": "done", "summary": "x"}) + texts = { + "trailing-prose": "Done.\n" + _terminal_json() + "\nThanks.", + "trailing-invalid-object": _terminal_json() + "\n" + invalid, + "trailing-non-terminal-object": _terminal_json() + ' {"note":"x"}', + "truncated-terminal-object": _terminal_json() + '\n{"terminal_status":', + "two-records": _terminal_json() + "\n" + _terminal_json(), + "conflicting-records": _terminal_json() + "\n" + _terminal_json("blocked"), + } + for name, text in texts.items(): + for adapter, lines in ( + ("cursor", _cursor_stream_events(text)), + ("antigravity", _agy_stream_events(text)), + ("pi", _pi_json_events(text)), + ): + with self.subTest(case=name, adapter=adapter): + self.assertIsNone(self._decode(adapter, lines)) + with self.subTest(case=name, adapter="custom"): + self.assertIsNone( + self._decode("custom", [], stdout_tail="", result_text=text) + ) + def test_cursor_user_echo_and_error_result_are_not_records(self) -> None: lines = _cursor_stream_events("prose only") echo = json.loads(lines[1])