185 lines
6.6 KiB
Python
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
|