From d711cd73160452ccc0eeca808416740d3201edb3 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:55:30 +0800 Subject: [PATCH 1/2] fix(chat): preserve quoted review markers in answers Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/chat.py | 85 +++++++++++++++++++++++------ tests/test_chat_response_parsing.py | 54 ++++++++++++++++++ 2 files changed, 122 insertions(+), 17 deletions(-) diff --git a/loopx/chat.py b/loopx/chat.py index 8cbf7c38ad..b701aaa97d 100644 --- a/loopx/chat.py +++ b/loopx/chat.py @@ -399,6 +399,46 @@ def redact_response_markdown( ) +def _review_openings(text: str, code_ticks: int = 0) -> tuple[list[int], int, int | None]: + """Locate control markers outside backtick examples and escaped text. + + Skip decoded JSON bodies too: a legacy message can itself quote markers + or backticks without creating another envelope or changing code context. + """ + openings = [] + tokens = re.compile(r"`+|" + re.escape(CHAT_REVIEW_OPEN_TAG)) + cursor = 0 + while token := tokens.search(text, cursor): + cursor = token.end() + value = token.group() + if not code_ticks: + preceding = token.start() - 1 + while preceding >= 0 and text[preceding] == "\\": + preceding -= 1 + if (token.start() - 1 - preceding) % 2: + continue + if value.startswith("`"): + if not code_ticks: + code_ticks = len(value) + elif len(value) == code_ticks: + code_ticks = 0 + elif code_ticks: + if not any(len(ticks.group()) == code_ticks for ticks in re.finditer(r"`+", text[cursor:])): + # A later chunk may close this example. Until then, withhold + # the possible envelope rather than leaking its metadata. + return openings, code_ticks, token.start() + else: + openings.append(token.start()) + body_start = len(text) - len(text[cursor:].lstrip()) + try: + _, body_end = json.JSONDecoder().raw_decode(text, body_start) + except json.JSONDecodeError: + pass + else: + cursor = body_end + return openings, code_ticks, None + + class VisibleResponseStreamFilter: """Stream safe operator text while withholding the structured review envelope.""" @@ -420,6 +460,7 @@ def __init__(self, *, protected_paths: Iterable[Path | str] = ()) -> None: self.marker_pending = "" self.visible_pending = "" self.envelope_started = False + self._code_ticks = 0 @staticmethod def _protected_root_atoms(root: str) -> tuple[tuple[tuple[str, bool], ...], ...]: @@ -528,28 +569,36 @@ def feed(self, chunk: str) -> str: if self.envelope_started: return "" self.marker_pending += str(chunk or "") - marker_at = self.marker_pending.find(CHAT_REVIEW_OPEN_TAG) - if marker_at >= 0: - visible = self.marker_pending[:marker_at] - self.marker_pending = "" - self.envelope_started = True - return self._accept_visible(visible, final=True) suffix_length = 0 limit = min(len(self.marker_pending), len(CHAT_REVIEW_OPEN_TAG) - 1) for length in range(1, limit + 1): if self.marker_pending.endswith(CHAT_REVIEW_OPEN_TAG[:length]): suffix_length = length - if suffix_length: - visible = self.marker_pending[:-suffix_length] - self.marker_pending = self.marker_pending[-suffix_length:] - else: - visible = self.marker_pending + cut = len(self.marker_pending) - suffix_length + # A delimiter run or escape can straddle chunks, just like the marker. + # Keep it whole so one split cannot change which text is code. + while cut and self.marker_pending[cut - 1] == "\\": + cut -= 1 + ticks = re.search(r"\\*`+$", self.marker_pending[:cut]) + if ticks: + cut = ticks.start() + visible = self.marker_pending[:cut] + openings, self._code_ticks, uncertain = _review_openings(visible, self._code_ticks) + if openings: self.marker_pending = "" + self.envelope_started = True + return self._accept_visible(visible[:openings[0]], final=True) + if uncertain is not None: + cut = uncertain + visible = visible[:cut] + self.marker_pending = self.marker_pending[cut:] return self._accept_visible(visible, final=False) def finish(self) -> str: if not self.envelope_started: - self.visible_pending += self.marker_pending + openings, _, uncertain = _review_openings(self.marker_pending, self._code_ticks) + cut = openings[0] if openings else uncertain + self.visible_pending += self.marker_pending[:cut] self.marker_pending = "" return self._accept_visible("", final=True) @@ -812,8 +861,12 @@ def parse_agent_response( team_plan_context: Mapping[str, Any] | None = None, ) -> dict[str, Any]: protected = tuple(protected_paths) - start = raw_text.rfind(CHAT_REVIEW_OPEN_TAG) + openings, _, uncertain = _review_openings(raw_text) + if uncertain is not None: + openings.append(uncertain) + start = openings[-1] if openings else -1 end = raw_text.rfind(CHAT_REVIEW_CLOSE_TAG) + visible = raw_text[:openings[0]].strip() if openings else raw_text.strip() if start >= 0 and end > start: body = raw_text[start + len(CHAT_REVIEW_OPEN_TAG) : end].strip() try: @@ -825,7 +878,6 @@ def parse_agent_response( # Resolve only inside a complete envelope, then use the same # redaction and typed metadata normalization as legacy responses. if payload.get("message") == "": - visible = raw_text[:start].split(CHAT_REVIEW_OPEN_TAG, 1)[0].strip() payload = {**payload, "message": visible} try: return normalize_agent_response( @@ -839,7 +891,7 @@ def parse_agent_response( # Preserve prose, but admit none of the unvalidated actions. answer = payload.get("message") if not isinstance(answer, str) or not answer.strip(): - answer = raw_text[:start].split(CHAT_REVIEW_OPEN_TAG, 1)[0].strip() + answer = visible if not answer: raise summary = "答复正文已保留,但操作或委派元数据未通过校验;相关操作和委派尚未提交。" @@ -862,14 +914,13 @@ def parse_agent_response( "protected_action": None, "gate": None, } - raw_text = raw_text[:start] + raw_text = visible elif start >= 0: # The review envelope is an authority boundary, not display text. A # provider can finish after emitting a complete JSON object but before # emitting the closing tag. In that case retain only a human-readable # answer and fail closed for proposals, gates, and protected actions. # Never leak the raw protocol fragment into downstream transports. - visible = raw_text[:start].strip() body = raw_text[start + len(CHAT_REVIEW_OPEN_TAG) :].strip() salvaged_message = "" try: diff --git a/tests/test_chat_response_parsing.py b/tests/test_chat_response_parsing.py index ae671c32ec..8e4dabaa49 100644 --- a/tests/test_chat_response_parsing.py +++ b/tests/test_chat_response_parsing.py @@ -135,6 +135,60 @@ def test_compact_answer_never_reveals_an_earlier_hidden_envelope() -> None: assert parse_agent_response(raw)["message"] == "Answer." +@pytest.mark.parametrize("example", [ + "Use `` as the opening marker.", + 'Use ``{"message":"example"}`` literally.', + '```json\n{"message":"example"}\n```', + r"Escaped \` text and \ are literal.", + r"A path ending with a slash, `C:\`, is code.", +]) +def test_quoted_review_examples_survive_compact_answers_and_stream_splits(example): + visible = example + "\nThe complete explanation continues here." + raw = visible + "\n" + CHAT_REVIEW_OPEN_TAG + json.dumps({"message": ""}) + CHAT_REVIEW_CLOSE_TAG + + assert parse_agent_response(raw)["message"] == visible + for split in range(len(raw) + 1): + stream = VisibleResponseStreamFilter() + assert (stream.feed(raw[:split]) + stream.feed(raw[split:]) + stream.finish()).strip() == visible + + +def test_review_example_without_an_envelope_is_text_and_admits_no_actions(): + example = '```json\n{"message":"example","proposals":[{"kind":"todo","text":"must not run"}]}\n```' + response = parse_agent_response(example) + assert response["message"] == example + assert response["proposals"] == [] and response["gate"] is None + assert response["protected_action"] is None + + +@pytest.mark.parametrize("metadata", [ + {"context_handoff": [{"brief": "invalid"}]}, + {"proposals": [{"kind": "todo", "text": "must not run"}]}, +]) +def test_quoted_markers_do_not_restore_an_earlier_hidden_envelope(metadata): + visible = "The `` example is followed by the full answer." + hidden = CHAT_REVIEW_OPEN_TAG + json.dumps({"message": "hidden"}) + CHAT_REVIEW_CLOSE_TAG + raw = visible + "\n" + hidden + "\n" + CHAT_REVIEW_OPEN_TAG + json.dumps({"message": "", **metadata}) + response = parse_agent_response(raw) + assert response["message"] == visible + assert response["proposals"] == [] and response["gate"] is None + assert response["protected_action"] is None and not response.get("context_handoff") + + +def test_legacy_message_can_contain_literal_review_markers(): + message = "Literal and inside the answer." + raw = CHAT_REVIEW_OPEN_TAG + json.dumps({"message": message}) + CHAT_REVIEW_CLOSE_TAG + assert parse_agent_response(raw)["message"] == message + + +def test_unclosed_code_does_not_expose_a_possible_review_envelope(): + visible = "An unfinished `example." + raw = visible + "\n" + CHAT_REVIEW_OPEN_TAG + json.dumps({"message": "", "GlobalSummary": "hidden"}) + CHAT_REVIEW_CLOSE_TAG + assert parse_agent_response(raw)["message"] == visible + for split in range(len(raw) + 1): + stream = VisibleResponseStreamFilter() + assert (stream.feed(raw[:split]) + stream.feed(raw[split:]) + stream.finish()).strip() == visible + + @pytest.mark.parametrize("suffix", ["", CHAT_REVIEW_CLOSE_TAG]) def test_invalid_compact_envelope_cannot_restore_metadata(suffix) -> None: raw = 'Readable answer\n{"message":"","proposals":[{"kind":"todo","text":"must not run"}],' + suffix From 8a1dba15b51d5d745f78fc7b9858c74816a7b53c Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:07:58 +0800 Subject: [PATCH 2/2] fix(chat): withhold unclosed code metadata until delimiters settle Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/chat.py | 25 +++++++++++++++++-------- tests/test_chat_response_parsing.py | 5 +++-- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/loopx/chat.py b/loopx/chat.py index b701aaa97d..b7adadf43a 100644 --- a/loopx/chat.py +++ b/loopx/chat.py @@ -422,20 +422,29 @@ def _review_openings(text: str, code_ticks: int = 0) -> tuple[list[int], int, in code_ticks = len(value) elif len(value) == code_ticks: code_ticks = 0 - elif code_ticks: - if not any(len(ticks.group()) == code_ticks for ticks in re.finditer(r"`+", text[cursor:])): - # A later chunk may close this example. Until then, withhold - # the possible envelope rather than leaking its metadata. - return openings, code_ticks, token.start() else: - openings.append(token.start()) body_start = len(text) - len(text[cursor:].lstrip()) try: _, body_end = json.JSONDecoder().raw_decode(text, body_start) except json.JSONDecodeError: - pass + body_end = None + if code_ticks: + if text[body_start:body_start + 1] == "{": + # Backticks in metadata cannot close a visible example. + # Only release a JSON example after its complete envelope + # and the code delimiter have both arrived. + if body_end is None: + return openings, code_ticks, token.start() + close = len(text) - len(text[body_end:].lstrip()) + if not text.startswith(CHAT_REVIEW_CLOSE_TAG, close): + return openings, code_ticks, token.start() + cursor = close + len(CHAT_REVIEW_CLOSE_TAG) + if not any(len(ticks.group()) == code_ticks for ticks in re.finditer(r"`+", text[cursor:])): + return openings, code_ticks, token.start() else: - cursor = body_end + openings.append(token.start()) + if body_end is not None: + cursor = body_end return openings, code_ticks, None diff --git a/tests/test_chat_response_parsing.py b/tests/test_chat_response_parsing.py index 8e4dabaa49..229ae540e0 100644 --- a/tests/test_chat_response_parsing.py +++ b/tests/test_chat_response_parsing.py @@ -180,9 +180,10 @@ def test_legacy_message_can_contain_literal_review_markers(): assert parse_agent_response(raw)["message"] == message -def test_unclosed_code_does_not_expose_a_possible_review_envelope(): +@pytest.mark.parametrize("summary", ["hidden", "hidden ` detail"]) +def test_unclosed_code_does_not_expose_a_possible_review_envelope(summary): visible = "An unfinished `example." - raw = visible + "\n" + CHAT_REVIEW_OPEN_TAG + json.dumps({"message": "", "GlobalSummary": "hidden"}) + CHAT_REVIEW_CLOSE_TAG + raw = visible + "\n" + CHAT_REVIEW_OPEN_TAG + json.dumps({"message": "", "GlobalSummary": summary}) + CHAT_REVIEW_CLOSE_TAG assert parse_agent_response(raw)["message"] == visible for split in range(len(raw) + 1): stream = VisibleResponseStreamFilter()