diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 66084e2d4..b6537f916 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -467,9 +467,48 @@ class GatewayStreamConsumer: self._accumulated += buf[:-held_back] self._think_buffer = buf[-held_back:] else: - self._accumulated += buf + # No (partial) open tag — but the model may have + # emitted an orphan close tag like on its + # own (e.g. when a thinking-mode toggle drops the + # matched open, or when upstream stripping is + # incomplete). Strip those before accumulating so + # they never reach the user. + self._accumulated += self._strip_orphan_close_tags(buf) return + @classmethod + def _strip_orphan_close_tags(cls, text: str) -> str: + """Remove any close tags from *text* that have no matching open. + + Mirrors ``agent/think_scrubber.py::StreamingThinkScrubber. + _strip_orphan_close_tags`` so the progressive-display filter + behaves the same as the post-stream final-response scrubber. + An orphan close tag is always noise — stripped along with any + trailing whitespace so surrounding prose flows naturally. + """ + if " None: """Flush any held-back partial-tag buffer into accumulated text. @@ -477,7 +516,9 @@ class GatewayStreamConsumer: was held back waiting for a possible opening tag is not lost. """ if self._think_buffer and not self._in_think_block: - self._accumulated += self._think_buffer + # Strip any orphan close tags that may have been held back — + # see _filter_and_accumulate for context. + self._accumulated += self._strip_orphan_close_tags(self._think_buffer) self._think_buffer = "" async def run(self) -> None: diff --git a/scripts/release.py b/scripts/release.py index 1a32956ee..3401eb62c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "259353979+testingbuddies24@users.noreply.github.com": "testingbuddies24", # PR #43192 salvage (strip orphan think-tag close tags in progressive gateway stream so a bare whose open was dropped upstream can't leak to the user) "5848605+itenev@users.noreply.github.com": "itenev", # PR #22753 salvage (asyncify model-context resolution in gateway message path so blocking requests.get can't starve Discord heartbeats) "arthur.zhang@ingenico.com": "arthurzhang", # PR #34718 salvage (redact Slack App-Level xapp- tokens in agent/redact.py + gateway/run.py) "290873280+rrevenanttt@users.noreply.github.com": "rrevenanttt", # PR #40773 salvage (close hardline rm bypass via quoted paths and ${HOME} brace form) diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index d83d086d6..009621e7c 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -2246,3 +2246,110 @@ class TestRunStillCurrentGuard: adapter.send.assert_not_called() adapter.edit_message.assert_not_called() assert consumer._final_response_sent is False + + +# ── _strip_orphan_close_tags regression tests ────────────────────────── +# Regression guard for the /think tag leak: when the stream consumer is +# NOT inside a think block, stray close tags like must be +# stripped before text is accumulated — otherwise they leak to Telegram. +# (Reported by Tony on 2026-06-09.) + + +class TestStripOrphanCloseTags: + """Verify orphan close tags are stripped from text the stream consumer + would accumulate while NOT inside a think block.""" + + @pytest.mark.parametrize( + "tag", + [ + "", + "", + "", + "", + "", + "", + ], + ) + def test_all_close_tag_variants_stripped(self, tag): + text = f"before{tag}after" + result = GatewayStreamConsumer._strip_orphan_close_tags(text) + assert tag not in result + assert "before" in result and "after" in result + + def test_no_close_tag_passthrough(self): + text = "Just normal text with no tags." + assert GatewayStreamConsumer._strip_orphan_close_tags(text) == text + + def test_empty_string(self): + assert GatewayStreamConsumer._strip_orphan_close_tags("") == "" + + def test_close_tag_with_trailing_whitespace(self): + """The trailing whitespace after the tag should also be eaten so + surrounding prose flows naturally (matches StreamingThinkScrubber).""" + text = "Looking at this now.\n\n\n\nThe answer is 42." + result = GatewayStreamConsumer._strip_orphan_close_tags(text) + assert "" not in result + assert "Looking at this now" in result + assert "The answer is 42" in result + + def test_multiple_orphan_close_tags(self): + text = "foo bar baz" + result = GatewayStreamConsumer._strip_orphan_close_tags(text) + assert "" not in result + assert "" not in result + assert "foo" in result and "bar" in result and "baz" in result + + def test_orphan_close_does_not_eat_following_prose(self): + text = "answer then this should remain" + result = GatewayStreamConsumer._strip_orphan_close_tags(text) + assert result == "answer then this should remain" + + def test_partial_close_tag_not_stripped(self): + """A partial tag like '\n\n" + "The answer is 42 and the cat is black." + ) + # No raw close tag should remain in the accumulated text. + for tag in GatewayStreamConsumer._CLOSE_THINK_TAGS: + assert tag not in consumer._accumulated, ( + f"Orphan close tag {tag!r} leaked into accumulated text: " + f"{consumer._accumulated!r}" + ) + # Surrounding prose must survive intact. + assert "Here is the result" in consumer._accumulated + assert "The answer is 42" in consumer._accumulated + + def test_flush_think_buffer_strips_orphan_close(self): + """The end-of-stream flush should also strip orphan close tags from + any held-back buffer text.""" + adapter = MagicMock() + adapter.MAX_MESSAGE_LENGTH = 4096 + config = StreamConsumerConfig(cursor=" ▉") + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + # Plant a held-back buffer with an orphan close tag (simulates the + # buffer being held while waiting for a possible opening tag, then + # flushed when the stream ends). + consumer._think_buffer = "trailing prose more" + consumer._in_think_block = False + consumer._flush_think_buffer() + for tag in GatewayStreamConsumer._CLOSE_THINK_TAGS: + assert tag not in consumer._accumulated + assert "trailing prose" in consumer._accumulated + assert "more" in consumer._accumulated