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 chengyongru
parent dcc9c057bb
commit c121547114
3 changed files with 50 additions and 50 deletions

View File

@ -251,6 +251,7 @@ class FeishuConfig(Base):
allow_from: list[str] = Field(default_factory=list)
react_emoji: str = "THUMBSUP"
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"
reply_to_message: bool = False # If True, bot replies quote the user's original message
streaming: bool = True
@ -267,7 +268,6 @@ class _FeishuStreamBuf:
card_id: str | None = None
sequence: int = 0
last_edit: float = 0.0
tool_hint_len: int = 0
class FeishuChannel(BaseChannel):
@ -1265,7 +1265,15 @@ class FeishuChannel(BaseChannel):
async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | 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:
return
meta = metadata or {}
@ -1287,7 +1295,6 @@ 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:
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,
@ -1297,7 +1304,6 @@ class FeishuChannel(BaseChannel):
buf = self._stream_bufs.pop(chat_id, None)
if not buf or not buf.text:
return
buf.tool_hint_len = 0
if buf.card_id:
buf.sequence += 1
await loop.run_in_executor(
@ -1333,8 +1339,6 @@ class FeishuChannel(BaseChannel):
if buf is None:
buf = _FeishuStreamBuf()
self._stream_bufs[chat_id] = buf
if buf.tool_hint_len > 0:
buf.tool_hint_len = 0
buf.text += delta
if not buf.text.strip():
return
@ -1377,22 +1381,17 @@ 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]
lines = self._format_tool_hint_lines(hint).split("\n")
formatted = "\n".join(f"🔧 {ln}" for ln in lines if ln.strip())
suffix = f"\n\n{formatted}\n\n"
buf.text += suffix
buf.tool_hint_len = len(suffix)
buf.sequence += 1
await loop.run_in_executor(
None, self._stream_update_text_sync,
buf.card_id, buf.text, buf.sequence,
)
else:
await self._send_tool_hint_card(
receive_id_type, msg.chat_id, hint
)
# Delegate to send_delta so tool hints get the same
# throttling (and card creation) as regular text deltas.
lines = self.__class__._format_tool_hint_lines(hint).split("\n")
delta = "\n\n" + "\n".join(
f"{self.config.tool_hint_prefix} {ln}" for ln in lines if ln.strip()
) + "\n\n"
await self.send_delta(msg.chat_id, delta)
return
await self._send_tool_hint_card(
receive_id_type, msg.chat_id, hint
)
return
# Determine whether the first message should quote the user's message.
@ -1701,7 +1700,7 @@ class FeishuChannel(BaseChannel):
loop = asyncio.get_running_loop()
# 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 = {
"config": {"wide_screen_mode": True},

View File

@ -63,17 +63,6 @@ def _get_args(tc) -> dict:
return {}
def _group_consecutive(calls: list) -> list[tuple[str, int, object]]:
"""Group consecutive calls to the same tool: [(name, count, first), ...]."""
groups: list[tuple[str, int, object]] = []
for tc in calls:
if groups and groups[-1][0] == tc.name:
groups[-1] = (groups[-1][0], groups[-1][1] + 1, groups[-1][2])
else:
groups.append((tc.name, 1, tc))
return groups
def _extract_arg(tc, key_args: list[str]) -> str | None:
"""Extract the first available value from preferred key names."""
args = _get_args(tc)

View File

@ -310,7 +310,6 @@ class TestToolHintInlineStreaming:
buf = ch._stream_bufs["oc_chat1"]
assert '🔧 web_fetch("https://example.com")' in buf.text
assert buf.tool_hint_len > 0
assert buf.sequence == 3
ch._client.cardkit.v1.card_element.content.assert_called_once()
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):
"""When new delta arrives, the tool hint is kept as permanent content and delta appends after it."""
ch = _make_channel()
suffix = "\n\n🔧 web_fetch(\"url\")\n\n"
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,
tool_hint_len=len(suffix),
)
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 "🔧 web_fetch" in buf.text
assert buf.text.endswith(" continued")
assert buf.tool_hint_len == 0
@pytest.mark.asyncio
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()
@pytest.mark.asyncio
async def test_consecutive_tool_hints_replace_previous(self):
"""When multiple tool hints arrive consecutively, each replaces the previous one."""
async def test_consecutive_tool_hints_append(self):
"""When multiple tool hints arrive consecutively, each appends to the card."""
ch = _make_channel()
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Partial answer", card_id="card_1", sequence=2, last_edit=0.0,
@ -373,20 +369,19 @@ class TestToolHintInlineStreaming:
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 "$ cd /project" in buf.text
assert "$ git status" in buf.text
assert buf.text.startswith("Partial answer")
assert "🔧 $ cd /project" in buf.text
assert "🔧 $ git status" in buf.text
@pytest.mark.asyncio
async def test_tool_hint_preserved_on_resuming_flush(self):
"""When _resuming flushes the buffer, tool hint is kept as permanent content."""
ch = _make_channel()
suffix = "\n\n🔧 $ cd /project\n\n"
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,
tool_hint_len=len(suffix),
)
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
@ -395,17 +390,14 @@ class TestToolHintInlineStreaming:
buf = ch._stream_bufs["oc_chat1"]
assert "Partial answer" in buf.text
assert "🔧 $ cd /project" in buf.text
assert buf.tool_hint_len == 0
@pytest.mark.asyncio
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."""
ch = _make_channel()
suffix = "\n\n🔧 web_fetch(\"url\")\n\n"
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,
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()
@ -416,6 +408,26 @@ class TestToolHintInlineStreaming:
update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0]
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:
def test_returns_message_id_on_success(self):