Skip to content

Commit 316faf4

Browse files
committed
feat: ensure Full message closes coalescing buffer to prevent stale duplicates
1 parent f8f8427 commit 316faf4

2 files changed

Lines changed: 40 additions & 9 deletions

File tree

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

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -518,21 +518,23 @@ async def stream_update(self, update: TaskMessageUpdate) -> TaskMessageUpdate |
518518
await self._buffer.add(update)
519519
return update
520520

521+
# A full message supersedes the streamed deltas and ends the stream.
522+
# Drain and stop the coalescing buffer BEFORE publishing the Full, so any
523+
# leftover buffered deltas land on the stream in order (deltas -> Full)
524+
# rather than after the terminal Full — a consumer treating Full as the
525+
# final message would otherwise see those trailing deltas as a stale
526+
# duplicate tail. Closing here also stops the ticker, so it can't be
527+
# orphaned when __aexit__'s close() later short-circuits on _is_closed.
528+
if isinstance(update, StreamTaskMessageFull) and self._buffer is not None:
529+
await self._buffer.close()
530+
self._buffer = None
531+
521532
result = await self._streaming_service.stream_update(update)
522533

523534
if isinstance(update, StreamTaskMessageDone):
524535
await self.close()
525536
return update
526537
elif isinstance(update, StreamTaskMessageFull):
527-
# A full message supersedes any buffered deltas and ends the stream.
528-
# Close the coalescing buffer (stopping its ticker) BEFORE marking
529-
# the context done — otherwise __aexit__'s close() early-returns on
530-
# _is_closed and the ticker is never stopped, polling forever and
531-
# leaking CPU (mirrors the StreamTaskMessageDone branch, which closes
532-
# via self.close()).
533-
if self._buffer is not None:
534-
await self._buffer.close()
535-
self._buffer = None
536538
await self._agentex_client.messages.update(
537539
task_id=self.task_id,
538540
message_id=update.parent_task_message.id, # type: ignore[union-attr]

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,35 @@ async def test_full_message_stops_ticker(self) -> None:
613613
assert ctx._buffer is None, "Full message left the buffer un-closed"
614614
assert task.done(), "coalescing-buffer ticker still running after Full (orphaned)"
615615

616+
@pytest.mark.asyncio
617+
async def test_full_is_terminal_publish_no_trailing_deltas(self) -> None:
618+
# Leftover buffered deltas must be drained BEFORE the Full hits the
619+
# stream (deltas -> Full), never after it — a consumer treating Full as
620+
# the final message would see a trailing delta as a stale duplicate tail.
621+
ctx, svc, tm = await _make_context("coalesced")
622+
# First delta flushes immediately; the second stays in the coalescing
623+
# window, so it is still buffered when the Full arrives.
624+
await ctx.stream_update(_text(tm, "alpha"))
625+
await ctx.stream_update(_text(tm, "beta"))
626+
627+
full = StreamTaskMessageFull(
628+
parent_task_message=tm,
629+
content=TextContent(author="agent", content="alphabeta", format="markdown"),
630+
type="full",
631+
)
632+
await ctx.stream_update(full)
633+
634+
# Every publish (delta flushes + the Full) goes through the service mock.
635+
published = [c.args[0] for c in svc.stream_update.await_args_list]
636+
assert published, "nothing was published"
637+
assert published[-1] is full, (
638+
f"Full must be the terminal publish; saw trailing "
639+
f"{type(published[-1]).__name__} after it (stale duplicate tail)"
640+
)
641+
assert any(isinstance(u, StreamTaskMessageDelta) for u in published[:-1]), (
642+
"expected the buffered deltas to be published before the Full"
643+
)
644+
616645
@pytest.mark.asyncio
617646
async def test_close_reaps_buffer_even_if_already_marked_closed(self) -> None:
618647
# Defense-in-depth: if any path marks the context closed without closing

0 commit comments

Comments
 (0)