diff --git a/tests/tools/test_clarify_tool.py b/tests/tools/test_clarify_tool.py new file mode 100644 index 00000000..bcdc4192 --- /dev/null +++ b/tests/tools/test_clarify_tool.py @@ -0,0 +1,195 @@ +"""Tests for tools/clarify_tool.py - Interactive clarifying questions.""" + +import json +from typing import List, Optional + +import pytest + +from tools.clarify_tool import ( + clarify_tool, + check_clarify_requirements, + MAX_CHOICES, + CLARIFY_SCHEMA, +) + + +class TestClarifyToolBasics: + """Basic functionality tests for clarify_tool.""" + + def test_simple_question_with_callback(self): + """Should return user response for simple question.""" + def mock_callback(question: str, choices: Optional[List[str]]) -> str: + assert question == "What color?" + assert choices is None + return "blue" + + result = json.loads(clarify_tool("What color?", callback=mock_callback)) + assert result["question"] == "What color?" + assert result["choices_offered"] is None + assert result["user_response"] == "blue" + + def test_question_with_choices(self): + """Should pass choices to callback and return response.""" + def mock_callback(question: str, choices: Optional[List[str]]) -> str: + assert question == "Pick a number" + assert choices == ["1", "2", "3"] + return "2" + + result = json.loads(clarify_tool( + "Pick a number", + choices=["1", "2", "3"], + callback=mock_callback + )) + assert result["question"] == "Pick a number" + assert result["choices_offered"] == ["1", "2", "3"] + assert result["user_response"] == "2" + + def test_empty_question_returns_error(self): + """Should return error for empty question.""" + result = json.loads(clarify_tool("", callback=lambda q, c: "ignored")) + assert "error" in result + assert "required" in result["error"].lower() + + def test_whitespace_only_question_returns_error(self): + """Should return error for whitespace-only question.""" + result = json.loads(clarify_tool(" \n\t ", callback=lambda q, c: "ignored")) + assert "error" in result + + def test_no_callback_returns_error(self): + """Should return error when no callback is provided.""" + result = json.loads(clarify_tool("What do you want?")) + assert "error" in result + assert "not available" in result["error"].lower() + + +class TestClarifyToolChoicesValidation: + """Tests for choices parameter validation.""" + + def test_choices_trimmed_to_max(self): + """Should trim choices to MAX_CHOICES.""" + choices_passed = [] + + def mock_callback(question: str, choices: Optional[List[str]]) -> str: + choices_passed.extend(choices or []) + return "picked" + + many_choices = ["a", "b", "c", "d", "e", "f", "g"] + clarify_tool("Pick one", choices=many_choices, callback=mock_callback) + + assert len(choices_passed) == MAX_CHOICES + + def test_empty_choices_become_none(self): + """Empty choices list should become None (open-ended).""" + choices_received = ["marker"] + + def mock_callback(question: str, choices: Optional[List[str]]) -> str: + choices_received.clear() + if choices is not None: + choices_received.extend(choices) + return "answer" + + clarify_tool("Open question?", choices=[], callback=mock_callback) + assert choices_received == [] # Was cleared, nothing added + + def test_choices_with_only_whitespace_stripped(self): + """Whitespace-only choices should be stripped out.""" + choices_received = [] + + def mock_callback(question: str, choices: Optional[List[str]]) -> str: + choices_received.extend(choices or []) + return "answer" + + clarify_tool("Pick", choices=["valid", " ", "", "also valid"], callback=mock_callback) + assert choices_received == ["valid", "also valid"] + + def test_invalid_choices_type_returns_error(self): + """Non-list choices should return error.""" + result = json.loads(clarify_tool( + "Question?", + choices="not a list", # type: ignore + callback=lambda q, c: "ignored" + )) + assert "error" in result + assert "list" in result["error"].lower() + + def test_choices_converted_to_strings(self): + """Non-string choices should be converted to strings.""" + choices_received = [] + + def mock_callback(question: str, choices: Optional[List[str]]) -> str: + choices_received.extend(choices or []) + return "answer" + + clarify_tool("Pick", choices=[1, 2, 3], callback=mock_callback) # type: ignore + assert choices_received == ["1", "2", "3"] + + +class TestClarifyToolCallbackHandling: + """Tests for callback error handling.""" + + def test_callback_exception_returns_error(self): + """Should return error if callback raises exception.""" + def failing_callback(question: str, choices: Optional[List[str]]) -> str: + raise RuntimeError("User cancelled") + + result = json.loads(clarify_tool("Question?", callback=failing_callback)) + assert "error" in result + assert "Failed to get user input" in result["error"] + assert "User cancelled" in result["error"] + + def test_callback_receives_stripped_question(self): + """Callback should receive trimmed question.""" + received_question = [] + + def mock_callback(question: str, choices: Optional[List[str]]) -> str: + received_question.append(question) + return "answer" + + clarify_tool(" Question with spaces \n", callback=mock_callback) + assert received_question[0] == "Question with spaces" + + def test_user_response_stripped(self): + """User response should be stripped of whitespace.""" + def mock_callback(question: str, choices: Optional[List[str]]) -> str: + return " response with spaces \n" + + result = json.loads(clarify_tool("Q?", callback=mock_callback)) + assert result["user_response"] == "response with spaces" + + +class TestCheckClarifyRequirements: + """Tests for the requirements check function.""" + + def test_always_returns_true(self): + """clarify tool has no external requirements.""" + assert check_clarify_requirements() is True + + +class TestClarifySchema: + """Tests for the OpenAI function-calling schema.""" + + def test_schema_name(self): + """Schema should have correct name.""" + assert CLARIFY_SCHEMA["name"] == "clarify" + + def test_schema_has_description(self): + """Schema should have a description.""" + assert "description" in CLARIFY_SCHEMA + assert len(CLARIFY_SCHEMA["description"]) > 50 + + def test_schema_question_required(self): + """Question parameter should be required.""" + assert "question" in CLARIFY_SCHEMA["parameters"]["required"] + + def test_schema_choices_optional(self): + """Choices parameter should be optional.""" + assert "choices" not in CLARIFY_SCHEMA["parameters"]["required"] + + def test_schema_choices_max_items(self): + """Schema should specify max items for choices.""" + choices_spec = CLARIFY_SCHEMA["parameters"]["properties"]["choices"] + assert choices_spec.get("maxItems") == MAX_CHOICES + + def test_max_choices_is_four(self): + """MAX_CHOICES constant should be 4.""" + assert MAX_CHOICES == 4