From b96c2f7bdc9a170e2c51ce17624fae7df6f7d928 Mon Sep 17 00:00:00 2001 From: song Date: Tue, 29 Sep 2026 00:41:14 +0800 Subject: [PATCH 1/3] fix(chat): stream English answers sentence by sentence VisibleResponseStreamFilter holds visible text until a flush boundary so a local path is never split before redaction. The boundaries were only a newline and Chinese sentence punctuation, so an English answer surfaced only at a newline or after 160 characters, usually all at once at the end. Also end a sentence at '.', '!' or '?' followed by whitespace. Decimals, versions, file names and URLs have no whitespace after the point and never split, and the split lands on whitespace, which the length fallback already treats as a safe boundary. Signed-off-by: song --- loopx/chat.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/loopx/chat.py b/loopx/chat.py index e85fa386ac..08cd36cdec 100644 --- a/loopx/chat.py +++ b/loopx/chat.py @@ -106,6 +106,10 @@ class VisibleResponseStreamFilter: """Stream safe operator text while withholding the structured review envelope.""" _FLUSH_BOUNDARIES = {"\n", "。", "!", "?"} + # Latin sentence punctuation ends a sentence only before whitespace, so + # decimals, versions, file names and URLs never split. The split lands on + # whitespace, which the length fallback below already treats as safe. + _SPACED_SENTENCE_ENDINGS = {".", "!", "?"} _MAX_PENDING_CHARS = 160 def __init__(self, *, protected_paths: Iterable[Path | str] = ()) -> None: @@ -125,6 +129,11 @@ def _accept_visible(self, text: str, *, final: bool) -> str: for index, character in enumerate(self.visible_pending[:search_limit]): if character in self._FLUSH_BOUNDARIES: boundary = index + 1 + elif ( + character in self._SPACED_SENTENCE_ENDINGS + and self.visible_pending[index + 1 : index + 2] in {" ", "\t"} + ): + boundary = index + 2 if boundary < 0 and len(self.visible_pending) >= self._MAX_PENDING_CHARS: prefix = self.visible_pending[: self._MAX_PENDING_CHARS + 1] whitespace = max(prefix.rfind(" "), prefix.rfind("\t")) From dd25609c6557b16a5ba4732c4ebe6671b30af644 Mon Sep 17 00:00:00 2001 From: song Date: Tue, 29 Sep 2026 00:41:14 +0800 Subject: [PATCH 2/3] test(chat): cover English sentence streaming and redaction parity A multi-sentence English stream surfaces each sentence once its trailing whitespace arrives, a version point never splits, and a protected path inside an English sentence redacts exactly as the unsplit text does. Signed-off-by: song --- examples/loopx-chat-agent-smoke.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/examples/loopx-chat-agent-smoke.py b/examples/loopx-chat-agent-smoke.py index c14609ce80..d9957f8ff1 100644 --- a/examples/loopx-chat-agent-smoke.py +++ b/examples/loopx-chat-agent-smoke.py @@ -18,7 +18,7 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) -from loopx.chat import VisibleResponseStreamFilter # noqa: E402 +from loopx.chat import VisibleResponseStreamFilter, redact_local_paths # noqa: E402 from loopx.chat_agent import ( # noqa: E402 CodexChatAgentError, CodexChatAgentSession, @@ -247,7 +247,26 @@ def get(self, *, timeout: float) -> dict[str, object]: assert chinese_early, "Chinese prose must stream without whitespace or sentence punctuation" assert chinese_early + chinese_filter.finish() == long_chinese + english_filter = VisibleResponseStreamFilter() + english_chunks = ["Version 1.2 is ready", ".", " See example.com for notes! Next,", " run the check? Done"] + english_visible = [english_filter.feed(chunk) for chunk in english_chunks] + assert english_visible[0] == "", "a decimal or version point must not end a sentence" + assert english_visible[1] == "", "sentence punctuation waits for the following whitespace" + assert english_visible[2] == "Version 1.2 is ready. See example.com for notes! ", english_visible + assert english_visible[3] == "Next, run the check? ", english_visible + assert "".join(english_visible) + english_filter.finish() == "".join(english_chunks) + protected_path = "/home/example/project" + english_path_filter = VisibleResponseStreamFilter(protected_paths=[protected_path]) + english_path_text = f"The report is in {protected_path}/notes.txt. Review it next. " + english_path_visible = "".join( + english_path_filter.feed(english_path_text[index : index + 7]) + for index in range(0, len(english_path_text), 7) + ) + english_path_filter.finish() + assert protected_path not in english_path_visible, english_path_visible + # Splitting at sentences must not change what redaction produces. + assert english_path_visible == redact_local_paths(english_path_text, protected_paths=[protected_path]), english_path_visible + path_filter = VisibleResponseStreamFilter(protected_paths=[protected_path]) path_text = ("前" * 150) + protected_path + "/secret.txt 后续内容" path_early = "".join( From f0f644a8c0a6730639b8eae900b5324c4d598140 Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:55:59 -0400 Subject: [PATCH 3/3] fix(chat): keep streaming the long tail after an early sentence end The stream filter took one flush boundary per chunk and skipped the 160-character fallback whenever that boundary existed. A single chunk with a short first sentence and a long tail ("Ready. " plus 500 chars) released only "Ready. " and held the rest until the next chunk or the end of the Turn. The filter now keeps cutting safe boundaries from the pending text until none is left, then redacts the released prefix once. The Chat smoke adds that single-chunk case, and the same shape with a protected path and a following review envelope: the tail streams, the final text is byte-identical to one-shot redaction, and neither the path nor the envelope leaks. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: song <22676124+songoow@users.noreply.github.com> --- examples/loopx-chat-agent-smoke.py | 22 ++++++++++++++++- loopx/chat.py | 39 ++++++++++++++++++------------ 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/examples/loopx-chat-agent-smoke.py b/examples/loopx-chat-agent-smoke.py index d9957f8ff1..83a98662af 100644 --- a/examples/loopx-chat-agent-smoke.py +++ b/examples/loopx-chat-agent-smoke.py @@ -18,7 +18,7 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) -from loopx.chat import VisibleResponseStreamFilter, redact_local_paths # noqa: E402 +from loopx.chat import CHAT_REVIEW_OPEN_TAG, VisibleResponseStreamFilter, redact_local_paths # noqa: E402 from loopx.chat_agent import ( # noqa: E402 CodexChatAgentError, CodexChatAgentSession, @@ -256,7 +256,27 @@ def get(self, *, timeout: float) -> dict[str, object]: assert english_visible[3] == "Next, run the check? ", english_visible assert "".join(english_visible) + english_filter.finish() == "".join(english_chunks) + # An early sentence end must not hold back a long tail in the same chunk: + # the tail still streams through the length fallback before the provider + # sends anything else. + tail_filter = VisibleResponseStreamFilter() + tail_chunk = "Ready. " + "word " * 100 + tail_visible = tail_filter.feed(tail_chunk) + assert tail_visible.startswith("Ready. word "), tail_visible[:40] + assert len(tail_chunk) - len(tail_visible) < 160, len(tail_visible) + assert tail_visible + tail_filter.finish() == tail_chunk + protected_path = "/home/example/project" + tail_path_filter = VisibleResponseStreamFilter(protected_paths=[protected_path]) + tail_path_text = "Ready. " + "word " * 40 + f"see {protected_path}/notes.txt " + "word " * 40 + tail_path_chunks = [tail_path_text, CHAT_REVIEW_OPEN_TAG + '{"hidden": true}'] + tail_path_early = tail_path_filter.feed(tail_path_chunks[0]) + assert len(tail_path_text) - len(tail_path_early) < 160, len(tail_path_early) + tail_path_visible = tail_path_early + tail_path_filter.feed(tail_path_chunks[1]) + tail_path_filter.finish() + assert protected_path not in tail_path_visible, tail_path_visible + assert "hidden" not in tail_path_visible, tail_path_visible + assert tail_path_visible == redact_local_paths(tail_path_text, protected_paths=[protected_path]), tail_path_visible + english_path_filter = VisibleResponseStreamFilter(protected_paths=[protected_path]) english_path_text = f"The report is in {protected_path}/notes.txt. Review it next. " english_path_visible = "".join( diff --git a/loopx/chat.py b/loopx/chat.py index 08cd36cdec..c43732d391 100644 --- a/loopx/chat.py +++ b/loopx/chat.py @@ -118,24 +118,19 @@ def __init__(self, *, protected_paths: Iterable[Path | str] = ()) -> None: self.visible_pending = "" self.envelope_started = False - def _accept_visible(self, text: str, *, final: bool) -> str: - self.visible_pending += text - if final: - ready = self.visible_pending - self.visible_pending = "" - return redact_local_paths(ready, protected_paths=self.protected_paths) + def _next_boundary(self, pending: str) -> int: boundary = -1 - search_limit = min(len(self.visible_pending), self._MAX_PENDING_CHARS) - for index, character in enumerate(self.visible_pending[:search_limit]): + search_limit = min(len(pending), self._MAX_PENDING_CHARS) + for index, character in enumerate(pending[:search_limit]): if character in self._FLUSH_BOUNDARIES: boundary = index + 1 elif ( character in self._SPACED_SENTENCE_ENDINGS - and self.visible_pending[index + 1 : index + 2] in {" ", "\t"} + and pending[index + 1 : index + 2] in {" ", "\t"} ): boundary = index + 2 - if boundary < 0 and len(self.visible_pending) >= self._MAX_PENDING_CHARS: - prefix = self.visible_pending[: self._MAX_PENDING_CHARS + 1] + if boundary < 0 and len(pending) >= self._MAX_PENDING_CHARS: + prefix = pending[: self._MAX_PENDING_CHARS + 1] whitespace = max(prefix.rfind(" "), prefix.rfind("\t")) if whitespace >= 0: boundary = whitespace + 1 @@ -143,16 +138,30 @@ def _accept_visible(self, text: str, *, final: bool) -> str: boundary = self._MAX_PENDING_CHARS else: for index, character in enumerate( - self.visible_pending[self._MAX_PENDING_CHARS :], + pending[self._MAX_PENDING_CHARS :], start=self._MAX_PENDING_CHARS, ): if character in " \t\r\n`'\"<>": boundary = index + 1 break - if boundary < 0: + return boundary + + def _accept_visible(self, text: str, *, final: bool) -> str: + self.visible_pending += text + if final: + ready = self.visible_pending + self.visible_pending = "" + return redact_local_paths(ready, protected_paths=self.protected_paths) + # One chunk can hold several safe boundaries. Keep cutting until none + # is left, so an early sentence never holds back a long tail that the + # length fallback would otherwise release. + ready_length = 0 + while (boundary := self._next_boundary(self.visible_pending[ready_length:])) > 0: + ready_length += boundary + if not ready_length: return "" - ready = self.visible_pending[:boundary] - self.visible_pending = self.visible_pending[boundary:] + ready = self.visible_pending[:ready_length] + self.visible_pending = self.visible_pending[ready_length:] return redact_local_paths(ready, protected_paths=self.protected_paths) def feed(self, chunk: str) -> str: