diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 7c14651f3..bf2c5a4bd 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1046,6 +1046,19 @@ class FeishuChannel(BaseChannel): # --- stream end: final update or fallback --- if meta.get("_stream_end"): + resuming = meta.get("_resuming", False) + if resuming: + # Mid-turn pause (e.g. tool call between streaming segments). + # Flush current text to card but keep the buffer alive so the + # next segment appends to the same card. + buf = self._stream_bufs.get(chat_id) + if buf and buf.card_id and buf.text: + buf.sequence += 1 + await loop.run_in_executor( + None, self._stream_update_text_sync, buf.card_id, buf.text, buf.sequence, + ) + return + buf = self._stream_bufs.pop(chat_id, None) if not buf or not buf.text: return diff --git a/tests/channels/test_feishu_streaming.py b/tests/channels/test_feishu_streaming.py index 22ad8cbc6..f8e9da846 100644 --- a/tests/channels/test_feishu_streaming.py +++ b/tests/channels/test_feishu_streaming.py @@ -203,6 +203,55 @@ class TestSendDelta: ch._client.cardkit.v1.card_element.content.assert_not_called() ch._client.im.v1.message.create.assert_called_once() + @pytest.mark.asyncio + async def test_stream_end_resuming_keeps_buffer(self): + """_resuming=True flushes text to card but keeps the buffer for the next segment.""" + ch = _make_channel() + ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( + text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0, + ) + ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() + + await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True, "_resuming": True}) + + assert "oc_chat1" in ch._stream_bufs + buf = ch._stream_bufs["oc_chat1"] + assert buf.card_id == "card_1" + assert buf.sequence == 3 + ch._client.cardkit.v1.card_element.content.assert_called_once() + ch._client.cardkit.v1.card.settings.assert_not_called() + + @pytest.mark.asyncio + async def test_stream_end_resuming_then_final_end(self): + """Full multi-segment flow: resuming mid-turn, then final end closes the card.""" + ch = _make_channel() + ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( + text="Seg1", card_id="card_1", sequence=1, last_edit=0.0, + ) + ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response() + ch._client.cardkit.v1.card.settings.return_value = _mock_content_response() + + await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True, "_resuming": True}) + assert "oc_chat1" in ch._stream_bufs + + ch._stream_bufs["oc_chat1"].text += " Seg2" + await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True}) + + assert "oc_chat1" not in ch._stream_bufs + ch._client.cardkit.v1.card.settings.assert_called_once() + + @pytest.mark.asyncio + async def test_stream_end_resuming_no_card_is_noop(self): + """_resuming with no card_id (card creation failed) is a safe no-op.""" + ch = _make_channel() + ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( + text="text", card_id=None, sequence=0, last_edit=0.0, + ) + await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True, "_resuming": True}) + + assert "oc_chat1" in ch._stream_bufs + ch._client.cardkit.v1.card_element.content.assert_not_called() + @pytest.mark.asyncio async def test_stream_end_without_buf_is_noop(self): ch = _make_channel()