diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index e3eeb19..3d83eaa 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -825,28 +825,7 @@ class FeishuChannel(BaseChannel): # Handle tool hint messages as code blocks in interactive cards if msg.metadata.get("_tool_hint"): if msg.content and msg.content.strip(): - # Create a simple card with a code block - 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), - ) + await self._send_tool_hint_card(receive_id_type, msg.chat_id, msg.content.strip()) return for file_path in msg.media: @@ -1030,3 +1009,33 @@ class FeishuChannel(BaseChannel): """Ignore p2p-enter events when a user opens a bot chat.""" logger.debug("Bot entered p2p chat (user opened chat window)") 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), + ) diff --git a/tests/test_feishu_tool_hint_code_block.py b/tests/test_feishu_tool_hint_code_block.py index 7356122..a3fc024 100644 --- a/tests/test_feishu_tool_hint_code_block.py +++ b/tests/test_feishu_tool_hint_code_block.py @@ -4,6 +4,7 @@ import json from unittest.mock import MagicMock, patch import pytest +from pytest import mark from nanobot.bus.events import OutboundMessage from nanobot.channels.feishu import FeishuChannel @@ -23,7 +24,8 @@ def mock_feishu_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.""" msg = OutboundMessage( 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: - # Run send in async context - import asyncio - asyncio.run(mock_feishu_channel.send(msg)) + await mock_feishu_channel.send(msg) # Verify interactive message with card was sent 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 -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.""" msg = OutboundMessage( 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: - import asyncio - asyncio.run(mock_feishu_channel.send(msg)) + await mock_feishu_channel.send(msg) # Should not send any message 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.""" msg = OutboundMessage( 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: - import asyncio - asyncio.run(mock_feishu_channel.send(msg)) + await mock_feishu_channel.send(msg) # Should send as text message (detected format) 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!"} -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.""" msg = OutboundMessage( 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: - import asyncio - asyncio.run(mock_feishu_channel.send(msg)) + await mock_feishu_channel.send(msg) call_args = mock_send.call_args[0] msg_type = call_args[2]