From df89bd2dfa25898d08777b8f5bfd3f39793cb434 Mon Sep 17 00:00:00 2001 From: Tony Date: Fri, 13 Mar 2026 14:41:54 +0800 Subject: [PATCH 1/7] feat(feishu): display tool calls in code block messages - Add special handling for tool hint messages (_tool_hint metadata) - Send tool calls using Feishu's "code" message type with formatting - Tool calls now appear as formatted code snippets in Feishu chat - Add unit tests for the new functionality Co-Authored-By: Claude Opus 4.6 --- nanobot/channels/feishu.py | 15 +++ tests/test_feishu_tool_hint_code_block.py | 110 ++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 tests/test_feishu_tool_hint_code_block.py diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 2eb6a6a..2122d97 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -822,6 +822,21 @@ class FeishuChannel(BaseChannel): receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id" loop = asyncio.get_running_loop() + # Handle tool hint messages as code blocks + if msg.metadata.get("_tool_hint"): + if msg.content and msg.content.strip(): + code_content = { + "title": "Tool Call", + "code": msg.content.strip(), + "language": "text" + } + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "code", + json.dumps(code_content, ensure_ascii=False), + ) + return + for file_path in msg.media: if not os.path.isfile(file_path): logger.warning("Media file not found: {}", file_path) diff --git a/tests/test_feishu_tool_hint_code_block.py b/tests/test_feishu_tool_hint_code_block.py new file mode 100644 index 0000000..c10c322 --- /dev/null +++ b/tests/test_feishu_tool_hint_code_block.py @@ -0,0 +1,110 @@ +"""Tests for FeishuChannel tool hint code block formatting.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +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 + + +def test_tool_hint_sends_code_message(mock_feishu_channel): + """Tool hint messages should be sent as 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: + # Run send in async context + import asyncio + asyncio.run(mock_feishu_channel.send(msg)) + + # Verify code message 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 == "code" + + # Parse content to verify structure + content_dict = json.loads(content) + assert content_dict["title"] == "Tool Call" + assert content_dict["code"] == 'web_search("test query")' + assert content_dict["language"] == "text" + + +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: + import asyncio + asyncio.run(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): + """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: + import asyncio + asyncio.run(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!"} + + +def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel): + """Multiple tool calls should be in a single 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: + import asyncio + asyncio.run(mock_feishu_channel.send(msg)) + + call_args = mock_send.call_args[0] + content = json.loads(call_args[3]) + assert content["code"] == 'web_search("query"), read_file("/path/to/file")' + assert "\n" not in content["code"] # Single line as intended From 7261bd8c3fab95e6ea628803ad20bcf5e97238a1 Mon Sep 17 00:00:00 2001 From: Tony Date: Fri, 13 Mar 2026 14:43:47 +0800 Subject: [PATCH 2/7] feat(feishu): display tool calls in code block messages - Tool hint messages with _tool_hint metadata now render as formatted code blocks - Uses Feishu interactive card message type with markdown code fences - Shows "Tool Call" header followed by code in a monospace block - Adds comprehensive unit tests for the new functionality Co-Authorship-Bot: Claude Opus 4.6 --- nanobot/channels/feishu.py | 20 ++++++++++++------- tests/test_feishu_tool_hint_code_block.py | 24 +++++++++++++---------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 2122d97..cfc3de0 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -822,18 +822,24 @@ class FeishuChannel(BaseChannel): receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id" loop = asyncio.get_running_loop() - # Handle tool hint messages as code blocks + # Handle tool hint messages as code blocks in interactive cards if msg.metadata.get("_tool_hint"): if msg.content and msg.content.strip(): - code_content = { - "title": "Tool Call", - "code": msg.content.strip(), - "language": "text" + # Create a simple card with a code block + code_text = msg.content.strip() + card = { + "config": {"wide_screen_mode": True}, + "elements": [ + { + "tag": "markdown", + "content": f"**Tool Call**\n\n```\n{code_text}\n```" + } + ] } await loop.run_in_executor( None, self._send_message_sync, - receive_id_type, msg.chat_id, "code", - json.dumps(code_content, ensure_ascii=False), + receive_id_type, msg.chat_id, "interactive", + json.dumps(card, ensure_ascii=False), ) return diff --git a/tests/test_feishu_tool_hint_code_block.py b/tests/test_feishu_tool_hint_code_block.py index c10c322..2c84060 100644 --- a/tests/test_feishu_tool_hint_code_block.py +++ b/tests/test_feishu_tool_hint_code_block.py @@ -24,7 +24,7 @@ def mock_feishu_channel(): def test_tool_hint_sends_code_message(mock_feishu_channel): - """Tool hint messages should be sent as code blocks.""" + """Tool hint messages should be sent as interactive cards with code blocks.""" msg = OutboundMessage( channel="feishu", chat_id="oc_123456", @@ -37,20 +37,23 @@ def test_tool_hint_sends_code_message(mock_feishu_channel): import asyncio asyncio.run(mock_feishu_channel.send(msg)) - # Verify code message was sent + # 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 == "code" + assert msg_type == "interactive" - # Parse content to verify structure - content_dict = json.loads(content) - assert content_dict["title"] == "Tool Call" - assert content_dict["code"] == 'web_search("test query")' - assert content_dict["language"] == "text" + # 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 + expected_md = "**Tool Call**\n\n```\nweb_search(\"test query\")\n```" + assert card["elements"][0]["content"] == expected_md def test_tool_hint_empty_content_does_not_send(mock_feishu_channel): @@ -105,6 +108,7 @@ def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel): asyncio.run(mock_feishu_channel.send(msg)) call_args = mock_send.call_args[0] + msg_type = call_args[2] content = json.loads(call_args[3]) - assert content["code"] == 'web_search("query"), read_file("/path/to/file")' - assert "\n" not in content["code"] # Single line as intended + assert msg_type == "interactive" + assert "web_search(\"query\"), read_file(\"/path/to/file\")" in content["elements"][0]["content"] From 82064efe510231c008609447ce1f0587abccfbea Mon Sep 17 00:00:00 2001 From: Tony Date: Fri, 13 Mar 2026 14:48:36 +0800 Subject: [PATCH 3/7] feat(feishu): improve tool call card formatting for multiple tools - Format multiple tool calls each on their own line - Change title from 'Tool Call' to 'Tool Calls' (plural) - Add explicit 'text' language for code block - Improves readability and supports displaying longer content - Update tests to match new formatting --- nanobot/channels/feishu.py | 8 +++++++- tests/test_feishu_tool_hint_code_block.py | 10 ++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index cfc3de0..e3eeb19 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -827,12 +827,18 @@ class FeishuChannel(BaseChannel): 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 Call**\n\n```\n{code_text}\n```" + "content": f"**Tool Calls**\n\n```text\n{formatted_code}\n```" } ] } diff --git a/tests/test_feishu_tool_hint_code_block.py b/tests/test_feishu_tool_hint_code_block.py index 2c84060..7356122 100644 --- a/tests/test_feishu_tool_hint_code_block.py +++ b/tests/test_feishu_tool_hint_code_block.py @@ -51,8 +51,8 @@ def test_tool_hint_sends_code_message(mock_feishu_channel): 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 - expected_md = "**Tool Call**\n\n```\nweb_search(\"test query\")\n```" + # 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 @@ -95,7 +95,7 @@ def test_tool_hint_without_metadata_sends_as_normal(mock_feishu_channel): def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel): - """Multiple tool calls should be in a single code block.""" + """Multiple tool calls should be displayed each on its own line in a code block.""" msg = OutboundMessage( channel="feishu", chat_id="oc_123456", @@ -111,4 +111,6 @@ def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel): msg_type = call_args[2] content = json.loads(call_args[3]) assert msg_type == "interactive" - assert "web_search(\"query\"), read_file(\"/path/to/file\")" in content["elements"][0]["content"] + # 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 From 87ab980bd1b5e1e2398966e0b5ce85731eff750b Mon Sep 17 00:00:00 2001 From: Tony Date: Fri, 13 Mar 2026 14:52:15 +0800 Subject: [PATCH 4/7] 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 --- nanobot/channels/feishu.py | 53 +++++++++++++---------- tests/test_feishu_tool_hint_code_block.py | 26 +++++------ 2 files changed, 44 insertions(+), 35 deletions(-) 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] From 2787523f49bd98e67aaf9af2643dad06f35003b7 Mon Sep 17 00:00:00 2001 From: Tony Date: Fri, 13 Mar 2026 14:55:34 +0800 Subject: [PATCH 5/7] fix: prevent empty tags from appearing in messages - Enhance _strip_think to handle stray tags: * Remove unmatched closing tags () * Remove incomplete blocks ( ... to end of string) - Apply _strip_think to tool hint messages as well - Prevents blank/parse errors from showing in chat outputs Fixes issue with empty appearing in Feishu tool call cards and other messages. --- nanobot/agent/loop.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e05a73e..94b6548 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -163,7 +163,13 @@ class AgentLoop: """Remove blocks that some models embed in content.""" if not text: return None - return re.sub(r"[\s\S]*?", "", text).strip() or None + # Remove complete think blocks (non-greedy) + cleaned = re.sub(r"[\s\S]*?", "", text) + # Remove any stray closing tags left without opening + cleaned = re.sub(r"", "", cleaned) + # Remove any stray opening tag and everything after it (incomplete block) + cleaned = re.sub(r"[\s\S]*$", "", cleaned) + return cleaned.strip() or None @staticmethod def _tool_hint(tool_calls: list) -> str: @@ -203,7 +209,9 @@ class AgentLoop: thought = self._strip_think(response.content) if 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 = [ tc.to_openai_tool_call() From a8fbea6a95950bc984ca224edfd9454d992ce104 Mon Sep 17 00:00:00 2001 From: nne998 Date: Fri, 13 Mar 2026 16:53:57 +0800 Subject: [PATCH 6/7] cleanup --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 0d392d3..6556ecb 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,4 @@ __pycache__/ poetry.lock .pytest_cache/ botpy.log -nano.*.save - +nano.*.save \ No newline at end of file From 19ae7a167e6818eb7e661e1e979d35d4eddddac0 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sat, 14 Mar 2026 15:40:53 +0000 Subject: [PATCH 7/7] fix(feishu): avoid breaking tool hint formatting and think stripping --- nanobot/agent/loop.py | 8 +--- nanobot/channels/feishu.py | 51 +++++++++++++++++++++-- tests/test_feishu_tool_hint_code_block.py | 22 ++++++++++ 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6ebebcd..d644845 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -163,13 +163,7 @@ class AgentLoop: """Remove blocks that some models embed in content.""" if not text: return None - # Remove complete think blocks (non-greedy) - cleaned = re.sub(r"[\s\S]*?", "", text) - # Remove any stray closing tags left without opening - cleaned = re.sub(r"", "", cleaned) - # Remove any stray opening tag and everything after it (incomplete block) - cleaned = re.sub(r"[\s\S]*$", "", cleaned) - return cleaned.strip() or None + return re.sub(r"[\s\S]*?", "", text).strip() or None @staticmethod def _tool_hint(tool_calls: list) -> str: diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index e3ab8a0..f657359 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -1137,6 +1137,52 @@ class FeishuChannel(BaseChannel): logger.debug("Bot entered p2p chat (user opened chat window)") 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. @@ -1147,9 +1193,8 @@ class FeishuChannel(BaseChannel): """ 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 + # 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}, diff --git a/tests/test_feishu_tool_hint_code_block.py b/tests/test_feishu_tool_hint_code_block.py index a3fc024..2a1b812 100644 --- a/tests/test_feishu_tool_hint_code_block.py +++ b/tests/test_feishu_tool_hint_code_block.py @@ -114,3 +114,25 @@ async def test_tool_hint_multiple_tools_in_one_message(mock_feishu_channel): # 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