refactor(feishu): simplify tool hint to append-only, delegate to send_delta for throttling

- Make tool_hint_prefix configurable in FeishuConfig (default: 🔧)
- Delegate tool hint card updates from send() to send_delta() so hints
  automatically benefit from _STREAM_EDIT_INTERVAL throttling
- Fix staticmethod calls to use self.__class__ instead of self
- Document all supported metadata keys in send_delta docstring
- Add test for empty/whitespace-only tool hint with active stream buffer
This commit is contained in:
chengyongru 2026-04-08 18:31:40 +08:00 committed by Xubin Ren
parent 049ce9baae
commit 6fd2511c8a
2 changed files with 50 additions and 39 deletions

View File

@ -251,6 +251,7 @@ class FeishuConfig(Base):
allow_from: list[str] = Field(default_factory=list) allow_from: list[str] = Field(default_factory=list)
react_emoji: str = "THUMBSUP" react_emoji: str = "THUMBSUP"
done_emoji: str | None = None # Emoji to show when task is completed (e.g., "DONE", "OK") done_emoji: str | None = None # Emoji to show when task is completed (e.g., "DONE", "OK")
tool_hint_prefix: str = "\U0001f527" # Prefix for inline tool hints (default: 🔧)
group_policy: Literal["open", "mention"] = "mention" group_policy: Literal["open", "mention"] = "mention"
reply_to_message: bool = False # If True, bot replies quote the user's original message reply_to_message: bool = False # If True, bot replies quote the user's original message
streaming: bool = True streaming: bool = True
@ -267,7 +268,6 @@ class _FeishuStreamBuf:
card_id: str | None = None card_id: str | None = None
sequence: int = 0 sequence: int = 0
last_edit: float = 0.0 last_edit: float = 0.0
tool_hint_len: int = 0
class FeishuChannel(BaseChannel): class FeishuChannel(BaseChannel):
@ -1265,7 +1265,15 @@ class FeishuChannel(BaseChannel):
async def send_delta( async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
) -> None: ) -> None:
"""Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.""" """Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.
Supported metadata keys:
_stream_end: Finalize the streaming card.
_resuming: Mid-turn pause flush but keep the buffer alive.
_tool_hint: Delta is a formatted tool hint (for display only).
message_id: Original message id (used with _stream_end for reaction cleanup).
reaction_id: Reaction id to remove on stream end.
"""
if not self._client: if not self._client:
return return
meta = metadata or {} meta = metadata or {}
@ -1287,7 +1295,6 @@ class FeishuChannel(BaseChannel):
# next segment appends to the same card. # next segment appends to the same card.
buf = self._stream_bufs.get(chat_id) buf = self._stream_bufs.get(chat_id)
if buf and buf.card_id and buf.text: if buf and buf.card_id and buf.text:
buf.tool_hint_len = 0
buf.sequence += 1 buf.sequence += 1
await loop.run_in_executor( await loop.run_in_executor(
None, self._stream_update_text_sync, buf.card_id, buf.text, buf.sequence, None, self._stream_update_text_sync, buf.card_id, buf.text, buf.sequence,
@ -1297,7 +1304,6 @@ class FeishuChannel(BaseChannel):
buf = self._stream_bufs.pop(chat_id, None) buf = self._stream_bufs.pop(chat_id, None)
if not buf or not buf.text: if not buf or not buf.text:
return return
buf.tool_hint_len = 0
if buf.card_id: if buf.card_id:
buf.sequence += 1 buf.sequence += 1
await loop.run_in_executor( await loop.run_in_executor(
@ -1333,8 +1339,6 @@ class FeishuChannel(BaseChannel):
if buf is None: if buf is None:
buf = _FeishuStreamBuf() buf = _FeishuStreamBuf()
self._stream_bufs[chat_id] = buf self._stream_bufs[chat_id] = buf
if buf.tool_hint_len > 0:
buf.tool_hint_len = 0
buf.text += delta buf.text += delta
if not buf.text.strip(): if not buf.text.strip():
return return
@ -1377,22 +1381,17 @@ class FeishuChannel(BaseChannel):
return return
buf = self._stream_bufs.get(msg.chat_id) buf = self._stream_bufs.get(msg.chat_id)
if buf and buf.card_id: if buf and buf.card_id:
if buf.tool_hint_len > 0: # Delegate to send_delta so tool hints get the same
buf.text = buf.text[:-buf.tool_hint_len] # throttling (and card creation) as regular text deltas.
lines = self._format_tool_hint_lines(hint).split("\n") lines = self.__class__._format_tool_hint_lines(hint).split("\n")
formatted = "\n".join(f"🔧 {ln}" for ln in lines if ln.strip()) delta = "\n\n" + "\n".join(
suffix = f"\n\n{formatted}\n\n" f"{self.config.tool_hint_prefix} {ln}" for ln in lines if ln.strip()
buf.text += suffix ) + "\n\n"
buf.tool_hint_len = len(suffix) await self.send_delta(msg.chat_id, delta)
buf.sequence += 1 return
await loop.run_in_executor( await self._send_tool_hint_card(
None, self._stream_update_text_sync, receive_id_type, msg.chat_id, hint
buf.card_id, buf.text, buf.sequence, )
)
else:
await self._send_tool_hint_card(
receive_id_type, msg.chat_id, hint
)
return return
# Determine whether the first message should quote the user's message. # Determine whether the first message should quote the user's message.
@ -1701,7 +1700,7 @@ class FeishuChannel(BaseChannel):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
# Put each top-level tool call on its own line without altering commas inside arguments. # Put each top-level tool call on its own line without altering commas inside arguments.
formatted_code = self._format_tool_hint_lines(tool_hint) formatted_code = self.__class__._format_tool_hint_lines(tool_hint)
card = { card = {
"config": {"wide_screen_mode": True}, "config": {"wide_screen_mode": True},

View File

@ -310,7 +310,6 @@ class TestToolHintInlineStreaming:
buf = ch._stream_bufs["oc_chat1"] buf = ch._stream_bufs["oc_chat1"]
assert '🔧 web_fetch("https://example.com")' in buf.text assert '🔧 web_fetch("https://example.com")' in buf.text
assert buf.tool_hint_len > 0
assert buf.sequence == 3 assert buf.sequence == 3
ch._client.cardkit.v1.card_element.content.assert_called_once() ch._client.cardkit.v1.card_element.content.assert_called_once()
ch._client.im.v1.message.create.assert_not_called() ch._client.im.v1.message.create.assert_not_called()
@ -319,11 +318,9 @@ class TestToolHintInlineStreaming:
async def test_tool_hint_preserved_on_next_delta(self): async def test_tool_hint_preserved_on_next_delta(self):
"""When new delta arrives, the tool hint is kept as permanent content and delta appends after it.""" """When new delta arrives, the tool hint is kept as permanent content and delta appends after it."""
ch = _make_channel() ch = _make_channel()
suffix = "\n\n🔧 web_fetch(\"url\")\n\n"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer" + suffix, text="Partial answer\n\n🔧 web_fetch(\"url\")\n\n",
card_id="card_1", sequence=3, last_edit=0.0, 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_element.content.return_value = _mock_content_response()
@ -333,7 +330,6 @@ class TestToolHintInlineStreaming:
assert "Partial answer" in buf.text assert "Partial answer" in buf.text
assert "🔧 web_fetch" in buf.text assert "🔧 web_fetch" in buf.text
assert buf.text.endswith(" continued") assert buf.text.endswith(" continued")
assert buf.tool_hint_len == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tool_hint_fallback_when_no_stream(self): async def test_tool_hint_fallback_when_no_stream(self):
@ -352,8 +348,8 @@ class TestToolHintInlineStreaming:
ch._client.im.v1.message.create.assert_called_once() ch._client.im.v1.message.create.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_consecutive_tool_hints_replace_previous(self): async def test_consecutive_tool_hints_append(self):
"""When multiple tool hints arrive consecutively, each replaces the previous one.""" """When multiple tool hints arrive consecutively, each appends to the card."""
ch = _make_channel() ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0, text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0,
@ -373,20 +369,19 @@ class TestToolHintInlineStreaming:
await ch.send(msg2) await ch.send(msg2)
buf = ch._stream_bufs["oc_chat1"] buf = ch._stream_bufs["oc_chat1"]
assert buf.text.count("$ cd /project") == 0 assert "$ cd /project" in buf.text
assert buf.text.count("$ git status") == 1 assert "$ git status" in buf.text
assert buf.text.startswith("Partial answer") assert buf.text.startswith("Partial answer")
assert "🔧 $ cd /project" in buf.text
assert "🔧 $ git status" in buf.text assert "🔧 $ git status" in buf.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tool_hint_preserved_on_resuming_flush(self): async def test_tool_hint_preserved_on_resuming_flush(self):
"""When _resuming flushes the buffer, tool hint is kept as permanent content.""" """When _resuming flushes the buffer, tool hint is kept as permanent content."""
ch = _make_channel() ch = _make_channel()
suffix = "\n\n🔧 $ cd /project\n\n"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer" + suffix, text="Partial answer\n\n🔧 $ cd /project\n\n",
card_id="card_1", sequence=2, last_edit=0.0, 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() ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
@ -395,17 +390,14 @@ class TestToolHintInlineStreaming:
buf = ch._stream_bufs["oc_chat1"] buf = ch._stream_bufs["oc_chat1"]
assert "Partial answer" in buf.text assert "Partial answer" in buf.text
assert "🔧 $ cd /project" in buf.text assert "🔧 $ cd /project" in buf.text
assert buf.tool_hint_len == 0
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tool_hint_preserved_on_final_stream_end(self): async def test_tool_hint_preserved_on_final_stream_end(self):
"""When final _stream_end closes the card, tool hint is kept in the final text.""" """When final _stream_end closes the card, tool hint is kept in the final text."""
ch = _make_channel() ch = _make_channel()
suffix = "\n\n🔧 web_fetch(\"url\")\n\n"
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Final content" + suffix, text="Final content\n\n🔧 web_fetch(\"url\")\n\n",
card_id="card_1", sequence=3, last_edit=0.0, 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_element.content.return_value = _mock_content_response()
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response() ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
@ -416,6 +408,26 @@ class TestToolHintInlineStreaming:
update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0] update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0]
assert "🔧" in update_call.body.content assert "🔧" in update_call.body.content
@pytest.mark.asyncio
async def test_empty_tool_hint_is_noop(self):
"""Empty or whitespace-only tool hint content is silently ignored."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0,
)
for content in ("", " ", "\t\n"):
msg = OutboundMessage(
channel="feishu", chat_id="oc_chat1",
content=content, metadata={"_tool_hint": True},
)
await ch.send(msg)
buf = ch._stream_bufs["oc_chat1"]
assert buf.text == "Partial answer"
assert buf.sequence == 2
ch._client.cardkit.v1.card_element.content.assert_not_called()
class TestSendMessageReturnsId: class TestSendMessageReturnsId:
def test_returns_message_id_on_success(self): def test_returns_message_id_on_success(self):