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) <noreply@anthropic.com>
This commit is contained in:
Peter Boers 2026-09-03 11:42:40 +02:00
parent 13a313987a
commit cc7107c782
No known key found for this signature in database
2 changed files with 25 additions and 2 deletions

View file

@ -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

View file

@ -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.