mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
GlobTool is redundant — GrepTool already supports glob-based file filtering via its `glob` parameter, making a standalone glob-only tool unnecessary. Removing it simplifies the tool surface and reduces LLM confusion between glob and grep.
309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""Tests for tool hint formatting (nanobot.utils.tool_hints)."""
|
|
|
|
from nanobot.utils.tool_hints import format_tool_hints
|
|
from nanobot.providers.base import ToolCallRequest
|
|
|
|
|
|
def _tc(name: str, args) -> ToolCallRequest:
|
|
return ToolCallRequest(id="c1", name=name, arguments=args)
|
|
|
|
|
|
def _hint(calls, max_length=40):
|
|
"""Shortcut for format_tool_hints."""
|
|
return format_tool_hints(calls, max_length=max_length)
|
|
|
|
|
|
class TestToolHintKnownTools:
|
|
"""Test registered tool types produce correct formatted output."""
|
|
|
|
def test_read_file_short_path(self):
|
|
result = _hint([_tc("read_file", {"path": "foo.txt"})])
|
|
assert result == 'read foo.txt'
|
|
|
|
def test_read_file_long_path(self):
|
|
result = _hint([_tc("read_file", {"path": "/home/user/.local/share/uv/tools/nanobot/agent/loop.py"})])
|
|
assert "loop.py" in result
|
|
assert "read " in result
|
|
|
|
def test_write_file_shows_path_not_content(self):
|
|
result = _hint([_tc("write_file", {"path": "docs/api.md", "content": "# API Reference\n\nLong content..."})])
|
|
assert result == "write docs/api.md"
|
|
|
|
def test_edit_shows_path(self):
|
|
result = _hint([_tc("edit", {"file_path": "src/main.py", "old_string": "x", "new_string": "y"})])
|
|
assert "main.py" in result
|
|
assert "edit " in result
|
|
|
|
def test_grep_shows_pattern(self):
|
|
result = _hint([_tc("grep", {"pattern": "TODO|FIXME", "path": "src"})])
|
|
assert result == 'grep "TODO|FIXME"'
|
|
|
|
def test_exec_shows_command(self):
|
|
result = _hint([_tc("exec", {"command": "npm install typescript"})])
|
|
assert result == "$ npm install typescript"
|
|
|
|
def test_exec_truncates_long_command(self):
|
|
cmd = "cd /very/long/path && cat file && echo done && sleep 1 && ls -la"
|
|
result = _hint([_tc("exec", {"command": cmd})])
|
|
assert result.startswith("$ ")
|
|
assert len(result) <= 50 # reasonable limit
|
|
|
|
def test_exec_abbreviates_paths_in_command(self):
|
|
"""Windows paths in exec commands should be folded, not blindly truncated."""
|
|
cmd = "cd D:\\Documents\\GitHub\\nanobot\\.worktree\\tomain\\nanobot && git diff origin/main...pr-2706 --name-only 2>&1"
|
|
result = _hint([_tc("exec", {"command": cmd})])
|
|
assert "\u2026/" in result # path should be folded with …/
|
|
assert "worktree" not in result # middle segments should be collapsed
|
|
|
|
def test_exec_abbreviates_linux_paths(self):
|
|
"""Unix absolute paths in exec commands should be folded."""
|
|
cmd = "cd /home/user/projects/nanobot/.worktree/tomain && make build"
|
|
result = _hint([_tc("exec", {"command": cmd})])
|
|
assert "\u2026/" in result
|
|
assert "projects" not in result
|
|
|
|
def test_exec_abbreviates_home_paths(self):
|
|
"""~/ paths in exec commands should be folded."""
|
|
cmd = "cd ~/projects/nanobot/workspace && pytest tests/"
|
|
result = _hint([_tc("exec", {"command": cmd})])
|
|
assert "\u2026/" in result
|
|
|
|
def test_exec_abbreviates_quoted_linux_paths_with_spaces(self):
|
|
"""Quoted Unix paths with spaces should still be folded."""
|
|
cmd = 'cd "/home/user/My Documents/project" && pytest tests/'
|
|
result = _hint([_tc("exec", {"command": cmd})])
|
|
assert "\u2026/" in result
|
|
assert '"/home/user/My Documents/project"' not in result
|
|
assert '"' in result
|
|
|
|
def test_exec_abbreviates_quoted_windows_paths_with_spaces(self):
|
|
"""Quoted Windows paths with spaces should still be folded."""
|
|
cmd = 'cd "C:/Program Files/Git/project" && git status'
|
|
result = _hint([_tc("exec", {"command": cmd})])
|
|
assert "\u2026/" in result
|
|
assert '"C:/Program Files/Git/project"' not in result
|
|
assert '"' in result
|
|
|
|
def test_exec_short_command_unchanged(self):
|
|
result = _hint([_tc("exec", {"command": "npm install typescript"})])
|
|
assert result == "$ npm install typescript"
|
|
|
|
def test_exec_chained_commands_truncated_not_mid_path(self):
|
|
"""Long chained commands should truncate preserving abbreviated paths."""
|
|
cmd = "cd D:\\Documents\\GitHub\\project && npm run build && npm test"
|
|
result = _hint([_tc("exec", {"command": cmd})])
|
|
assert "\u2026/" in result # path folded
|
|
assert "npm" in result # chained command still visible
|
|
|
|
def test_web_search(self):
|
|
result = _hint([_tc("web_search", {"query": "Claude 4 vs GPT-4"})])
|
|
assert result == 'search "Claude 4 vs GPT-4"'
|
|
|
|
def test_web_fetch(self):
|
|
result = _hint([_tc("web_fetch", {"url": "https://example.com/page"})])
|
|
assert result == "fetch https://example.com/page"
|
|
|
|
|
|
class TestToolHintMCP:
|
|
"""Test MCP tools are abbreviated to server::tool format."""
|
|
|
|
def test_mcp_standard_format(self):
|
|
result = _hint([_tc("mcp_4_5v_mcp__analyze_image", {"imageSource": "https://img.jpg", "prompt": "describe"})])
|
|
assert "4_5v" in result
|
|
assert "analyze_image" in result
|
|
|
|
def test_mcp_simple_name(self):
|
|
result = _hint([_tc("mcp_github__create_issue", {"title": "Bug fix"})])
|
|
assert "github" in result
|
|
assert "create_issue" in result
|
|
|
|
|
|
class TestToolHintFallback:
|
|
"""Test unknown tools fall back to original behavior."""
|
|
|
|
def test_unknown_tool_with_string_arg(self):
|
|
result = _hint([_tc("custom_tool", {"data": "hello world"})])
|
|
assert result == 'custom_tool("hello world")'
|
|
|
|
def test_unknown_tool_with_long_arg_truncates(self):
|
|
long_val = "a" * 60
|
|
result = _hint([_tc("custom_tool", {"data": long_val})])
|
|
assert len(result) < 80
|
|
assert "\u2026" in result
|
|
|
|
def test_unknown_tool_no_string_arg(self):
|
|
result = _hint([_tc("custom_tool", {"count": 42})])
|
|
assert result == "custom_tool"
|
|
|
|
def test_empty_tool_calls(self):
|
|
result = _hint([])
|
|
assert result == ""
|
|
|
|
|
|
class TestToolHintFolding:
|
|
"""Test consecutive same-tool calls are folded."""
|
|
|
|
def test_single_call_no_fold(self):
|
|
calls = [_tc("grep", {"pattern": "*.py"})]
|
|
result = _hint(calls)
|
|
assert "\u00d7" not in result
|
|
|
|
def test_two_consecutive_different_args_not_folded(self):
|
|
calls = [
|
|
_tc("grep", {"pattern": "*.py"}),
|
|
_tc("grep", {"pattern": "*.ts"}),
|
|
]
|
|
result = _hint(calls)
|
|
assert "\u00d7" not in result
|
|
|
|
def test_two_consecutive_same_args_folded(self):
|
|
calls = [
|
|
_tc("grep", {"pattern": "TODO"}),
|
|
_tc("grep", {"pattern": "TODO"}),
|
|
]
|
|
result = _hint(calls)
|
|
assert "\u00d7 2" in result
|
|
|
|
def test_three_consecutive_different_args_not_folded(self):
|
|
calls = [
|
|
_tc("read_file", {"path": "a.py"}),
|
|
_tc("read_file", {"path": "b.py"}),
|
|
_tc("read_file", {"path": "c.py"}),
|
|
]
|
|
result = _hint(calls)
|
|
assert "\u00d7" not in result
|
|
|
|
def test_different_tools_not_folded(self):
|
|
calls = [
|
|
_tc("grep", {"pattern": "TODO"}),
|
|
_tc("read_file", {"path": "a.py"}),
|
|
]
|
|
result = _hint(calls)
|
|
assert "\u00d7" not in result
|
|
|
|
def test_interleaved_same_tools_not_folded(self):
|
|
calls = [
|
|
_tc("grep", {"pattern": "a"}),
|
|
_tc("read_file", {"path": "f.py"}),
|
|
_tc("grep", {"pattern": "b"}),
|
|
]
|
|
result = _hint(calls)
|
|
assert "\u00d7" not in result
|
|
|
|
|
|
class TestToolHintMultipleCalls:
|
|
"""Test multiple different tool calls are comma-separated."""
|
|
|
|
def test_two_different_tools(self):
|
|
calls = [
|
|
_tc("grep", {"pattern": "TODO"}),
|
|
_tc("read_file", {"path": "main.py"}),
|
|
]
|
|
result = _hint(calls)
|
|
assert 'grep "TODO"' in result
|
|
assert "read main.py" in result
|
|
assert ", " in result
|
|
|
|
|
|
class TestToolHintEdgeCases:
|
|
"""Test edge cases and defensive handling (G1, G2)."""
|
|
|
|
def test_known_tool_empty_list_args(self):
|
|
"""C1/G1: Empty list arguments should not crash."""
|
|
result = _hint([_tc("read_file", [])])
|
|
assert result == "read_file"
|
|
|
|
def test_known_tool_none_args(self):
|
|
"""G2: None arguments should not crash."""
|
|
result = _hint([_tc("read_file", None)])
|
|
assert result == "read_file"
|
|
|
|
def test_fallback_empty_list_args(self):
|
|
"""C1: Empty list args in fallback should not crash."""
|
|
result = _hint([_tc("custom_tool", [])])
|
|
assert result == "custom_tool"
|
|
|
|
def test_fallback_none_args(self):
|
|
"""G2: None args in fallback should not crash."""
|
|
result = _hint([_tc("custom_tool", None)])
|
|
assert result == "custom_tool"
|
|
|
|
def test_list_dir_registered(self):
|
|
"""S2: list_dir should use 'ls' format."""
|
|
result = _hint([_tc("list_dir", {"path": "/tmp"})])
|
|
assert result == "ls /tmp"
|
|
|
|
|
|
class TestToolHintMixedFolding:
|
|
"""G4: Mixed folding groups with interleaved same-tool segments."""
|
|
|
|
def test_read_read_grep_grep_read(self):
|
|
"""All different args — each hint listed separately."""
|
|
calls = [
|
|
_tc("read_file", {"path": "a.py"}),
|
|
_tc("read_file", {"path": "b.py"}),
|
|
_tc("grep", {"pattern": "x"}),
|
|
_tc("grep", {"pattern": "y"}),
|
|
_tc("read_file", {"path": "c.py"}),
|
|
]
|
|
result = _hint(calls)
|
|
assert "\u00d7" not in result
|
|
parts = result.split(", ")
|
|
assert len(parts) == 5
|
|
|
|
|
|
class TestToolHintMaxLength:
|
|
"""Test max_length parameter controls truncation of tool hints."""
|
|
|
|
def test_exec_default_truncates_at_40(self):
|
|
cmd = "cd /very/long/path/to/some/project && npm run build && npm test"
|
|
result = _hint([_tc("exec", {"command": cmd})], max_length=40)
|
|
assert len(result) <= 50 # "$ " prefix + 40 + ellipsis
|
|
assert "\u2026" in result
|
|
|
|
def test_exec_larger_max_length_shows_more(self):
|
|
cmd = "cd /very/long/path/to/some/project && npm run build && npm test"
|
|
short = _hint([_tc("exec", {"command": cmd})], max_length=40)
|
|
long = _hint([_tc("exec", {"command": cmd})], max_length=120)
|
|
assert len(long) > len(short)
|
|
assert "npm test" in long
|
|
|
|
def test_exec_max_length_120_shows_full_command(self):
|
|
cmd = "cd /home/user/project && npm install && npm run build"
|
|
result = _hint([_tc("exec", {"command": cmd})], max_length=120)
|
|
assert "npm run build" in result
|
|
|
|
def test_fallback_respects_max_length(self):
|
|
long_val = "a" * 100
|
|
result = _hint([_tc("custom_tool", {"data": long_val})], max_length=60)
|
|
assert "\u2026" in result
|
|
result_40 = _hint([_tc("custom_tool", {"data": long_val})], max_length=40)
|
|
assert len(result) > len(result_40)
|
|
|
|
def test_mcp_respects_max_length(self):
|
|
long_url = "https://example.com/very/long/path/to/resource"
|
|
result = _hint([_tc("mcp_github__fetch", {"url": long_url})], max_length=80)
|
|
result_40 = _hint([_tc("mcp_github__fetch", {"url": long_url})], max_length=40)
|
|
assert len(result) >= len(result_40)
|
|
|
|
def test_path_type_respects_max_length(self):
|
|
"""Path-type tools (read_file, write_file, etc.) should honor max_length."""
|
|
long_path = "/home/user/.local/share/uv/tools/nanobot/agent/loop.py"
|
|
short = _hint([_tc("read_file", {"path": long_path})], max_length=40)
|
|
long = _hint([_tc("read_file", {"path": long_path})], max_length=120)
|
|
assert len(long) > len(short)
|
|
|
|
def test_edit_path_respects_max_length(self):
|
|
"""edit (is_path=True) should honor max_length, not stay hardcoded at 40."""
|
|
long_path = "/home/user/projects/nanobot/src/agent/loop.py"
|
|
short = _hint([_tc("edit", {"file_path": long_path})], max_length=40)
|
|
long = _hint([_tc("edit", {"file_path": long_path})], max_length=120)
|
|
assert len(long) > len(short)
|
|
|
|
def test_list_dir_path_respects_max_length(self):
|
|
"""list_dir (is_path=True) should honor max_length."""
|
|
long_path = "/home/user/.local/share/uv/tools/nanobot/"
|
|
short = _hint([_tc("list_dir", {"path": long_path})], max_length=40)
|
|
long = _hint([_tc("list_dir", {"path": long_path})], max_length=120)
|
|
assert len(long) > len(short)
|