fix(feishu): prevent tool hint stacking and clean hints on stream_end

Three fixes for inline tool hints:

1. Consecutive tool hints now replace the previous one instead of
   stacking — the old suffix is stripped before appending the new one.

2. When _resuming flushes the buffer, any trailing tool hint suffix
   is removed so it doesn't persist into the next streaming segment.

3. When final _stream_end closes the card, tool hint suffix is
   cleaned from the text before the final card update.

Adds 3 regression tests covering all three scenarios.

Made-with: Cursor
This commit is contained in:
xzq.xu 2026-04-08 11:33:57 +08:00 committed by chengyongru
parent 8d6f41e484
commit a4bb1923ac
2 changed files with 72 additions and 0 deletions

View File

@ -1287,6 +1287,9 @@ class FeishuChannel(BaseChannel):
# next segment appends to the same card.
buf = self._stream_bufs.get(chat_id)
if buf and buf.card_id and buf.text:
if buf.tool_hint_len > 0:
buf.text = buf.text[:-buf.tool_hint_len]
buf.tool_hint_len = 0
buf.sequence += 1
await loop.run_in_executor(
None, self._stream_update_text_sync, buf.card_id, buf.text, buf.sequence,
@ -1296,6 +1299,9 @@ class FeishuChannel(BaseChannel):
buf = self._stream_bufs.pop(chat_id, None)
if not buf or not buf.text:
return
if buf.tool_hint_len > 0:
buf.text = buf.text[:-buf.tool_hint_len]
buf.tool_hint_len = 0
if buf.card_id:
buf.sequence += 1
await loop.run_in_executor(
@ -1376,6 +1382,8 @@ class FeishuChannel(BaseChannel):
return
buf = self._stream_bufs.get(msg.chat_id)
if buf and buf.card_id:
if buf.tool_hint_len > 0:
buf.text = buf.text[:-buf.tool_hint_len]
suffix = f"\n\n---\n🔧 {hint}"
buf.text += suffix
buf.tool_hint_len = len(suffix)

View File

@ -349,6 +349,70 @@ class TestToolHintInlineStreaming:
assert "oc_chat1" not in ch._stream_bufs
ch._client.im.v1.message.create.assert_called_once()
@pytest.mark.asyncio
async def test_consecutive_tool_hints_replace_previous(self):
"""When multiple tool hints arrive consecutively, each replaces the previous one."""
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()
msg1 = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='$ cd /project', metadata={"_tool_hint": True},
)
await ch.send(msg1)
msg2 = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content='$ git status', metadata={"_tool_hint": True},
)
await ch.send(msg2)
buf = ch._stream_bufs["oc_chat1"]
assert buf.text.count("$ cd /project") == 0
assert buf.text.count("$ git status") == 1
assert buf.text.startswith("Partial answer")
assert buf.text.endswith("🔧 $ git status")
@pytest.mark.asyncio
async def test_tool_hint_stripped_on_resuming_flush(self):
"""When _resuming flushes the buffer, tool hint suffix is cleaned."""
ch = _make_channel()
suffix = "\n\n---\n🔧 $ cd /project"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer" + suffix,
card_id="card_1", sequence=2, last_edit=0.0,
tool_hint_len=len(suffix),
)
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True, "_resuming": True})
buf = ch._stream_bufs["oc_chat1"]
assert buf.text == "Partial answer"
assert buf.tool_hint_len == 0
@pytest.mark.asyncio
async def test_tool_hint_stripped_on_final_stream_end(self):
"""When final _stream_end closes the card, tool hint suffix is cleaned from text."""
ch = _make_channel()
suffix = "\n\n---\n🔧 web_fetch(\"url\")"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Final content" + suffix,
card_id="card_1", sequence=3, last_edit=0.0,
tool_hint_len=len(suffix),
)
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})
assert "oc_chat1" not in ch._stream_bufs
update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0]
assert "🔧" not in update_call.body.content
class TestSendMessageReturnsId:
def test_returns_message_id_on_success(self):