refactor(feishu): extract tool hint card sending into dedicated method

- Extract card creation logic into _send_tool_hint_card() helper
- Improves code organization and testability
- Update tests to use pytest.mark.asyncio for cleaner async testing
- Remove redundant asyncio.run() calls in favor of native async test functions
This commit is contained in:
Tony
2026-03-13 14:52:15 +08:00
parent 82064efe51
commit 87ab980bd1
2 changed files with 44 additions and 35 deletions

View File

@@ -825,28 +825,7 @@ class FeishuChannel(BaseChannel):
# Handle tool hint messages as code blocks in interactive cards # Handle tool hint messages as code blocks in interactive cards
if msg.metadata.get("_tool_hint"): if msg.metadata.get("_tool_hint"):
if msg.content and msg.content.strip(): if msg.content and msg.content.strip():
# Create a simple card with a code block await self._send_tool_hint_card(receive_id_type, msg.chat_id, msg.content.strip())
code_text = msg.content.strip()
# Format tool calls: put each tool on its own line for better readability
# _tool_hint uses ", " to join multiple tool calls
if ", " in code_text:
formatted_code = code_text.replace(", ", ",\n")
else:
formatted_code = code_text
card = {
"config": {"wide_screen_mode": True},
"elements": [
{
"tag": "markdown",
"content": f"**Tool Calls**\n\n```text\n{formatted_code}\n```"
}
]
}
await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, "interactive",
json.dumps(card, ensure_ascii=False),
)
return return
for file_path in msg.media: for file_path in msg.media:
@@ -1030,3 +1009,33 @@ class FeishuChannel(BaseChannel):
"""Ignore p2p-enter events when a user opens a bot chat.""" """Ignore p2p-enter events when a user opens a bot chat."""
logger.debug("Bot entered p2p chat (user opened chat window)") logger.debug("Bot entered p2p chat (user opened chat window)")
pass pass
async def _send_tool_hint_card(self, receive_id_type: str, receive_id: str, tool_hint: str) -> None:
"""Send tool hint as an interactive card with formatted code block.
Args:
receive_id_type: "chat_id" or "open_id"
receive_id: The target chat or user ID
tool_hint: Formatted tool hint string (e.g., 'web_search("q"), read_file("path")')
"""
loop = asyncio.get_running_loop()
# Format: put each tool call on its own line for readability
# _tool_hint joins multiple calls with ", "
formatted_code = tool_hint.replace(", ", ",\n") if ", " in tool_hint else tool_hint
card = {
"config": {"wide_screen_mode": True},
"elements": [
{
"tag": "markdown",
"content": f"**Tool Calls**\n\n```text\n{formatted_code}\n```"
}
]
}
await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, receive_id, "interactive",
json.dumps(card, ensure_ascii=False),
)

View File

@@ -4,6 +4,7 @@ import json
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
from pytest import mark
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.channels.feishu import FeishuChannel from nanobot.channels.feishu import FeishuChannel
@@ -23,7 +24,8 @@ def mock_feishu_channel():
return channel return channel
def test_tool_hint_sends_code_message(mock_feishu_channel): @mark.asyncio
async def test_tool_hint_sends_code_message(mock_feishu_channel):
"""Tool hint messages should be sent as interactive cards with code blocks.""" """Tool hint messages should be sent as interactive cards with code blocks."""
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", channel="feishu",
@@ -33,9 +35,7 @@ def test_tool_hint_sends_code_message(mock_feishu_channel):
) )
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
# Run send in async context await mock_feishu_channel.send(msg)
import asyncio
asyncio.run(mock_feishu_channel.send(msg))
# Verify interactive message with card was sent # Verify interactive message with card was sent
assert mock_send.call_count == 1 assert mock_send.call_count == 1
@@ -56,7 +56,8 @@ def test_tool_hint_sends_code_message(mock_feishu_channel):
assert card["elements"][0]["content"] == expected_md assert card["elements"][0]["content"] == expected_md
def test_tool_hint_empty_content_does_not_send(mock_feishu_channel): @mark.asyncio
async def test_tool_hint_empty_content_does_not_send(mock_feishu_channel):
"""Empty tool hint messages should not be sent.""" """Empty tool hint messages should not be sent."""
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", channel="feishu",
@@ -66,14 +67,14 @@ def test_tool_hint_empty_content_does_not_send(mock_feishu_channel):
) )
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
import asyncio await mock_feishu_channel.send(msg)
asyncio.run(mock_feishu_channel.send(msg))
# Should not send any message # Should not send any message
mock_send.assert_not_called() mock_send.assert_not_called()
def test_tool_hint_without_metadata_sends_as_normal(mock_feishu_channel): @mark.asyncio
async def test_tool_hint_without_metadata_sends_as_normal(mock_feishu_channel):
"""Regular messages without _tool_hint should use normal formatting.""" """Regular messages without _tool_hint should use normal formatting."""
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", channel="feishu",
@@ -83,8 +84,7 @@ def test_tool_hint_without_metadata_sends_as_normal(mock_feishu_channel):
) )
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
import asyncio await mock_feishu_channel.send(msg)
asyncio.run(mock_feishu_channel.send(msg))
# Should send as text message (detected format) # Should send as text message (detected format)
assert mock_send.call_count == 1 assert mock_send.call_count == 1
@@ -94,7 +94,8 @@ def test_tool_hint_without_metadata_sends_as_normal(mock_feishu_channel):
assert json.loads(content) == {"text": "Hello, world!"} assert json.loads(content) == {"text": "Hello, world!"}
def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel): @mark.asyncio
async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel):
"""Multiple tool calls should be displayed each on its own line in a code block.""" """Multiple tool calls should be displayed each on its own line in a code block."""
msg = OutboundMessage( msg = OutboundMessage(
channel="feishu", channel="feishu",
@@ -104,8 +105,7 @@ def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel):
) )
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send: with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
import asyncio await mock_feishu_channel.send(msg)
asyncio.run(mock_feishu_channel.send(msg))
call_args = mock_send.call_args[0] call_args = mock_send.call_args[0]
msg_type = call_args[2] msg_type = call_args[2]