Skip to content

Commit 9a1c8ba

Browse files
eberki-scaleclaude
andcommitted
fix(streaming): drain coalesced buffer before terminal Done, dedupe Done
The Done path published the terminal update before reaping the buffer, so a delta still in the coalescing window arrived after Done, and close() emitted a second Done — consumers saw a stale trailing delta and a duplicate terminal. Drain/stop the buffer before the terminal for Done as well as Full, and let close() be the sole Done emitter so it publishes exactly once. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a56d181 commit 9a1c8ba

2 files changed

Lines changed: 33 additions & 8 deletions

File tree

src/agentex/lib/core/services/adk/streaming.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -505,20 +505,23 @@ async def stream_update(self, update: TaskMessageUpdate) -> TaskMessageUpdate |
505505
await self._buffer.add(update)
506506
return update
507507

508-
# Drain and stop the buffer BEFORE publishing the Full, so leftover
509-
# deltas land in order (deltas -> Full); publishing them after the
510-
# terminal Full would look like a stale duplicate tail. This also stops
511-
# the ticker so it isn't orphaned past __aexit__'s close().
512-
if isinstance(update, StreamTaskMessageFull) and self._buffer is not None:
508+
# Terminal Done/Full updates must drain and stop the buffer BEFORE the
509+
# terminal event reaches consumers, so leftover deltas land in order
510+
# (deltas -> terminal) instead of trailing it as a stale duplicate tail.
511+
# This also stops the ticker so it isn't orphaned past __aexit__'s close().
512+
if isinstance(update, (StreamTaskMessageDone, StreamTaskMessageFull)) and self._buffer is not None:
513513
await self._buffer.close()
514514
self._buffer = None
515515

516-
result = await self._streaming_service.stream_update(update)
517-
518516
if isinstance(update, StreamTaskMessageDone):
517+
# close() publishes the single terminal Done, persists, and marks the
518+
# context closed — don't publish here too, that would duplicate it.
519519
await self.close()
520520
return update
521-
elif isinstance(update, StreamTaskMessageFull):
521+
522+
result = await self._streaming_service.stream_update(update)
523+
524+
if isinstance(update, StreamTaskMessageFull):
522525
await self._agentex_client.messages.update(
523526
task_id=self.task_id,
524527
message_id=update.parent_task_message.id, # type: ignore[union-attr]

tests/lib/core/services/adk/test_streaming.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
ReasoningSummaryDelta,
2424
)
2525
from agentex.types.task_message_update import (
26+
StreamTaskMessageDone,
2627
StreamTaskMessageFull,
2728
StreamTaskMessageDelta,
2829
)
@@ -621,6 +622,27 @@ async def test_full_is_terminal_publish_no_trailing_deltas(self) -> None:
621622
"expected the buffered deltas to be published before the Full"
622623
)
623624

625+
@pytest.mark.asyncio
626+
async def test_done_is_single_terminal_publish_no_trailing_deltas(self) -> None:
627+
# Same guarantee as Full: buffered deltas publish BEFORE the terminal
628+
# Done, and Done is published exactly once (not duplicated by close()).
629+
ctx, svc, tm = await _make_context("coalesced")
630+
# "alpha" flushes immediately; "beta" stays buffered in the window.
631+
await ctx.stream_update(_text(tm, "alpha"))
632+
await ctx.stream_update(_text(tm, "beta"))
633+
634+
await ctx.stream_update(StreamTaskMessageDone(parent_task_message=tm, type="done"))
635+
636+
published = [c.args[0] for c in svc.stream_update.await_args_list]
637+
dones = [u for u in published if isinstance(u, StreamTaskMessageDone)]
638+
assert len(dones) == 1, f"Done must publish exactly once, saw {len(dones)}"
639+
assert isinstance(published[-1], StreamTaskMessageDone), (
640+
f"Done must be the terminal publish; saw trailing {type(published[-1]).__name__}"
641+
)
642+
assert any(isinstance(u, StreamTaskMessageDelta) for u in published[:-1]), (
643+
"expected the buffered deltas to be published before the Done"
644+
)
645+
624646
@pytest.mark.asyncio
625647
async def test_close_reaps_buffer_even_if_already_marked_closed(self) -> None:
626648
# close() must stop the ticker even when _is_closed is already set.

0 commit comments

Comments
 (0)