fix(gateway): linearize tool-progress bubbles with content messages (#17280)
After PR #7885 (97b0cd51e) added content-side segment breaks for
natural mid-turn assistant messages, the tool-progress task in
gateway/run.py was not updated to match. progress_msg_id and
progress_lines persisted for the whole run, so after a tool batch
produced bubble B1 followed by content bubble C1, the next tool.started
kept editing the OLD bubble B1 above C1 — making the chat appear out
of order on Telegram, Discord, and Slack.
Add on_new_message callback to GatewayStreamConsumer, fired at the
four sites where a fresh content bubble lands on the platform:
- _send_or_edit first-send branch (NOT edits)
- _send_commentary
- _send_new_chunk (overflow split)
- each successful chunk of _send_fallback_final
Gateway supplies a lambda that enqueues ('__reset__',) into the
progress_queue. send_progress_messages() handles the marker in both
the main loop and the CancelledError drain path, clearing
progress_msg_id, progress_lines, and the dedup state so the next
tool.started opens a fresh bubble below the new content.
Result: each tool batch appears in chronological order below the
preceding content. When no content appears between tool batches,
tools still group in one bubble (CLI-style compactness).
Co-authored-by: teknium1 <teknium@users.noreply.github.com>
This commit is contained in:
@@ -1337,3 +1337,159 @@ class TestCursorStrippingOnFallback:
|
||||
assert consumer._already_sent is True
|
||||
# _last_sent_text must NOT be updated when the edit failed
|
||||
assert consumer._last_sent_text == "Hello ▉"
|
||||
|
||||
|
||||
# ── on_new_message callback (tool-progress linearization) ─────────────
|
||||
|
||||
|
||||
class TestOnNewMessageCallback:
|
||||
"""The on_new_message callback fires whenever a fresh content bubble
|
||||
lands on the platform. Gateway uses this to close off the current
|
||||
tool-progress bubble so the next tool.started opens a new bubble
|
||||
below the content — preserving chronological order in the chat.
|
||||
|
||||
Before this callback existed (post PR #7885), content messages got
|
||||
their own bubbles after segment breaks, but the tool-progress task
|
||||
kept editing the ORIGINAL progress bubble above all new content.
|
||||
Result: tool lines appeared stacked in the upper bubble while
|
||||
content messages lined up below, making the timeline look scrambled.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_fires_on_first_send(self):
|
||||
"""First-send of a new content bubble fires on_new_message."""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(return_value=SimpleNamespace(success=True, message_id="msg_1"))
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True))
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
events = []
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=1)
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter, "chat", config,
|
||||
on_new_message=lambda: events.append("reset"),
|
||||
)
|
||||
|
||||
consumer.on_delta("Hello")
|
||||
consumer.finish()
|
||||
await consumer.run()
|
||||
|
||||
assert events == ["reset"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_fires_once_per_segment(self):
|
||||
"""A new first-send fires the callback again after segment break."""
|
||||
adapter = MagicMock()
|
||||
msg_counter = iter(["msg_1", "msg_2", "msg_3"])
|
||||
adapter.send = AsyncMock(
|
||||
side_effect=lambda **kw: SimpleNamespace(success=True, message_id=next(msg_counter))
|
||||
)
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True))
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
events = []
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=1)
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter, "chat", config,
|
||||
on_new_message=lambda: events.append("reset"),
|
||||
)
|
||||
|
||||
consumer.on_delta("A")
|
||||
consumer.on_delta(None)
|
||||
consumer.on_delta("B")
|
||||
consumer.on_delta(None)
|
||||
consumer.on_delta("C")
|
||||
consumer.finish()
|
||||
await consumer.run()
|
||||
|
||||
# Three content bubbles ⇒ three reset notifications
|
||||
assert events == ["reset", "reset", "reset"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_not_fired_on_edit(self):
|
||||
"""Subsequent edits of the same bubble do NOT fire the callback."""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(return_value=SimpleNamespace(success=True, message_id="msg_1"))
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True))
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
events = []
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=1)
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter, "chat", config,
|
||||
on_new_message=lambda: events.append("reset"),
|
||||
)
|
||||
|
||||
consumer.on_delta("Hello")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.on_delta(" world")
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.on_delta(" more")
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
# Only one first-send happened; edits do not re-fire.
|
||||
assert events == ["reset"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_fires_on_commentary(self):
|
||||
"""Commentary messages are fresh bubbles too — fire the callback."""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(return_value=SimpleNamespace(success=True, message_id="msg_1"))
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True))
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
events = []
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=1)
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter, "chat", config,
|
||||
on_new_message=lambda: events.append("reset"),
|
||||
)
|
||||
|
||||
consumer.on_commentary("I'll search for that first.")
|
||||
consumer.finish()
|
||||
await consumer.run()
|
||||
|
||||
assert events == ["reset"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_error_swallowed(self):
|
||||
"""Exceptions in the callback do not crash the consumer."""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(return_value=SimpleNamespace(success=True, message_id="msg_1"))
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True))
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
def raiser():
|
||||
raise RuntimeError("boom")
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=1)
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter, "chat", config,
|
||||
on_new_message=raiser,
|
||||
)
|
||||
|
||||
consumer.on_delta("Hello")
|
||||
consumer.finish()
|
||||
await consumer.run() # must not raise
|
||||
|
||||
assert consumer.already_sent is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_callback_when_none(self):
|
||||
"""Consumer works correctly when on_new_message is None (default)."""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(return_value=SimpleNamespace(success=True, message_id="msg_1"))
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True))
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=1)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat", config) # no callback
|
||||
|
||||
consumer.on_delta("Hello")
|
||||
consumer.finish()
|
||||
await consumer.run()
|
||||
|
||||
assert consumer.already_sent is True
|
||||
|
||||
Reference in New Issue
Block a user