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
This commit is contained in:
甘全
2026-04-28 17:29:12 +08:00
committed by Xubin Ren
parent 278ef22776
commit 0053e68423
2 changed files with 64 additions and 1 deletions
+6 -1
View File
@@ -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)
+58
View File
@@ -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")