2026-04-14 14:14:14 +08:00
|
|
|
|
"""Tests for FeishuChannel tool hint formatting."""
|
2026-03-13 14:41:54 +08:00
|
|
|
|
|
|
|
|
|
|
import json
|
2026-04-14 14:14:14 +08:00
|
|
|
|
from types import SimpleNamespace
|
2026-03-13 14:41:54 +08:00
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
2026-03-13 14:52:15 +08:00
|
|
|
|
from pytest import mark
|
2026-03-13 14:41:54 +08:00
|
|
|
|
|
2026-03-24 13:37:06 +08:00
|
|
|
|
# Check optional Feishu dependencies before running tests
|
|
|
|
|
|
try:
|
|
|
|
|
|
from nanobot.channels import feishu
|
|
|
|
|
|
FEISHU_AVAILABLE = getattr(feishu, "FEISHU_AVAILABLE", False)
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
FEISHU_AVAILABLE = False
|
|
|
|
|
|
|
|
|
|
|
|
if not FEISHU_AVAILABLE:
|
|
|
|
|
|
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
|
|
|
|
|
|
|
2026-03-13 14:41:54 +08:00
|
|
|
|
from nanobot.bus.events import OutboundMessage
|
|
|
|
|
|
from nanobot.channels.feishu import FeishuChannel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
|
def mock_feishu_channel():
|
|
|
|
|
|
"""Create a FeishuChannel with mocked client."""
|
|
|
|
|
|
config = MagicMock()
|
|
|
|
|
|
config.app_id = "test_app_id"
|
|
|
|
|
|
config.app_secret = "test_app_secret"
|
|
|
|
|
|
config.encrypt_key = None
|
|
|
|
|
|
config.verification_token = None
|
2026-04-14 14:14:14 +08:00
|
|
|
|
config.tool_hint_prefix = "\U0001f527" # 🔧
|
2026-03-13 14:41:54 +08:00
|
|
|
|
bus = MagicMock()
|
|
|
|
|
|
channel = FeishuChannel(config, bus)
|
2026-04-14 14:14:14 +08:00
|
|
|
|
channel._client = MagicMock()
|
2026-03-13 14:41:54 +08:00
|
|
|
|
return channel
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-14 14:14:14 +08:00
|
|
|
|
def _get_tool_hint_card(mock_send):
|
|
|
|
|
|
"""Extract the interactive card from _send_message_sync calls."""
|
|
|
|
|
|
call_args = mock_send.call_args[0]
|
|
|
|
|
|
_, _, msg_type, content = call_args
|
|
|
|
|
|
assert msg_type == "interactive"
|
|
|
|
|
|
return json.loads(content)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 14:52:15 +08:00
|
|
|
|
@mark.asyncio
|
2026-04-14 14:14:14 +08:00
|
|
|
|
async def test_tool_hint_sends_interactive_card(mock_feishu_channel):
|
|
|
|
|
|
"""Tool hint without active buffer sends an interactive card with 🔧 style."""
|
2026-03-13 14:41:54 +08:00
|
|
|
|
msg = OutboundMessage(
|
|
|
|
|
|
channel="feishu",
|
|
|
|
|
|
chat_id="oc_123456",
|
|
|
|
|
|
content='web_search("test query")',
|
|
|
|
|
|
metadata={"_tool_hint": True}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
2026-03-13 14:52:15 +08:00
|
|
|
|
await mock_feishu_channel.send(msg)
|
2026-03-13 14:41:54 +08:00
|
|
|
|
|
|
|
|
|
|
assert mock_send.call_count == 1
|
2026-04-14 14:14:14 +08:00
|
|
|
|
card = _get_tool_hint_card(mock_send)
|
2026-03-13 14:43:47 +08:00
|
|
|
|
assert card["config"]["wide_screen_mode"] is True
|
2026-04-14 14:14:14 +08:00
|
|
|
|
md = card["elements"][0]["content"]
|
|
|
|
|
|
assert "\U0001f527" in md
|
|
|
|
|
|
assert "web_search" in md
|
2026-03-13 14:41:54 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 14:52:15 +08:00
|
|
|
|
@mark.asyncio
|
|
|
|
|
|
async def test_tool_hint_empty_content_does_not_send(mock_feishu_channel):
|
2026-03-13 14:41:54 +08:00
|
|
|
|
"""Empty tool hint messages should not be sent."""
|
|
|
|
|
|
msg = OutboundMessage(
|
|
|
|
|
|
channel="feishu",
|
|
|
|
|
|
chat_id="oc_123456",
|
|
|
|
|
|
content=" ", # whitespace only
|
|
|
|
|
|
metadata={"_tool_hint": True}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
2026-03-13 14:52:15 +08:00
|
|
|
|
await mock_feishu_channel.send(msg)
|
2026-03-13 14:41:54 +08:00
|
|
|
|
mock_send.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 14:52:15 +08:00
|
|
|
|
@mark.asyncio
|
|
|
|
|
|
async def test_tool_hint_without_metadata_sends_as_normal(mock_feishu_channel):
|
2026-03-13 14:41:54 +08:00
|
|
|
|
"""Regular messages without _tool_hint should use normal formatting."""
|
|
|
|
|
|
msg = OutboundMessage(
|
|
|
|
|
|
channel="feishu",
|
|
|
|
|
|
chat_id="oc_123456",
|
|
|
|
|
|
content="Hello, world!",
|
|
|
|
|
|
metadata={}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
2026-03-13 14:52:15 +08:00
|
|
|
|
await mock_feishu_channel.send(msg)
|
2026-03-13 14:41:54 +08:00
|
|
|
|
|
|
|
|
|
|
assert mock_send.call_count == 1
|
|
|
|
|
|
call_args = mock_send.call_args[0]
|
|
|
|
|
|
_, _, msg_type, content = call_args
|
|
|
|
|
|
assert msg_type == "text"
|
|
|
|
|
|
assert json.loads(content) == {"text": "Hello, world!"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 14:52:15 +08:00
|
|
|
|
@mark.asyncio
|
|
|
|
|
|
async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel):
|
2026-04-14 14:14:14 +08:00
|
|
|
|
"""Multiple tool calls should each get the 🔧 prefix."""
|
2026-03-13 14:41:54 +08:00
|
|
|
|
msg = OutboundMessage(
|
|
|
|
|
|
channel="feishu",
|
|
|
|
|
|
chat_id="oc_123456",
|
|
|
|
|
|
content='web_search("query"), read_file("/path/to/file")',
|
|
|
|
|
|
metadata={"_tool_hint": True}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
2026-03-13 14:52:15 +08:00
|
|
|
|
await mock_feishu_channel.send(msg)
|
2026-03-13 14:41:54 +08:00
|
|
|
|
|
2026-04-14 14:14:14 +08:00
|
|
|
|
card = _get_tool_hint_card(mock_send)
|
|
|
|
|
|
md = card["elements"][0]["content"]
|
|
|
|
|
|
assert "web_search" in md
|
|
|
|
|
|
assert "read_file" in md
|
|
|
|
|
|
assert "\U0001f527" in md
|
2026-03-14 15:40:53 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@mark.asyncio
|
2026-04-07 11:34:54 +08:00
|
|
|
|
async def test_tool_hint_new_format_basic(mock_feishu_channel):
|
|
|
|
|
|
"""New format hints (read path, grep "pattern") should parse correctly."""
|
|
|
|
|
|
msg = OutboundMessage(
|
|
|
|
|
|
channel="feishu",
|
|
|
|
|
|
chat_id="oc_123456",
|
|
|
|
|
|
content='read src/main.py, grep "TODO"',
|
|
|
|
|
|
metadata={"_tool_hint": True}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
|
|
|
|
|
await mock_feishu_channel.send(msg)
|
|
|
|
|
|
|
2026-04-14 14:14:14 +08:00
|
|
|
|
card = _get_tool_hint_card(mock_send)
|
|
|
|
|
|
md = card["elements"][0]["content"]
|
2026-04-07 11:34:54 +08:00
|
|
|
|
assert "read src/main.py" in md
|
|
|
|
|
|
assert 'grep "TODO"' in md
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@mark.asyncio
|
|
|
|
|
|
async def test_tool_hint_new_format_with_comma_in_quotes(mock_feishu_channel):
|
|
|
|
|
|
"""Commas inside quoted arguments must not cause incorrect line splits."""
|
|
|
|
|
|
msg = OutboundMessage(
|
|
|
|
|
|
channel="feishu",
|
|
|
|
|
|
chat_id="oc_123456",
|
|
|
|
|
|
content='grep "hello, world", $ echo test',
|
|
|
|
|
|
metadata={"_tool_hint": True}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
|
|
|
|
|
await mock_feishu_channel.send(msg)
|
|
|
|
|
|
|
2026-04-14 14:14:14 +08:00
|
|
|
|
card = _get_tool_hint_card(mock_send)
|
|
|
|
|
|
md = card["elements"][0]["content"]
|
2026-04-07 11:34:54 +08:00
|
|
|
|
assert 'grep "hello, world"' in md
|
|
|
|
|
|
assert "$ echo test" in md
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@mark.asyncio
|
|
|
|
|
|
async def test_tool_hint_new_format_with_folding(mock_feishu_channel):
|
2026-04-14 14:14:14 +08:00
|
|
|
|
"""Folded calls (× N) should display correctly."""
|
2026-04-07 11:34:54 +08:00
|
|
|
|
msg = OutboundMessage(
|
|
|
|
|
|
channel="feishu",
|
|
|
|
|
|
chat_id="oc_123456",
|
|
|
|
|
|
content='read path × 3, grep "pattern"',
|
|
|
|
|
|
metadata={"_tool_hint": True}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
|
|
|
|
|
await mock_feishu_channel.send(msg)
|
|
|
|
|
|
|
2026-04-14 14:14:14 +08:00
|
|
|
|
card = _get_tool_hint_card(mock_send)
|
|
|
|
|
|
md = card["elements"][0]["content"]
|
2026-04-07 11:34:54 +08:00
|
|
|
|
assert "\u00d7 3" in md
|
|
|
|
|
|
assert 'grep "pattern"' in md
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@mark.asyncio
|
|
|
|
|
|
async def test_tool_hint_new_format_mcp(mock_feishu_channel):
|
|
|
|
|
|
"""MCP tool format (server::tool) should parse correctly."""
|
|
|
|
|
|
msg = OutboundMessage(
|
|
|
|
|
|
channel="feishu",
|
|
|
|
|
|
chat_id="oc_123456",
|
|
|
|
|
|
content='4_5v::analyze_image("photo.jpg")',
|
|
|
|
|
|
metadata={"_tool_hint": True}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
|
|
|
|
|
await mock_feishu_channel.send(msg)
|
|
|
|
|
|
|
2026-04-14 14:14:14 +08:00
|
|
|
|
card = _get_tool_hint_card(mock_send)
|
|
|
|
|
|
md = card["elements"][0]["content"]
|
2026-04-07 11:34:54 +08:00
|
|
|
|
assert "4_5v::analyze_image" in md
|
2026-04-14 14:14:14 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@mark.asyncio
|
2026-03-14 15:40:53 +00:00
|
|
|
|
async def test_tool_hint_keeps_commas_inside_arguments(mock_feishu_channel):
|
|
|
|
|
|
"""Commas inside a single tool argument must not be split onto a new line."""
|
|
|
|
|
|
msg = OutboundMessage(
|
|
|
|
|
|
channel="feishu",
|
|
|
|
|
|
chat_id="oc_123456",
|
|
|
|
|
|
content='web_search("foo, bar"), read_file("/path/to/file")',
|
|
|
|
|
|
metadata={"_tool_hint": True}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
|
|
|
|
|
await mock_feishu_channel.send(msg)
|
|
|
|
|
|
|
2026-04-14 14:14:14 +08:00
|
|
|
|
card = _get_tool_hint_card(mock_send)
|
|
|
|
|
|
md = card["elements"][0]["content"]
|
|
|
|
|
|
assert 'web_search("foo, bar")' in md
|
|
|
|
|
|
assert 'read_file("/path/to/file")' in md
|