/ tests / tools / test_clarify_tool.py
test_clarify_tool.py
  1  """Tests for tools/clarify_tool.py - Interactive clarifying questions."""
  2  
  3  import json
  4  from typing import List, Optional
  5  
  6  import pytest
  7  
  8  from tools.clarify_tool import (
  9      clarify_tool,
 10      check_clarify_requirements,
 11      MAX_CHOICES,
 12      CLARIFY_SCHEMA,
 13  )
 14  
 15  
 16  class TestClarifyToolBasics:
 17      """Basic functionality tests for clarify_tool."""
 18  
 19      def test_simple_question_with_callback(self):
 20          """Should return user response for simple question."""
 21          def mock_callback(question: str, choices: Optional[List[str]]) -> str:
 22              assert question == "What color?"
 23              assert choices is None
 24              return "blue"
 25  
 26          result = json.loads(clarify_tool("What color?", callback=mock_callback))
 27          assert result["question"] == "What color?"
 28          assert result["choices_offered"] is None
 29          assert result["user_response"] == "blue"
 30  
 31      def test_question_with_choices(self):
 32          """Should pass choices to callback and return response."""
 33          def mock_callback(question: str, choices: Optional[List[str]]) -> str:
 34              assert question == "Pick a number"
 35              assert choices == ["1", "2", "3"]
 36              return "2"
 37  
 38          result = json.loads(clarify_tool(
 39              "Pick a number",
 40              choices=["1", "2", "3"],
 41              callback=mock_callback
 42          ))
 43          assert result["question"] == "Pick a number"
 44          assert result["choices_offered"] == ["1", "2", "3"]
 45          assert result["user_response"] == "2"
 46  
 47      def test_empty_question_returns_error(self):
 48          """Should return error for empty question."""
 49          result = json.loads(clarify_tool("", callback=lambda q, c: "ignored"))
 50          assert "error" in result
 51          assert "required" in result["error"].lower()
 52  
 53      def test_whitespace_only_question_returns_error(self):
 54          """Should return error for whitespace-only question."""
 55          result = json.loads(clarify_tool("   \n\t  ", callback=lambda q, c: "ignored"))
 56          assert "error" in result
 57  
 58      def test_no_callback_returns_error(self):
 59          """Should return error when no callback is provided."""
 60          result = json.loads(clarify_tool("What do you want?"))
 61          assert "error" in result
 62          assert "not available" in result["error"].lower()
 63  
 64  
 65  class TestClarifyToolChoicesValidation:
 66      """Tests for choices parameter validation."""
 67  
 68      def test_choices_trimmed_to_max(self):
 69          """Should trim choices to MAX_CHOICES."""
 70          choices_passed = []
 71  
 72          def mock_callback(question: str, choices: Optional[List[str]]) -> str:
 73              choices_passed.extend(choices or [])
 74              return "picked"
 75  
 76          many_choices = ["a", "b", "c", "d", "e", "f", "g"]
 77          clarify_tool("Pick one", choices=many_choices, callback=mock_callback)
 78  
 79          assert len(choices_passed) == MAX_CHOICES
 80  
 81      def test_empty_choices_become_none(self):
 82          """Empty choices list should become None (open-ended)."""
 83          choices_received = ["marker"]
 84  
 85          def mock_callback(question: str, choices: Optional[List[str]]) -> str:
 86              choices_received.clear()
 87              if choices is not None:
 88                  choices_received.extend(choices)
 89              return "answer"
 90  
 91          clarify_tool("Open question?", choices=[], callback=mock_callback)
 92          assert choices_received == []  # Was cleared, nothing added
 93  
 94      def test_choices_with_only_whitespace_stripped(self):
 95          """Whitespace-only choices should be stripped out."""
 96          choices_received = []
 97  
 98          def mock_callback(question: str, choices: Optional[List[str]]) -> str:
 99              choices_received.extend(choices or [])
100              return "answer"
101  
102          clarify_tool("Pick", choices=["valid", "  ", "", "also valid"], callback=mock_callback)
103          assert choices_received == ["valid", "also valid"]
104  
105      def test_invalid_choices_type_returns_error(self):
106          """Non-list choices should return error."""
107          result = json.loads(clarify_tool(
108              "Question?",
109              choices="not a list",  # type: ignore
110              callback=lambda q, c: "ignored"
111          ))
112          assert "error" in result
113          assert "list" in result["error"].lower()
114  
115      def test_choices_converted_to_strings(self):
116          """Non-string choices should be converted to strings."""
117          choices_received = []
118  
119          def mock_callback(question: str, choices: Optional[List[str]]) -> str:
120              choices_received.extend(choices or [])
121              return "answer"
122  
123          clarify_tool("Pick", choices=[1, 2, 3], callback=mock_callback)  # type: ignore
124          assert choices_received == ["1", "2", "3"]
125  
126  
127  class TestClarifyToolCallbackHandling:
128      """Tests for callback error handling."""
129  
130      def test_callback_exception_returns_error(self):
131          """Should return error if callback raises exception."""
132          def failing_callback(question: str, choices: Optional[List[str]]) -> str:
133              raise RuntimeError("User cancelled")
134  
135          result = json.loads(clarify_tool("Question?", callback=failing_callback))
136          assert "error" in result
137          assert "Failed to get user input" in result["error"]
138          assert "User cancelled" in result["error"]
139  
140      def test_callback_receives_stripped_question(self):
141          """Callback should receive trimmed question."""
142          received_question = []
143  
144          def mock_callback(question: str, choices: Optional[List[str]]) -> str:
145              received_question.append(question)
146              return "answer"
147  
148          clarify_tool("  Question with spaces  \n", callback=mock_callback)
149          assert received_question[0] == "Question with spaces"
150  
151      def test_user_response_stripped(self):
152          """User response should be stripped of whitespace."""
153          def mock_callback(question: str, choices: Optional[List[str]]) -> str:
154              return "  response with spaces  \n"
155  
156          result = json.loads(clarify_tool("Q?", callback=mock_callback))
157          assert result["user_response"] == "response with spaces"
158  
159  
160  class TestCheckClarifyRequirements:
161      """Tests for the requirements check function."""
162  
163      def test_always_returns_true(self):
164          """clarify tool has no external requirements."""
165          assert check_clarify_requirements() is True
166  
167  
168  class TestClarifySchema:
169      """Tests for the OpenAI function-calling schema."""
170  
171      def test_schema_name(self):
172          """Schema should have correct name."""
173          assert CLARIFY_SCHEMA["name"] == "clarify"
174  
175      def test_schema_has_description(self):
176          """Schema should have a description."""
177          assert "description" in CLARIFY_SCHEMA
178          assert len(CLARIFY_SCHEMA["description"]) > 50
179  
180      def test_schema_question_required(self):
181          """Question parameter should be required."""
182          assert "question" in CLARIFY_SCHEMA["parameters"]["required"]
183  
184      def test_schema_choices_optional(self):
185          """Choices parameter should be optional."""
186          assert "choices" not in CLARIFY_SCHEMA["parameters"]["required"]
187  
188      def test_schema_choices_max_items(self):
189          """Schema should specify max items for choices."""
190          choices_spec = CLARIFY_SCHEMA["parameters"]["properties"]["choices"]
191          assert choices_spec.get("maxItems") == MAX_CHOICES
192  
193      def test_max_choices_is_four(self):
194          """MAX_CHOICES constant should be 4."""
195          assert MAX_CHOICES == 4