From cc7107c782b26135e5d0b850c6129e83fb18dfe6 Mon Sep 17 00:00:00 2001 From: Peter Boers Date: Thu, 3 Sep 2026 11:42:40 +0200 Subject: [PATCH] fix(a2a): stop a snapshot resending whitespace already delivered The raw-offset mapping lands just past the last matched non-whitespace character, so whitespace already emitted at the end of the accumulated text was forwarded a second time by the next cumulative snapshot: ["Hello ", "Hello world"] -> "Hello world" ["OK\n", "OK\n"] -> "OK\n\n" Drop only the overlap between the whitespace already sent and the whitespace the snapshot repeats, rather than stripping the tail outright, so a snapshot that introduces further whitespace keeps it: ["Hello ", "Hello \n\nworld"] -> "Hello \n\nworld" Reported by Greptile on #39513. Co-Authored-By: Claude Opus 5 (1M context) --- litellm/llms/a2a/chat/streaming_iterator.py | 20 +++++++++++++++++-- .../a2a/chat/test_a2a_chat_transformation.py | 7 +++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/litellm/llms/a2a/chat/streaming_iterator.py b/litellm/llms/a2a/chat/streaming_iterator.py index 4c352ca2b6e..1d00889679c 100644 --- a/litellm/llms/a2a/chat/streaming_iterator.py +++ b/litellm/llms/a2a/chat/streaming_iterator.py @@ -34,6 +34,16 @@ def _index_after(text: str, non_space_count: int) -> int: ) +def _trailing_whitespace(text: str) -> int: + """How many whitespace characters `text` ends with.""" + return len(text) - len(text.rstrip()) + + +def _leading_whitespace(text: str) -> int: + """How many whitespace characters `text` begins with.""" + return len(text) - len(text.lstrip()) + + class A2AModelResponseIterator(BaseModelResponseIterator): """ Iterator for parsing A2A streaming responses. @@ -135,8 +145,14 @@ class A2AModelResponseIterator(BaseModelResponseIterator): if emitted_key and text_key.startswith(emitted_key): # A cumulative snapshot: emit only its tail, which is empty when the snapshot - # just repeats everything sent so far. - suffix: Final = text[_index_after(text, len(emitted_key)) :] + # just repeats everything sent so far. The offset lands just past the last + # matched non-whitespace character, so any whitespace already delivered at the + # end of the emitted text would otherwise be sent a second time. Drop only that + # overlap, so a snapshot introducing further whitespace (a paragraph break, say) + # keeps it. + tail: Final = text[_index_after(text, len(emitted_key)) :] + already_sent: Final = min(_trailing_whitespace(self._emitted_text), _leading_whitespace(tail)) + suffix: Final = tail[already_sent:] self._emitted_text += suffix return suffix diff --git a/tests/test_litellm/llms/a2a/chat/test_a2a_chat_transformation.py b/tests/test_litellm/llms/a2a/chat/test_a2a_chat_transformation.py index a17c4bdb440..36be80db4fa 100644 --- a/tests/test_litellm/llms/a2a/chat/test_a2a_chat_transformation.py +++ b/tests/test_litellm/llms/a2a/chat/test_a2a_chat_transformation.py @@ -122,6 +122,13 @@ def test_kagent_stream_finishes_on_completed_state(): pytest.param(["O", "K", "OK"], "OK", id="deltas_then_final_snapshot"), pytest.param(["O", "K", "OK", "OK"], "OK", id="deltas_then_repeated_snapshots"), pytest.param(["OK", "OK"], "OK", id="one_delta_then_equal_snapshot"), + pytest.param(["Hello ", "Hello world"], "Hello world", id="snapshot_keeps_emitted_space_once"), + pytest.param(["OK\n", "OK\n"], "OK\n", id="snapshot_keeps_emitted_newline_once"), + pytest.param( + ["Hello ", "Hello \n\nworld"], + "Hello \n\nworld", + id="snapshot_keeps_its_own_new_whitespace", + ), # Known limitation: A2A marks no event as delta-or-snapshot, so a delta that # exactly reproduces the accumulated text is indistinguishable from a snapshot # and collapses. Duplicating a whole reply is the worse failure of the two.