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")