From 0053e684233a39e086bb7ecf0f5389ae4a4f94f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=98=E5=85=A8?= Date: Tue, 28 Apr 2026 17:09:41 +0800 Subject: [PATCH] fix(feishu): skip reaction transition on resuming stream end Stream-end events are emitted at the end of every assistant turn. When the agent has more tool-call rounds queued, the runner sets `_resuming=True` on the metadata. Without a guard, every intermediate stream end removed the OnIt reaction (the first one wins, since `_reaction_ids.pop` empties the slot) and re-added `done_emoji`, producing a DONE reaction after every tool call instead of only at final completion. Wrap the OnIt removal and `done_emoji` add in a `not _resuming` guard so the OnIt indicator persists across tool-call rounds and DONE fires exactly once when the agent's final response lands. `_resuming` already flows through outbound metadata (`nanobot/agent/loop.py:747`) and survives `_coalesce_stream_deltas` because pure `_stream_end` messages without `_stream_delta` skip the merge branch. Tests: - test_no_removal_when_resuming - test_done_emoji_only_on_final_stream_end --- nanobot/channels/feishu.py | 7 +++- tests/channels/test_feishu_reaction.py | 58 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 57260e90..0071ef41 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1361,7 +1361,12 @@ class FeishuChannel(BaseChannel): # --- stream end: final update or fallback --- if meta.get("_stream_end"): message_id = meta.get("message_id") - if message_id: + # Only finalize the OnIt -> DONE reaction transition on the truly + # final stream end. _resuming=True means the agent will keep + # working (more tool-call rounds), so leave the reaction state + # in place — otherwise the OnIt indicator disappears prematurely + # and the DONE reaction fires after every tool call. + if message_id and not meta.get("_resuming"): reaction_id = self._reaction_ids.pop(message_id, None) if reaction_id: await self._remove_reaction(message_id, reaction_id) diff --git a/tests/channels/test_feishu_reaction.py b/tests/channels/test_feishu_reaction.py index 68229e26..9d070ad5 100644 --- a/tests/channels/test_feishu_reaction.py +++ b/tests/channels/test_feishu_reaction.py @@ -255,3 +255,61 @@ class TestStreamEndReactionCleanup: ) ch._remove_reaction.assert_not_called() + + @pytest.mark.asyncio + async def test_no_removal_when_resuming(self): + """_resuming=True means more tool-call rounds follow; reaction must persist.""" + ch = _make_channel() + ch.config.done_emoji = "DONE" + ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( + text="partial", card_id="card_1", sequence=3, last_edit=0.0, + ) + ch._reaction_ids["om_001"] = "rx_42" + ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True)) + ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True)) + ch._remove_reaction = AsyncMock() + ch._add_reaction = AsyncMock() + + await ch.send_delta( + "oc_chat1", "", + metadata={"_stream_end": True, "_resuming": True, "message_id": "om_001"}, + ) + + ch._remove_reaction.assert_not_called() + ch._add_reaction.assert_not_called() + # OnIt reaction id is still tracked for the eventual final stream end + assert ch._reaction_ids.get("om_001") == "rx_42" + + @pytest.mark.asyncio + async def test_done_emoji_only_on_final_stream_end(self): + """Across resuming rounds, done_emoji is added only on the final round.""" + ch = _make_channel() + ch.config.done_emoji = "DONE" + ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( + text="t", card_id="card_1", sequence=3, last_edit=0.0, + ) + ch._reaction_ids["om_001"] = "rx_42" + ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True)) + ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True)) + ch._remove_reaction = AsyncMock() + ch._add_reaction = AsyncMock() + + # Intermediate stream end (more tool calls coming). + await ch.send_delta( + "oc_chat1", "", + metadata={"_stream_end": True, "_resuming": True, "message_id": "om_001"}, + ) + ch._remove_reaction.assert_not_called() + ch._add_reaction.assert_not_called() + + # Re-prime the stream buffer for the final round (the previous _stream_end popped it). + ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( + text="t", card_id="card_1", sequence=5, last_edit=0.0, + ) + # Final stream end (resuming=False): OnIt removed, done_emoji added. + await ch.send_delta( + "oc_chat1", "", + metadata={"_stream_end": True, "_resuming": False, "message_id": "om_001"}, + ) + ch._remove_reaction.assert_called_once_with("om_001", "rx_42") + ch._add_reaction.assert_called_once_with("om_001", "DONE")