Merge PR #1966: feat(feishu): display tool calls in code block messages + fix empty
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -20,5 +20,4 @@ __pycache__/
|
|||||||
poetry.lock
|
poetry.lock
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
botpy.log
|
botpy.log
|
||||||
nano.*.save
|
nano.*.save
|
||||||
|
|
||||||
@@ -203,7 +203,9 @@ class AgentLoop:
|
|||||||
thought = self._strip_think(response.content)
|
thought = self._strip_think(response.content)
|
||||||
if thought:
|
if thought:
|
||||||
await on_progress(thought)
|
await on_progress(thought)
|
||||||
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
|
tool_hint = self._tool_hint(response.tool_calls)
|
||||||
|
tool_hint = self._strip_think(tool_hint)
|
||||||
|
await on_progress(tool_hint, tool_hint=True)
|
||||||
|
|
||||||
tool_call_dicts = [
|
tool_call_dicts = [
|
||||||
tc.to_openai_tool_call()
|
tc.to_openai_tool_call()
|
||||||
|
|||||||
@@ -914,6 +914,15 @@ class FeishuChannel(BaseChannel):
|
|||||||
receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
|
receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
# Handle tool hint messages as code blocks in interactive cards.
|
||||||
|
# These are progress-only messages and should bypass normal reply routing.
|
||||||
|
if msg.metadata.get("_tool_hint"):
|
||||||
|
if msg.content and msg.content.strip():
|
||||||
|
await self._send_tool_hint_card(
|
||||||
|
receive_id_type, msg.chat_id, msg.content.strip()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Determine whether the first message should quote the user's message.
|
# Determine whether the first message should quote the user's message.
|
||||||
# Only the very first send (media or text) in this call uses reply; subsequent
|
# Only the very first send (media or text) in this call uses reply; subsequent
|
||||||
# chunks/media fall back to plain create to avoid redundant quote bubbles.
|
# chunks/media fall back to plain create to avoid redundant quote bubbles.
|
||||||
@@ -1127,3 +1136,78 @@ 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
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_tool_hint_lines(tool_hint: str) -> str:
|
||||||
|
"""Split tool hints across lines on top-level call separators only."""
|
||||||
|
parts: list[str] = []
|
||||||
|
buf: list[str] = []
|
||||||
|
depth = 0
|
||||||
|
in_string = False
|
||||||
|
quote_char = ""
|
||||||
|
escaped = False
|
||||||
|
|
||||||
|
for i, ch in enumerate(tool_hint):
|
||||||
|
buf.append(ch)
|
||||||
|
|
||||||
|
if in_string:
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif ch == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif ch == quote_char:
|
||||||
|
in_string = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch in {'"', "'"}:
|
||||||
|
in_string = True
|
||||||
|
quote_char = ch
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == "(":
|
||||||
|
depth += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == ")" and depth > 0:
|
||||||
|
depth -= 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch == "," and depth == 0:
|
||||||
|
next_char = tool_hint[i + 1] if i + 1 < len(tool_hint) else ""
|
||||||
|
if next_char == " ":
|
||||||
|
parts.append("".join(buf).rstrip())
|
||||||
|
buf = []
|
||||||
|
|
||||||
|
if buf:
|
||||||
|
parts.append("".join(buf).strip())
|
||||||
|
|
||||||
|
return "\n".join(part for part in parts if part)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# Put each top-level tool call on its own line without altering commas inside arguments.
|
||||||
|
formatted_code = self._format_tool_hint_lines(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),
|
||||||
|
)
|
||||||
|
|||||||
138
tests/test_feishu_tool_hint_code_block.py
Normal file
138
tests/test_feishu_tool_hint_code_block.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""Tests for FeishuChannel tool hint code block formatting."""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = FeishuChannel(config, bus)
|
||||||
|
channel._client = MagicMock() # Simulate initialized client
|
||||||
|
return 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",
|
||||||
|
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:
|
||||||
|
await mock_feishu_channel.send(msg)
|
||||||
|
|
||||||
|
# Verify interactive message with card was sent
|
||||||
|
assert mock_send.call_count == 1
|
||||||
|
call_args = mock_send.call_args[0]
|
||||||
|
receive_id_type, receive_id, msg_type, content = call_args
|
||||||
|
|
||||||
|
assert receive_id_type == "chat_id"
|
||||||
|
assert receive_id == "oc_123456"
|
||||||
|
assert msg_type == "interactive"
|
||||||
|
|
||||||
|
# Parse content to verify card structure
|
||||||
|
card = json.loads(content)
|
||||||
|
assert card["config"]["wide_screen_mode"] is True
|
||||||
|
assert len(card["elements"]) == 1
|
||||||
|
assert card["elements"][0]["tag"] == "markdown"
|
||||||
|
# Check that code block is properly formatted with language hint
|
||||||
|
expected_md = "**Tool Calls**\n\n```text\nweb_search(\"test query\")\n```"
|
||||||
|
assert card["elements"][0]["content"] == expected_md
|
||||||
|
|
||||||
|
|
||||||
|
@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",
|
||||||
|
chat_id="oc_123456",
|
||||||
|
content=" ", # whitespace only
|
||||||
|
metadata={"_tool_hint": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
||||||
|
await mock_feishu_channel.send(msg)
|
||||||
|
|
||||||
|
# Should not send any message
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@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",
|
||||||
|
chat_id="oc_123456",
|
||||||
|
content="Hello, world!",
|
||||||
|
metadata={}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(mock_feishu_channel, '_send_message_sync') as mock_send:
|
||||||
|
await mock_feishu_channel.send(msg)
|
||||||
|
|
||||||
|
# Should send as text message (detected format)
|
||||||
|
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!"}
|
||||||
|
|
||||||
|
|
||||||
|
@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",
|
||||||
|
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:
|
||||||
|
await mock_feishu_channel.send(msg)
|
||||||
|
|
||||||
|
call_args = mock_send.call_args[0]
|
||||||
|
msg_type = call_args[2]
|
||||||
|
content = json.loads(call_args[3])
|
||||||
|
assert msg_type == "interactive"
|
||||||
|
# Each tool call should be on its own line
|
||||||
|
expected_md = "**Tool Calls**\n\n```text\nweb_search(\"query\"),\nread_file(\"/path/to/file\")\n```"
|
||||||
|
assert content["elements"][0]["content"] == expected_md
|
||||||
|
|
||||||
|
|
||||||
|
@mark.asyncio
|
||||||
|
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)
|
||||||
|
|
||||||
|
content = json.loads(mock_send.call_args[0][3])
|
||||||
|
expected_md = (
|
||||||
|
"**Tool Calls**\n\n```text\n"
|
||||||
|
"web_search(\"foo, bar\"),\n"
|
||||||
|
"read_file(\"/path/to/file\")\n```"
|
||||||
|
)
|
||||||
|
assert content["elements"][0]["content"] == expected_md
|
||||||
Reference in New Issue
Block a user