From 87f5c5351a2c56e27dc271d7a865a874c18b0bfd Mon Sep 17 00:00:00 2001 From: MaxFreedomPollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:15:34 -0400 Subject: [PATCH] fix(yuanbao): await the forwarded-records loading heartbeat ForwardedRecordsParseMiddleware.handle() called the coroutine function _send_loading_heartbeat() without awaiting it, so the coroutine was built and dropped. The RUNNING heartbeat never reached the client and Python raised "coroutine was never awaited". Forwarded-record parsing is the slow inbound path, which is where the loading bubble matters most: the user sees nothing while the deep parse runs. Awaiting is safe, since the helper already swallows every exception and the call sits inside the middleware's own try block. --- gateway/platforms/yuanbao.py | 2 +- .../test_yuanbao_forwarded_heartbeat.py | 74 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 tests/gateway/test_yuanbao_forwarded_heartbeat.py diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index 96f8580179df3..ad5da066146ea 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -2160,7 +2160,7 @@ class ForwardedRecordsParseMiddleware(InboundMiddleware): async def handle(self, ctx: InboundContext, next_fn) -> None: try: if ctx.forwarded_records: - self._send_loading_heartbeat(ctx) + await self._send_loading_heartbeat(ctx) ctx.raw_text = self.build_forward_text(ctx.forwarded_records, ctx=ctx, is_dispatch=True) except Exception as exc: # Degrade gracefully: leave ctx.raw_text as-is. diff --git a/tests/gateway/test_yuanbao_forwarded_heartbeat.py b/tests/gateway/test_yuanbao_forwarded_heartbeat.py new file mode 100644 index 0000000000000..6ddd578fc73a3 --- /dev/null +++ b/tests/gateway/test_yuanbao_forwarded_heartbeat.py @@ -0,0 +1,74 @@ +"""ForwardedRecordsParseMiddleware must actually send its loading heartbeat. + +``_send_loading_heartbeat`` is a coroutine function. Calling it without +``await`` builds a coroutine object and drops it, so the RUNNING heartbeat is +never sent and Python raises "coroutine was never awaited" at collection time. +Forwarded-record parsing is the slow inbound path, which is exactly where the +user needs the loading bubble. +""" + +import warnings + +import pytest + +from gateway.platforms.yuanbao import ( + ForwardedRecordsParseMiddleware, + InboundContext, + WS_HEARTBEAT_RUNNING, +) + + +class _Heartbeat: + def __init__(self): + self.calls = [] + + async def send_heartbeat_once(self, chat_id, state): + self.calls.append((chat_id, state)) + + +class _Outbound: + def __init__(self): + self.heartbeat = _Heartbeat() + + +class _Adapter: + name = "yuanbao" + + def __init__(self): + self._outbound = _Outbound() + + +@pytest.mark.asyncio +async def test_forwarded_records_send_the_loading_heartbeat(): + adapter = _Adapter() + ctx = InboundContext(adapter=adapter) + ctx.chat_id = "chat-1" + ctx.forwarded_records = [{"msgContent": {"text": "hello"}}] + + called = [] + + async def _next(): + called.append(True) + + with warnings.catch_warnings(): + # A dropped coroutine surfaces here as a RuntimeWarning rather than a + # failure; promote it so the regression cannot pass silently. + warnings.simplefilter("error", RuntimeWarning) + await ForwardedRecordsParseMiddleware().handle(ctx, _next) + + assert adapter._outbound.heartbeat.calls == [("chat-1", WS_HEARTBEAT_RUNNING)] + assert called, "middleware must still call next_fn" + + +@pytest.mark.asyncio +async def test_no_forwarded_records_sends_no_heartbeat(): + adapter = _Adapter() + ctx = InboundContext(adapter=adapter) + ctx.chat_id = "chat-1" + + async def _next(): + pass + + await ForwardedRecordsParseMiddleware().handle(ctx, _next) + + assert adapter._outbound.heartbeat.calls == []