platform-codebase/tools/platform-knowledge/tests/test_tool_parser.py
Lilith cd244cb788 deps-upgrade(knowledge): ⬆️ Bump core and optional dependency versions in pyproject.toml files
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-02-27 15:18:50 -08:00

185 lines
6.6 KiB
Python

"""Tests for XML tool call parser."""
from __future__ import annotations
from crystal_cli.chat.tool_parser import ParsedResponse, ToolCall, parse_tool_calls
class TestParseToolCalls:
"""Tests for parse_tool_calls()."""
def test_no_tool_calls(self) -> None:
result = parse_tool_calls("Just a normal text response.")
assert not result.has_tool_calls
assert result.tool_count == 0
assert result.text == "Just a normal text response."
assert result.parse_errors == []
def test_empty_response(self) -> None:
result = parse_tool_calls("")
assert not result.has_tool_calls
assert result.text == ""
def test_single_tool_call(self) -> None:
response = (
'<tool_use>\n'
'<tool_name>read</tool_name>\n'
'<parameters>{"file_path": "/tmp/test.txt"}</parameters>\n'
'</tool_use>'
)
result = parse_tool_calls(response)
assert result.has_tool_calls
assert result.tool_count == 1
assert result.tool_calls[0].name == "read"
assert result.tool_calls[0].parameters == {"file_path": "/tmp/test.txt"}
def test_tool_call_with_surrounding_text(self) -> None:
response = (
'Let me read that file for you.\n\n'
'<tool_use>\n'
'<tool_name>read</tool_name>\n'
'<parameters>{"file_path": "/tmp/test.txt"}</parameters>\n'
'</tool_use>\n\n'
'I will analyze the contents.'
)
result = parse_tool_calls(response)
assert result.has_tool_calls
assert result.tool_count == 1
assert len(result.text_segments) == 2
assert "read that file" in result.text_segments[0]
assert "analyze the contents" in result.text_segments[1]
def test_multiple_tool_calls(self) -> None:
response = (
'<tool_use>\n'
'<tool_name>read</tool_name>\n'
'<parameters>{"file_path": "/tmp/a.txt"}</parameters>\n'
'</tool_use>\n'
'<tool_use>\n'
'<tool_name>read</tool_name>\n'
'<parameters>{"file_path": "/tmp/b.txt"}</parameters>\n'
'</tool_use>'
)
result = parse_tool_calls(response)
assert result.tool_count == 2
assert result.tool_calls[0].parameters["file_path"] == "/tmp/a.txt"
assert result.tool_calls[1].parameters["file_path"] == "/tmp/b.txt"
def test_tool_call_with_text_between(self) -> None:
response = (
'First, read file A.\n'
'<tool_use>\n'
'<tool_name>read</tool_name>\n'
'<parameters>{"file_path": "/tmp/a.txt"}</parameters>\n'
'</tool_use>\n'
'Now read file B.\n'
'<tool_use>\n'
'<tool_name>read</tool_name>\n'
'<parameters>{"file_path": "/tmp/b.txt"}</parameters>\n'
'</tool_use>\n'
'Done.'
)
result = parse_tool_calls(response)
assert result.tool_count == 2
assert len(result.text_segments) == 3
def test_invalid_json_parameters(self) -> None:
response = (
'<tool_use>\n'
'<tool_name>read</tool_name>\n'
'<parameters>not valid json</parameters>\n'
'</tool_use>'
)
result = parse_tool_calls(response)
assert not result.has_tool_calls
assert len(result.parse_errors) == 1
assert "Invalid JSON" in result.parse_errors[0].error
def test_non_dict_parameters(self) -> None:
response = (
'<tool_use>\n'
'<tool_name>read</tool_name>\n'
'<parameters>["a", "b"]</parameters>\n'
'</tool_use>'
)
result = parse_tool_calls(response)
assert not result.has_tool_calls
assert len(result.parse_errors) == 1
assert "JSON object" in result.parse_errors[0].error
def test_whitespace_tolerance(self) -> None:
response = (
' <tool_use> \n'
' <tool_name> bash </tool_name> \n'
' <parameters> {"command": "ls"} </parameters> \n'
' </tool_use> '
)
result = parse_tool_calls(response)
assert result.has_tool_calls
assert result.tool_calls[0].name == "bash"
assert result.tool_calls[0].parameters == {"command": "ls"}
def test_compact_format(self) -> None:
response = '<tool_use><tool_name>bash</tool_name><parameters>{"command": "pwd"}</parameters></tool_use>'
result = parse_tool_calls(response)
assert result.has_tool_calls
assert result.tool_calls[0].name == "bash"
def test_complex_parameters(self) -> None:
response = (
'<tool_use>\n'
'<tool_name>glob</tool_name>\n'
'<parameters>{"pattern": "**/*.py", "root": "/home/user", "exclude": ["node_modules"]}</parameters>\n'
'</tool_use>'
)
result = parse_tool_calls(response)
assert result.has_tool_calls
params = result.tool_calls[0].parameters
assert params["pattern"] == "**/*.py"
assert params["exclude"] == ["node_modules"]
def test_mixed_valid_and_invalid(self) -> None:
response = (
'<tool_use>\n'
'<tool_name>read</tool_name>\n'
'<parameters>{"file_path": "/tmp/a.txt"}</parameters>\n'
'</tool_use>\n'
'<tool_use>\n'
'<tool_name>broken</tool_name>\n'
'<parameters>{invalid}</parameters>\n'
'</tool_use>\n'
'<tool_use>\n'
'<tool_name>bash</tool_name>\n'
'<parameters>{"command": "ls"}</parameters>\n'
'</tool_use>'
)
result = parse_tool_calls(response)
assert result.tool_count == 2
assert len(result.parse_errors) == 1
assert result.tool_calls[0].name == "read"
assert result.tool_calls[1].name == "bash"
class TestToolCall:
def test_repr_short(self) -> None:
tc = ToolCall(name="read", parameters={"file_path": "/tmp/test.txt"})
r = repr(tc)
assert "read" in r
assert "file_path" in r
def test_repr_long_params(self) -> None:
tc = ToolCall(name="write", parameters={"content": "a" * 200})
r = repr(tc)
assert len(r) < 200
assert "..." in r
class TestParsedResponse:
def test_text_property_filters_empty(self) -> None:
result = ParsedResponse(text_segments=["hello", "", " ", "world"])
assert result.text == "hello\nworld"
def test_has_tool_calls_false_when_empty(self) -> None:
result = ParsedResponse()
assert not result.has_tool_calls
assert result.tool_count == 0