From 145880c5052c8bbb6b608e6b3512601781c651ae Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Wed, 31 Dec 2025 18:43:08 -0600 Subject: [PATCH 1/7] fix: avoid empty response warning when only tool calls are present Problem: -------- When using models that return tool calls without any text content (e.g., DeepSeek models in non-streaming mode), aider incorrectly displayed the warning: "Empty response received from LLM. Check your provider account?" This was misleading because the model had actually returned valid tool calls - it just didn't return any text content to display. Root Cause: ----------- The empty response warning logic only checked if text content was received, but didn't account for other valid response types: - Tool calls (function invocations) - Function calls (legacy format) - Reasoning content (for reasoning models) In non-streaming mode, the code called assistant_output() with an empty string when there were only tool calls, which triggered the warning in the TUI's assistant_output() method. Solution: --------- 1. Added partial response tracking system with flags for: - content: Regular text content - reasoning: Reasoning/thinking content - tool_calls: Tool/function call invocations - function_call: Legacy function call format 2. Modified show_send_output() to detect and register all response types in non-streaming mode before calling assistant_output() 3. Updated the empty response check to only show the warning when NO response type was received (truly empty response) 4. Added comprehensive test coverage for: - Flag initialization and registration - Response type detection in both streaming and non-streaming modes - Edge cases (tool calls only, reasoning only, etc.) Changes: -------- - aider/coders/base_coder.py: - Added _reset_partial_response_flags() - Added _register_partial_response() - Added _received_any_partial_response() - Enhanced show_send_output() to detect all response types - Updated empty response warning logic - tests/basic/test_coder.py: - Added 20+ new tests covering partial response tracking Impact: ------- - Users will no longer see misleading "Empty response" warnings when models return valid tool calls without text content - The warning will still be shown for truly empty responses (no content, no tool calls, no reasoning) - No breaking changes to existing functionality Co-authored-by: aider-ce (synthetic/hf:zai-org/GLM-4.7) --- aider/coders/base_coder.py | 86 ++++++- tests/basic/test_coder.py | 475 +++++++++++++++++++++++++++++++++++++ 2 files changed, 558 insertions(+), 3 deletions(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 51b04807273..2d0b07f6b5d 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -2983,6 +2983,7 @@ async def send(self, messages, model=None, functions=None, tools=None): self.partial_response_chunks = [] self.partial_response_tool_calls = [] self.partial_response_function_call = dict() + self._reset_partial_response_flags() completion = None @@ -3026,6 +3027,38 @@ async def send(self, messages, model=None, functions=None, tools=None): if args: self.io.ai_output(json.dumps(args, indent=4)) + def _reset_partial_response_flags(self): + self._partial_response_received_flags = { + "content": False, + "reasoning": False, + "tool_calls": False, + "function_call": False, + } + + def _register_partial_response( + self, + *, + content=False, + reasoning=False, + tool_calls=False, + function_call=False, + ): + flags = self._partial_response_received_flags + if content: + flags["content"] = True + if reasoning: + flags["reasoning"] = True + if tool_calls: + flags["tool_calls"] = True + if function_call: + flags["function_call"] = True + + def _received_any_partial_response(self, received_content_flag=False): + flags = self._partial_response_received_flags + if received_content_flag: + return True + return any(flags.values()) + def show_send_output(self, completion): if self.verbose: print(completion) @@ -3040,6 +3073,38 @@ def show_send_output(self, completion): self.partial_response_chunks.append(completion) + # Check for tool calls in the non-streaming response + try: + if completion.choices and completion.choices[0].message and completion.choices[0].message.tool_calls: + self._register_partial_response(tool_calls=True) + except (AttributeError, IndexError): + pass + + # Check for function calls in the non-streaming response + try: + if completion.choices and completion.choices[0].message and completion.choices[0].message.function_call: + self._register_partial_response(function_call=True) + except (AttributeError, IndexError): + pass + + # Check for reasoning content in the non-streaming response + try: + if completion.choices and completion.choices[0].message and completion.choices[0].message.reasoning_content: + self._register_partial_response(reasoning=True) + except AttributeError: + try: + if completion.choices and completion.choices[0].message and completion.choices[0].message.reasoning: + self._register_partial_response(reasoning=True) + except AttributeError: + pass + + # Check for regular content in the non-streaming response + try: + if completion.choices and completion.choices[0].message and completion.choices[0].message.content: + self._register_partial_response(content=True) + except AttributeError: + pass + response, func_err, content_err = self.consolidate_chunks() resp_hash = dict( @@ -3099,6 +3164,7 @@ async def show_send_output_stream(self, completion): try: if chunk.choices[0].delta.tool_calls: + self._register_partial_response(tool_calls=True) received_content = True for tool_call_chunk in chunk.choices[0].delta.tool_calls: self.tool_reflection = True @@ -3126,6 +3192,7 @@ async def show_send_output_stream(self, completion): self.tool_reflection = True self.io.update_spinner_suffix(v) + self._register_partial_response(function_call=True) received_content = True except AttributeError: pass @@ -3145,6 +3212,7 @@ async def show_send_output_stream(self, completion): text += f"<{REASONING_TAG}>\n\n" text += reasoning_content self.got_reasoning_content = True + self._register_partial_response(reasoning=True) received_content = True self.io.update_spinner_suffix(reasoning_content) self.partial_response_reasoning_content += reasoning_content @@ -3157,6 +3225,7 @@ async def show_send_output_stream(self, completion): self.ended_reasoning_content = True text += content + self._register_partial_response(content=True) received_content = True self.io.update_spinner_suffix(content) except AttributeError: @@ -3186,7 +3255,7 @@ async def show_send_output_stream(self, completion): # The Part Doing the Heavy Lifting Now self.consolidate_chunks() - if not received_content and len(self.partial_response_tool_calls) == 0: + if not self._received_any_partial_response(received_content): self.io.tool_warning("Empty response received from LLM. Check your provider account?") def consolidate_chunks(self): @@ -3207,6 +3276,7 @@ def consolidate_chunks(self): for chunk in self.partial_response_chunks: try: if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.tool_calls: + self._register_partial_response(tool_calls=True) for tool_call in chunk.choices[0].delta.tool_calls: if ( hasattr(tool_call, "provider_specific_fields") @@ -3235,6 +3305,7 @@ def consolidate_chunks(self): try: if response.choices[0].message.tool_calls: + self._register_partial_response(tool_calls=True) for i, tool_call in enumerate(response.choices[0].message.tool_calls): # Add provider-specific fields if we collected any for this tool tool_id = tool_call.id @@ -3268,6 +3339,7 @@ def consolidate_chunks(self): self.partial_response_function_call = ( response.choices[0].message.tool_calls[0].function ) + self._register_partial_response(function_call=True) except AttributeError as e: func_err = e @@ -3279,7 +3351,11 @@ def consolidate_chunks(self): except AttributeError: reasoning_content = None - self.partial_response_reasoning_content = reasoning_content or "" + if reasoning_content: + self.partial_response_reasoning_content = reasoning_content + self._register_partial_response(reasoning=True) + else: + self.partial_response_reasoning_content = "" try: content = response.choices[0].message.content @@ -3295,7 +3371,11 @@ def consolidate_chunks(self): for block in content if isinstance(block, dict) and block.get("type") == "text" ) - self.partial_response_content = content or "" + if content: + self.partial_response_content = content + self._register_partial_response(content=True) + else: + self.partial_response_content = "" except AttributeError as e: content_err = e diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index 42373ac1dac..25dc5fedbfd 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -1953,4 +1953,479 @@ async def test_execute_tool_calls_blob_content(self, mock_call_openai_tool): self.assertEqual(result[0]["content"], expected_content) + async def test_reset_partial_response_flags(self): + """Test that _reset_partial_response_flags initializes all flags to False.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Call the method + coder._reset_partial_response_flags() + + # Verify all flags are initialized to False + flags = coder._partial_response_received_flags + self.assertFalse(flags["content"]) + self.assertFalse(flags["reasoning"]) + self.assertFalse(flags["tool_calls"]) + self.assertFalse(flags["function_call"]) + + async def test_register_partial_response(self): + """Test that _register_partial_response correctly sets flags.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Reset flags first + coder._reset_partial_response_flags() + + # Register content response + coder._register_partial_response(content=True) + self.assertTrue(coder._partial_response_received_flags["content"]) + self.assertFalse(coder._partial_response_received_flags["reasoning"]) + + # Register reasoning response + coder._register_partial_response(reasoning=True) + self.assertTrue(coder._partial_response_received_flags["reasoning"]) + + # Register tool_calls response + coder._register_partial_response(tool_calls=True) + self.assertTrue(coder._partial_response_received_flags["tool_calls"]) + + # Register function_call response + coder._register_partial_response(function_call=True) + self.assertTrue(coder._partial_response_received_flags["function_call"]) + + # Test multiple flags at once + coder._reset_partial_response_flags() + coder._register_partial_response(content=True, reasoning=True) + self.assertTrue(coder._partial_response_received_flags["content"]) + self.assertTrue(coder._partial_response_received_flags["reasoning"]) + self.assertFalse(coder._partial_response_received_flags["tool_calls"]) + + async def test_received_any_partial_response(self): + """Test that _received_any_partial_response correctly checks flags.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Reset flags first + coder._reset_partial_response_flags() + + # Test when no flags are set + self.assertFalse(coder._received_any_partial_response()) + + # Test with received_content_flag=True + self.assertTrue(coder._received_any_partial_response(received_content_flag=True)) + + # Test with content flag set + coder._register_partial_response(content=True) + self.assertTrue(coder._received_any_partial_response()) + + # Test with reasoning flag set + coder._reset_partial_response_flags() + coder._register_partial_response(reasoning=True) + self.assertTrue(coder._received_any_partial_response()) + + # Test with tool_calls flag set + coder._reset_partial_response_flags() + coder._register_partial_response(tool_calls=True) + self.assertTrue(coder._received_any_partial_response()) + + # Test with function_call flag set + coder._reset_partial_response_flags() + coder._register_partial_response(function_call=True) + self.assertTrue(coder._received_any_partial_response()) + + async def test_partial_response_tracking_integration(self): + """Test that partial response tracking is properly integrated into response handling.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Mock a response chunk with content + mock_chunk = MagicMock() + mock_chunk.choices = [MagicMock()] + mock_chunk.choices[0].delta = MagicMock() + mock_chunk.choices[0].delta.content = "test content" + + # Simulate processing a chunk with content + coder.partial_response_chunks = [mock_chunk] + coder._reset_partial_response_flags() + + # Call consolidate_chunks which should register content + coder.consolidate_chunks() + + # Verify content flag was set + self.assertTrue(coder._partial_response_received_flags["content"]) + + # Test with reasoning content + coder._reset_partial_response_flags() + mock_chunk.choices[0].delta.reasoning_content = "test reasoning" + coder.consolidate_chunks() + self.assertTrue(coder._partial_response_received_flags["reasoning"]) + + async def test_empty_response_warning_integration(self): + """Test that empty response warning uses the new flag system.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + io.tool_warning = MagicMock() + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Test when no partial response is received + coder._reset_partial_response_flags() + coder.partial_response_chunks = [] + coder.partial_response_tool_calls = [] + + # This should trigger the empty response warning + coder.consolidate_chunks() + + # Verify warning was called + io.tool_warning.assert_called_with("Empty response received from LLM. Check your provider account?") + + # Reset and test when content is received + io.tool_warning.reset_mock() + coder._reset_partial_response_flags() + + # Add a content chunk + mock_chunk = MagicMock() + mock_chunk.choices = [MagicMock()] + mock_chunk.choices[0].delta = MagicMock() + mock_chunk.choices[0].delta.content = "test content" + coder.partial_response_chunks = [mock_chunk] + + coder.consolidate_chunks() + + # Verify warning was NOT called when content is received + io.tool_warning.assert_not_called() + + async def test_show_send_output_stream_registration(self): + """Test that show_send_output_stream properly registers different response types.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Test content registration + coder._reset_partial_response_flags() + mock_chunk = MagicMock() + mock_chunk.choices = [MagicMock()] + mock_chunk.choices[0].delta = MagicMock() + mock_chunk.choices[0].delta.content = "test content" + + # Simulate processing the chunk + coder.show_send_output_stream([mock_chunk]) + + # Verify content was registered + self.assertTrue(coder._partial_response_received_flags["content"]) + + # Test tool_calls registration + coder._reset_partial_response_flags() + mock_chunk.choices[0].delta.tool_calls = [MagicMock()] + mock_chunk.choices[0].delta.content = None + + coder.show_send_output_stream([mock_chunk]) + self.assertTrue(coder._partial_response_received_flags["tool_calls"]) + + # Test function_call registration + coder._reset_partial_response_flags() + mock_chunk.choices[0].delta.function_call = MagicMock() + mock_chunk.choices[0].delta.tool_calls = None + + coder.show_send_output_stream([mock_chunk]) + self.assertTrue(coder._partial_response_received_flags["function_call"]) + + # Test reasoning registration + coder._reset_partial_response_flags() + mock_chunk.choices[0].delta.reasoning_content = "test reasoning" + mock_chunk.choices[0].delta.function_call = None + + coder.show_send_output_stream([mock_chunk]) + self.assertTrue(coder._partial_response_received_flags["reasoning"]) + + async def test_consolidate_chunks_registration(self): + """Test that consolidate_chunks properly registers different response types.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Test tool_calls registration in consolidate_chunks + coder._reset_partial_response_flags() + mock_chunk = MagicMock() + mock_chunk.choices = [MagicMock()] + mock_chunk.choices[0].delta = MagicMock() + mock_chunk.choices[0].delta.tool_calls = [MagicMock()] + + coder.partial_response_chunks = [mock_chunk] + coder.consolidate_chunks() + + # Verify tool_calls was registered + self.assertTrue(coder._partial_response_received_flags["tool_calls"]) + + async def test_show_send_output_registration(self): + """Test that show_send_output properly registers different response types.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Test tool_calls registration in show_send_output + coder._reset_partial_response_flags() + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message = MagicMock() + mock_response.choices[0].message.tool_calls = [MagicMock()] + + coder.show_send_output(mock_response) + + # Verify tool_calls was registered + self.assertTrue(coder._partial_response_received_flags["tool_calls"]) + + # Test function_call registration + coder._reset_partial_response_flags() + mock_response.choices[0].message.tool_calls = [MagicMock()] + mock_response.choices[0].message.tool_calls[0].function = MagicMock() + + coder.show_send_output(mock_response) + self.assertTrue(coder._partial_response_received_flags["function_call"]) + + # Test reasoning registration + coder._reset_partial_response_flags() + mock_response.choices[0].message.reasoning_content = "test reasoning" + mock_response.choices[0].message.tool_calls = None + + coder.show_send_output(mock_response) + self.assertTrue(coder._partial_response_received_flags["reasoning"]) + + # Test content registration + coder._reset_partial_response_flags() + mock_response.choices[0].message.content = "test content" + mock_response.choices[0].message.reasoning_content = None + + coder.show_send_output(mock_response) + self.assertTrue(coder._partial_response_received_flags["content"]) + + async def test_initialization_in_constructor(self): + """Test that _reset_partial_response_flags is called during Coder initialization.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Verify that flags are initialized when coder is created + self.assertIn("_partial_response_received_flags", coder.__dict__) + flags = coder._partial_response_received_flags + self.assertFalse(flags["content"]) + self.assertFalse(flags["reasoning"]) + self.assertFalse(flags["tool_calls"]) + self.assertFalse(flags["function_call"]) + + async def test_empty_response_detection_edge_cases(self): + """Test edge cases for empty response detection.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + io.tool_warning = MagicMock() + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Test with only tool_calls (should not trigger warning) + coder._reset_partial_response_flags() + coder._register_partial_response(tool_calls=True) + coder.partial_response_chunks = [] + coder.partial_response_tool_calls = [MagicMock()] + + coder.consolidate_chunks() + io.tool_warning.assert_not_called() + + # Test with only function_call (should not trigger warning) + io.tool_warning.reset_mock() + coder._reset_partial_response_flags() + coder._register_partial_response(function_call=True) + coder.partial_response_chunks = [] + coder.partial_response_tool_calls = [] + + coder.consolidate_chunks() + io.tool_warning.assert_not_called() + + # Test with only reasoning (should not trigger warning) + io.tool_warning.reset_mock() + coder._reset_partial_response_flags() + coder._register_partial_response(reasoning=True) + coder.partial_response_chunks = [] + coder.partial_response_tool_calls = [] + + coder.consolidate_chunks() + io.tool_warning.assert_not_called() + + async def test_show_send_output_registers_tool_calls(self): + """Test that show_send_output properly registers tool calls in non-streaming responses.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + io.tool_warning = MagicMock() + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Create a mock response with tool calls + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message = MagicMock() + mock_response.choices[0].message.tool_calls = [MagicMock()] + mock_response.choices[0].message.content = None + mock_response.choices[0].message.reasoning_content = None + mock_response.choices[0].message.function_call = None + + # Reset flags before test + coder._reset_partial_response_flags() + + # Call show_send_output + coder.show_send_output(mock_response) + + # Verify that tool_calls flag was set + self.assertTrue(coder._partial_response_received_flags["tool_calls"]) + # Verify that no warning was shown + io.tool_warning.assert_not_called() + + async def test_show_send_output_registers_content(self): + """Test that show_send_output properly registers content in non-streaming responses.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + io.tool_warning = MagicMock() + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Create a mock response with content + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message = MagicMock() + mock_response.choices[0].message.tool_calls = None + mock_response.choices[0].message.content = "Test content" + mock_response.choices[0].message.reasoning_content = None + mock_response.choices[0].message.function_call = None + + # Reset flags before test + coder._reset_partial_response_flags() + + # Call show_send_output + coder.show_send_output(mock_response) + + # Verify that content flag was set + self.assertTrue(coder._partial_response_received_flags["content"]) + # Verify that no warning was shown + io.tool_warning.assert_not_called() + + async def test_show_send_output_registers_reasoning(self): + """Test that show_send_output properly registers reasoning content in non-streaming responses.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + io.tool_warning = MagicMock() + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Create a mock response with reasoning content + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message = MagicMock() + mock_response.choices[0].message.tool_calls = None + mock_response.choices[0].message.content = None + mock_response.choices[0].message.reasoning_content = "Test reasoning" + mock_response.choices[0].message.function_call = None + + # Reset flags before test + coder._reset_partial_response_flags() + + # Call show_send_output + coder.show_send_output(mock_response) + + # Verify that reasoning flag was set + self.assertTrue(coder._partial_response_received_flags["reasoning"]) + # Verify that no warning was shown + io.tool_warning.assert_not_called() + + async def test_show_send_output_registers_function_call(self): + """Test that show_send_output properly registers function calls in non-streaming responses.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + io.tool_warning = MagicMock() + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Create a mock response with function call + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message = MagicMock() + mock_response.choices[0].message.tool_calls = None + mock_response.choices[0].message.content = None + mock_response.choices[0].message.reasoning_content = None + mock_response.choices[0].message.function_call = MagicMock() + + # Reset flags before test + coder._reset_partial_response_flags() + + # Call show_send_output + coder.show_send_output(mock_response) + + # Verify that function_call flag was set + self.assertTrue(coder._partial_response_received_flags["function_call"]) + # Verify that no warning was shown + io.tool_warning.assert_not_called() + + async def test_show_send_output_stream_registers_tool_calls(self): + """Test that show_send_output_stream properly registers tool calls in streaming responses.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + io.tool_warning = MagicMock() + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Create a mock streaming response with tool calls + mock_chunk = MagicMock() + mock_chunk.choices = [MagicMock()] + mock_chunk.choices[0].delta = MagicMock() + mock_chunk.choices[0].delta.tool_calls = [MagicMock()] + mock_chunk.choices[0].delta.content = None + mock_chunk.choices[0].delta.reasoning_content = None + mock_chunk.choices[0].delta.function_call = None + + # Reset flags before test + coder._reset_partial_response_flags() + + # Create an async generator for the mock chunk + async def mock_stream(): + yield mock_chunk + + # Call show_send_output_stream + async for _ in coder.show_send_output_stream(mock_stream()): + pass + + # Verify that tool_calls flag was set + self.assertTrue(coder._partial_response_received_flags["tool_calls"]) + # Verify that no warning was shown + io.tool_warning.assert_not_called() + + async def test_show_send_output_stream_warns_on_empty_response(self): + """Test that show_send_output_stream shows warning for truly empty responses.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + io.tool_warning = MagicMock() + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Create an empty async generator (no chunks) + async def empty_stream(): + if False: + yield None + + # Reset flags before test + coder._reset_partial_response_flags() + + # Call show_send_output_stream with empty stream + async for _ in coder.show_send_output_stream(empty_stream()): + pass + + # Verify that warning was shown for empty response + io.tool_warning.assert_called_with("Empty response received from LLM. Check your provider account?") + + async def test_received_content_flag_override(self): + """Test that received_content_flag=True overrides all other flags.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + # Test with no flags set but received_content_flag=True + coder._reset_partial_response_flags() + self.assertTrue(coder._received_any_partial_response(received_content_flag=True)) + + # Test with flags set and received_content_flag=True + coder._register_partial_response(content=True, reasoning=True) + self.assertTrue(coder._received_any_partial_response(received_content_flag=True)) + # Remove the unittest.main() since we're using pytest From 9101730c33c87f7ac5608110c8cb6beff4eb7c6a Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Thu, 1 Jan 2026 00:13:55 -0600 Subject: [PATCH 2/7] style: reformat partial response checks and clean up test whitespace --- aider/coders/base_coder.py | 30 +++++-- tests/basic/test_coder.py | 174 +++++++++++++++++++------------------ 2 files changed, 114 insertions(+), 90 deletions(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 933a0c66d91..d9d6f6c77a4 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -3085,32 +3085,52 @@ def show_send_output(self, completion): # Check for tool calls in the non-streaming response try: - if completion.choices and completion.choices[0].message and completion.choices[0].message.tool_calls: + if ( + completion.choices + and completion.choices[0].message + and completion.choices[0].message.tool_calls + ): self._register_partial_response(tool_calls=True) except (AttributeError, IndexError): pass # Check for function calls in the non-streaming response try: - if completion.choices and completion.choices[0].message and completion.choices[0].message.function_call: + if ( + completion.choices + and completion.choices[0].message + and completion.choices[0].message.function_call + ): self._register_partial_response(function_call=True) except (AttributeError, IndexError): pass # Check for reasoning content in the non-streaming response try: - if completion.choices and completion.choices[0].message and completion.choices[0].message.reasoning_content: + if ( + completion.choices + and completion.choices[0].message + and completion.choices[0].message.reasoning_content + ): self._register_partial_response(reasoning=True) except AttributeError: try: - if completion.choices and completion.choices[0].message and completion.choices[0].message.reasoning: + if ( + completion.choices + and completion.choices[0].message + and completion.choices[0].message.reasoning + ): self._register_partial_response(reasoning=True) except AttributeError: pass # Check for regular content in the non-streaming response try: - if completion.choices and completion.choices[0].message and completion.choices[0].message.content: + if ( + completion.choices + and completion.choices[0].message + and completion.choices[0].message.content + ): self._register_partial_response(content=True) except AttributeError: pass diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index 25dc5fedbfd..f251d6c14d6 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -1952,16 +1952,15 @@ async def test_execute_tool_calls_blob_content(self, mock_call_openai_tool): ) self.assertEqual(result[0]["content"], expected_content) - async def test_reset_partial_response_flags(self): """Test that _reset_partial_response_flags initializes all flags to False.""" with GitTemporaryDirectory(): io = InputOutput(yes=True) coder = await Coder.create(self.GPT35, "diff", io=io) - + # Call the method coder._reset_partial_response_flags() - + # Verify all flags are initialized to False flags = coder._partial_response_received_flags self.assertFalse(flags["content"]) @@ -1974,27 +1973,27 @@ async def test_register_partial_response(self): with GitTemporaryDirectory(): io = InputOutput(yes=True) coder = await Coder.create(self.GPT35, "diff", io=io) - + # Reset flags first coder._reset_partial_response_flags() - + # Register content response coder._register_partial_response(content=True) self.assertTrue(coder._partial_response_received_flags["content"]) self.assertFalse(coder._partial_response_received_flags["reasoning"]) - + # Register reasoning response coder._register_partial_response(reasoning=True) self.assertTrue(coder._partial_response_received_flags["reasoning"]) - + # Register tool_calls response coder._register_partial_response(tool_calls=True) self.assertTrue(coder._partial_response_received_flags["tool_calls"]) - + # Register function_call response coder._register_partial_response(function_call=True) self.assertTrue(coder._partial_response_received_flags["function_call"]) - + # Test multiple flags at once coder._reset_partial_response_flags() coder._register_partial_response(content=True, reasoning=True) @@ -2007,30 +2006,30 @@ async def test_received_any_partial_response(self): with GitTemporaryDirectory(): io = InputOutput(yes=True) coder = await Coder.create(self.GPT35, "diff", io=io) - + # Reset flags first coder._reset_partial_response_flags() - + # Test when no flags are set self.assertFalse(coder._received_any_partial_response()) - + # Test with received_content_flag=True self.assertTrue(coder._received_any_partial_response(received_content_flag=True)) - + # Test with content flag set coder._register_partial_response(content=True) self.assertTrue(coder._received_any_partial_response()) - + # Test with reasoning flag set coder._reset_partial_response_flags() coder._register_partial_response(reasoning=True) self.assertTrue(coder._received_any_partial_response()) - + # Test with tool_calls flag set coder._reset_partial_response_flags() coder._register_partial_response(tool_calls=True) self.assertTrue(coder._received_any_partial_response()) - + # Test with function_call flag set coder._reset_partial_response_flags() coder._register_partial_response(function_call=True) @@ -2041,23 +2040,23 @@ async def test_partial_response_tracking_integration(self): with GitTemporaryDirectory(): io = InputOutput(yes=True) coder = await Coder.create(self.GPT35, "diff", io=io) - + # Mock a response chunk with content mock_chunk = MagicMock() mock_chunk.choices = [MagicMock()] mock_chunk.choices[0].delta = MagicMock() mock_chunk.choices[0].delta.content = "test content" - + # Simulate processing a chunk with content coder.partial_response_chunks = [mock_chunk] coder._reset_partial_response_flags() - + # Call consolidate_chunks which should register content coder.consolidate_chunks() - + # Verify content flag was set self.assertTrue(coder._partial_response_received_flags["content"]) - + # Test with reasoning content coder._reset_partial_response_flags() mock_chunk.choices[0].delta.reasoning_content = "test reasoning" @@ -2070,31 +2069,33 @@ async def test_empty_response_warning_integration(self): io = InputOutput(yes=True) io.tool_warning = MagicMock() coder = await Coder.create(self.GPT35, "diff", io=io) - + # Test when no partial response is received coder._reset_partial_response_flags() coder.partial_response_chunks = [] coder.partial_response_tool_calls = [] - + # This should trigger the empty response warning coder.consolidate_chunks() - + # Verify warning was called - io.tool_warning.assert_called_with("Empty response received from LLM. Check your provider account?") - + io.tool_warning.assert_called_with( + "Empty response received from LLM. Check your provider account?" + ) + # Reset and test when content is received io.tool_warning.reset_mock() coder._reset_partial_response_flags() - + # Add a content chunk mock_chunk = MagicMock() mock_chunk.choices = [MagicMock()] mock_chunk.choices[0].delta = MagicMock() mock_chunk.choices[0].delta.content = "test content" coder.partial_response_chunks = [mock_chunk] - + coder.consolidate_chunks() - + # Verify warning was NOT called when content is received io.tool_warning.assert_not_called() @@ -2103,41 +2104,41 @@ async def test_show_send_output_stream_registration(self): with GitTemporaryDirectory(): io = InputOutput(yes=True) coder = await Coder.create(self.GPT35, "diff", io=io) - + # Test content registration coder._reset_partial_response_flags() mock_chunk = MagicMock() mock_chunk.choices = [MagicMock()] mock_chunk.choices[0].delta = MagicMock() mock_chunk.choices[0].delta.content = "test content" - + # Simulate processing the chunk coder.show_send_output_stream([mock_chunk]) - + # Verify content was registered self.assertTrue(coder._partial_response_received_flags["content"]) - + # Test tool_calls registration coder._reset_partial_response_flags() mock_chunk.choices[0].delta.tool_calls = [MagicMock()] mock_chunk.choices[0].delta.content = None - + coder.show_send_output_stream([mock_chunk]) self.assertTrue(coder._partial_response_received_flags["tool_calls"]) - + # Test function_call registration coder._reset_partial_response_flags() mock_chunk.choices[0].delta.function_call = MagicMock() mock_chunk.choices[0].delta.tool_calls = None - + coder.show_send_output_stream([mock_chunk]) self.assertTrue(coder._partial_response_received_flags["function_call"]) - + # Test reasoning registration coder._reset_partial_response_flags() mock_chunk.choices[0].delta.reasoning_content = "test reasoning" mock_chunk.choices[0].delta.function_call = None - + coder.show_send_output_stream([mock_chunk]) self.assertTrue(coder._partial_response_received_flags["reasoning"]) @@ -2146,17 +2147,17 @@ async def test_consolidate_chunks_registration(self): with GitTemporaryDirectory(): io = InputOutput(yes=True) coder = await Coder.create(self.GPT35, "diff", io=io) - + # Test tool_calls registration in consolidate_chunks coder._reset_partial_response_flags() mock_chunk = MagicMock() mock_chunk.choices = [MagicMock()] mock_chunk.choices[0].delta = MagicMock() mock_chunk.choices[0].delta.tool_calls = [MagicMock()] - + coder.partial_response_chunks = [mock_chunk] coder.consolidate_chunks() - + # Verify tool_calls was registered self.assertTrue(coder._partial_response_received_flags["tool_calls"]) @@ -2165,40 +2166,40 @@ async def test_show_send_output_registration(self): with GitTemporaryDirectory(): io = InputOutput(yes=True) coder = await Coder.create(self.GPT35, "diff", io=io) - + # Test tool_calls registration in show_send_output coder._reset_partial_response_flags() mock_response = MagicMock() mock_response.choices = [MagicMock()] mock_response.choices[0].message = MagicMock() mock_response.choices[0].message.tool_calls = [MagicMock()] - + coder.show_send_output(mock_response) - + # Verify tool_calls was registered self.assertTrue(coder._partial_response_received_flags["tool_calls"]) - + # Test function_call registration coder._reset_partial_response_flags() mock_response.choices[0].message.tool_calls = [MagicMock()] mock_response.choices[0].message.tool_calls[0].function = MagicMock() - + coder.show_send_output(mock_response) self.assertTrue(coder._partial_response_received_flags["function_call"]) - + # Test reasoning registration coder._reset_partial_response_flags() mock_response.choices[0].message.reasoning_content = "test reasoning" mock_response.choices[0].message.tool_calls = None - + coder.show_send_output(mock_response) self.assertTrue(coder._partial_response_received_flags["reasoning"]) - + # Test content registration coder._reset_partial_response_flags() mock_response.choices[0].message.content = "test content" mock_response.choices[0].message.reasoning_content = None - + coder.show_send_output(mock_response) self.assertTrue(coder._partial_response_received_flags["content"]) @@ -2207,7 +2208,7 @@ async def test_initialization_in_constructor(self): with GitTemporaryDirectory(): io = InputOutput(yes=True) coder = await Coder.create(self.GPT35, "diff", io=io) - + # Verify that flags are initialized when coder is created self.assertIn("_partial_response_received_flags", coder.__dict__) flags = coder._partial_response_received_flags @@ -2222,33 +2223,33 @@ async def test_empty_response_detection_edge_cases(self): io = InputOutput(yes=True) io.tool_warning = MagicMock() coder = await Coder.create(self.GPT35, "diff", io=io) - + # Test with only tool_calls (should not trigger warning) coder._reset_partial_response_flags() coder._register_partial_response(tool_calls=True) coder.partial_response_chunks = [] coder.partial_response_tool_calls = [MagicMock()] - + coder.consolidate_chunks() io.tool_warning.assert_not_called() - + # Test with only function_call (should not trigger warning) io.tool_warning.reset_mock() coder._reset_partial_response_flags() coder._register_partial_response(function_call=True) coder.partial_response_chunks = [] coder.partial_response_tool_calls = [] - + coder.consolidate_chunks() io.tool_warning.assert_not_called() - + # Test with only reasoning (should not trigger warning) io.tool_warning.reset_mock() coder._reset_partial_response_flags() coder._register_partial_response(reasoning=True) coder.partial_response_chunks = [] coder.partial_response_tool_calls = [] - + coder.consolidate_chunks() io.tool_warning.assert_not_called() @@ -2258,7 +2259,7 @@ async def test_show_send_output_registers_tool_calls(self): io = InputOutput(yes=True) io.tool_warning = MagicMock() coder = await Coder.create(self.GPT35, "diff", io=io) - + # Create a mock response with tool calls mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -2267,13 +2268,13 @@ async def test_show_send_output_registers_tool_calls(self): mock_response.choices[0].message.content = None mock_response.choices[0].message.reasoning_content = None mock_response.choices[0].message.function_call = None - + # Reset flags before test coder._reset_partial_response_flags() - + # Call show_send_output coder.show_send_output(mock_response) - + # Verify that tool_calls flag was set self.assertTrue(coder._partial_response_received_flags["tool_calls"]) # Verify that no warning was shown @@ -2285,7 +2286,7 @@ async def test_show_send_output_registers_content(self): io = InputOutput(yes=True) io.tool_warning = MagicMock() coder = await Coder.create(self.GPT35, "diff", io=io) - + # Create a mock response with content mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -2294,13 +2295,13 @@ async def test_show_send_output_registers_content(self): mock_response.choices[0].message.content = "Test content" mock_response.choices[0].message.reasoning_content = None mock_response.choices[0].message.function_call = None - + # Reset flags before test coder._reset_partial_response_flags() - + # Call show_send_output coder.show_send_output(mock_response) - + # Verify that content flag was set self.assertTrue(coder._partial_response_received_flags["content"]) # Verify that no warning was shown @@ -2312,7 +2313,7 @@ async def test_show_send_output_registers_reasoning(self): io = InputOutput(yes=True) io.tool_warning = MagicMock() coder = await Coder.create(self.GPT35, "diff", io=io) - + # Create a mock response with reasoning content mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -2321,13 +2322,13 @@ async def test_show_send_output_registers_reasoning(self): mock_response.choices[0].message.content = None mock_response.choices[0].message.reasoning_content = "Test reasoning" mock_response.choices[0].message.function_call = None - + # Reset flags before test coder._reset_partial_response_flags() - + # Call show_send_output coder.show_send_output(mock_response) - + # Verify that reasoning flag was set self.assertTrue(coder._partial_response_received_flags["reasoning"]) # Verify that no warning was shown @@ -2339,7 +2340,7 @@ async def test_show_send_output_registers_function_call(self): io = InputOutput(yes=True) io.tool_warning = MagicMock() coder = await Coder.create(self.GPT35, "diff", io=io) - + # Create a mock response with function call mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -2348,13 +2349,13 @@ async def test_show_send_output_registers_function_call(self): mock_response.choices[0].message.content = None mock_response.choices[0].message.reasoning_content = None mock_response.choices[0].message.function_call = MagicMock() - + # Reset flags before test coder._reset_partial_response_flags() - + # Call show_send_output coder.show_send_output(mock_response) - + # Verify that function_call flag was set self.assertTrue(coder._partial_response_received_flags["function_call"]) # Verify that no warning was shown @@ -2366,7 +2367,7 @@ async def test_show_send_output_stream_registers_tool_calls(self): io = InputOutput(yes=True) io.tool_warning = MagicMock() coder = await Coder.create(self.GPT35, "diff", io=io) - + # Create a mock streaming response with tool calls mock_chunk = MagicMock() mock_chunk.choices = [MagicMock()] @@ -2375,18 +2376,18 @@ async def test_show_send_output_stream_registers_tool_calls(self): mock_chunk.choices[0].delta.content = None mock_chunk.choices[0].delta.reasoning_content = None mock_chunk.choices[0].delta.function_call = None - + # Reset flags before test coder._reset_partial_response_flags() - + # Create an async generator for the mock chunk async def mock_stream(): yield mock_chunk - + # Call show_send_output_stream async for _ in coder.show_send_output_stream(mock_stream()): pass - + # Verify that tool_calls flag was set self.assertTrue(coder._partial_response_received_flags["tool_calls"]) # Verify that no warning was shown @@ -2398,34 +2399,37 @@ async def test_show_send_output_stream_warns_on_empty_response(self): io = InputOutput(yes=True) io.tool_warning = MagicMock() coder = await Coder.create(self.GPT35, "diff", io=io) - + # Create an empty async generator (no chunks) async def empty_stream(): if False: yield None - + # Reset flags before test coder._reset_partial_response_flags() - + # Call show_send_output_stream with empty stream async for _ in coder.show_send_output_stream(empty_stream()): pass - + # Verify that warning was shown for empty response - io.tool_warning.assert_called_with("Empty response received from LLM. Check your provider account?") + io.tool_warning.assert_called_with( + "Empty response received from LLM. Check your provider account?" + ) async def test_received_content_flag_override(self): """Test that received_content_flag=True overrides all other flags.""" with GitTemporaryDirectory(): io = InputOutput(yes=True) coder = await Coder.create(self.GPT35, "diff", io=io) - + # Test with no flags set but received_content_flag=True coder._reset_partial_response_flags() self.assertTrue(coder._received_any_partial_response(received_content_flag=True)) - + # Test with flags set and received_content_flag=True coder._register_partial_response(content=True, reasoning=True) self.assertTrue(coder._received_any_partial_response(received_content_flag=True)) + # Remove the unittest.main() since we're using pytest From 0e6fa6ba160f73af562caedc07c54ed9a3e6031c Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Thu, 1 Jan 2026 07:57:08 -0600 Subject: [PATCH 3/7] feat: add detailed empty response warnings and update related tests Co-authored-by: aider-ce (synthetic/hf:Qwen/Qwen3-Coder-480B-A35B-Instruct) --- aider/coders/base_coder.py | 43 +++++++++++++++++++++++++++++++++++--- aider/io.py | 4 ++-- aider/tui/io.py | 4 ++-- tests/basic/test_coder.py | 6 +++--- 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index d9d6f6c77a4..0ad6b23df6a 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -3063,12 +3063,49 @@ def _register_partial_response( if function_call: flags["function_call"] = True - def _received_any_partial_response(self, received_content_flag=False): +def _received_any_partial_response(self, received_content_flag=False): flags = self._partial_response_received_flags if received_content_flag: return True return any(flags.values()) + def _get_empty_response_message(self): + """Generate a specific error message based on what type of response was received.""" + flags = self._partial_response_received_flags + + # Check what types of responses were received + has_content = flags.get("content", False) + has_reasoning = flags.get("reasoning", False) + has_tool_calls = flags.get("tool_calls", False) + has_function_call = flags.get("function_call", False) + + # If we received some response, be specific about what was missing + if has_tool_calls and not has_content: + if has_reasoning: + return ("Empty response received from LLM. " + "Only tool calls and reasoning content were received, but no text response. " + "Check if the model is configured to return text content.") + else: + return ("Empty response received from LLM. " + "Only tool calls were received, but no text response. " + "Check if the model is configured to return text content.") + elif has_reasoning and not has_content: + return ("Empty response received from LLM. " + "Only reasoning content was received, but no text response. " + "Check if the model is configured to return text content.") + elif has_function_call and not has_content: + return ("Empty response received from LLM. " + "Only function calls were received, but no text response. " + "Check if the model is configured to return text content.") + elif has_content and not has_content: # This should never happen, but just in case + return ("Empty response received from LLM. " + "Content flag was set but no actual content was received.") + else: + # Truly empty response + return ("Empty response received from LLM. " + "No content, tool calls, or reasoning was received. " + "Check your provider account, model availability, or network connectivity.") + def show_send_output(self, completion): if self.verbose: print(completion) @@ -3290,7 +3327,7 @@ async def show_send_output_stream(self, completion): self.consolidate_chunks() if not self._received_any_partial_response(received_content): - self.io.tool_warning("Empty response received from LLM. Check your provider account?") +self.io.tool_warning(self._get_empty_response_message()) def consolidate_chunks(self): response = ( @@ -4084,4 +4121,4 @@ def format_command_with_prefix(self, command): return command_prefix.replace("{}", command) else: # Append the command to the prefix with a space - return f"{command_prefix} {command}" + return f"{command_prefix} {command}" \ No newline at end of file diff --git a/aider/io.py b/aider/io.py index 2b50c9d88f5..9f0944547b2 100644 --- a/aider/io.py +++ b/aider/io.py @@ -1487,7 +1487,7 @@ def profile(self, *messages, start=False): def assistant_output(self, message, pretty=None): if not message: - self.tool_warning("Empty response received from LLM. Check your provider account?") +self.tool_warning("Empty response received from LLM. No text content was returned. Check your provider account, model availability, or network connectivity.") return show_resp = message @@ -1785,4 +1785,4 @@ def get_rel_fname(fname, root): try: return os.path.relpath(fname, root) except ValueError: - return fname + return fname \ No newline at end of file diff --git a/aider/tui/io.py b/aider/tui/io.py index dc6e5497195..5d3d66f8c0f 100644 --- a/aider/tui/io.py +++ b/aider/tui/io.py @@ -170,7 +170,7 @@ def assistant_output(self, message, pretty=None): pretty: Whether to use pretty formatting (unused in TUI, kept for compatibility) """ if not message: - self.tool_warning("Empty response received from LLM. Check your provider account?") +self.tool_warning("Empty response received from LLM. No text content was returned. Check your provider account, model availability, or network connectivity.") return # Use the streaming path so markdown rendering is applied @@ -563,4 +563,4 @@ def request_exit(self): This sends an exit signal to the TUI instead of calling sys.exit() directly, allowing Textual to properly restore terminal state. """ - self.output_queue.put({"type": "exit"}) + self.output_queue.put({"type": "exit"}) \ No newline at end of file diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index f251d6c14d6..5a8e82d9b1f 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -2080,7 +2080,7 @@ async def test_empty_response_warning_integration(self): # Verify warning was called io.tool_warning.assert_called_with( - "Empty response received from LLM. Check your provider account?" +"Empty response received from LLM. No content, tool calls, or reasoning was received. Check your provider account, model availability, or network connectivity." ) # Reset and test when content is received @@ -2414,7 +2414,7 @@ async def empty_stream(): # Verify that warning was shown for empty response io.tool_warning.assert_called_with( - "Empty response received from LLM. Check your provider account?" + "Empty response received from LLM. No content, tool calls, or reasoning was received. Check your provider account, model availability, or network connectivity." ) async def test_received_content_flag_override(self): @@ -2432,4 +2432,4 @@ async def test_received_content_flag_override(self): self.assertTrue(coder._received_any_partial_response(received_content_flag=True)) -# Remove the unittest.main() since we're using pytest +# Remove the unittest.main() since we're using pytest \ No newline at end of file From 24ac4081733be516ffb854d2b168a124bf29572c Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Thu, 1 Jan 2026 08:06:36 -0600 Subject: [PATCH 4/7] fix: correct indentation of helper methods in base_coder.py Co-authored-by: aider-ce (openai/gpt-5-codex) --- aider/coders/base_coder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 0ad6b23df6a..3c508cb2736 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -3063,7 +3063,7 @@ def _register_partial_response( if function_call: flags["function_call"] = True -def _received_any_partial_response(self, received_content_flag=False): + def _received_any_partial_response(self, received_content_flag=False): flags = self._partial_response_received_flags if received_content_flag: return True From 2035e567189dd5cd816bf759f9e9b30985ec75b5 Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Thu, 1 Jan 2026 08:07:49 -0600 Subject: [PATCH 5/7] fix: correct indentation of tool_warning call in base_coder Co-authored-by: aider-ce (openai/gpt-5-codex) --- aider/coders/base_coder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 3c508cb2736..e57b565d18b 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -3327,7 +3327,7 @@ async def show_send_output_stream(self, completion): self.consolidate_chunks() if not self._received_any_partial_response(received_content): -self.io.tool_warning(self._get_empty_response_message()) + self.io.tool_warning(self._get_empty_response_message()) def consolidate_chunks(self): response = ( From 0961b1e99d42cd0c6211a9347907caedbccd8b0d Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Thu, 1 Jan 2026 08:11:18 -0600 Subject: [PATCH 6/7] fix: correct indentation in InputOutput.assistant_output Co-authored-by: aider-ce (openai/gpt-5-codex) --- aider/io.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aider/io.py b/aider/io.py index 9f0944547b2..c97c57515ba 100644 --- a/aider/io.py +++ b/aider/io.py @@ -1487,7 +1487,9 @@ def profile(self, *messages, start=False): def assistant_output(self, message, pretty=None): if not message: -self.tool_warning("Empty response received from LLM. No text content was returned. Check your provider account, model availability, or network connectivity.") + self.tool_warning( + "Empty response received from LLM. No text content was returned. Check your provider account, model availability, or network connectivity." + ) return show_resp = message From 7003d37d07e37aead04def5782aaa5bcf3fe5e82 Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Sun, 4 Jan 2026 14:35:36 -0600 Subject: [PATCH 7/7] Improve empty-response handling for tool-only completions --- cecli/coders/base_coder.py | 132 ++++++++++++++++++++++++++++++++++++- cecli/io.py | 5 +- cecli/tui/io.py | 5 +- tests/basic/test_coder.py | 114 ++++++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+), 5 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 0991fc011e2..0c07d0e4305 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -575,6 +575,9 @@ def __init__( self.io.tool_output("JSON Schema:") self.io.tool_output(json.dumps(self.functions, indent=4)) + # Track partial response state for each request + self._reset_partial_response_flags() + @property def gpt_prompts(self): """Get prompts from the registry based on the coder type.""" @@ -2997,6 +3000,7 @@ async def send(self, messages, model=None, functions=None, tools=None): self.partial_response_chunks = [] self.partial_response_tool_calls = [] self.partial_response_function_call = dict() + self._reset_partial_response_flags() completion = None self.token_profiler.start() @@ -3042,6 +3046,84 @@ async def send(self, messages, model=None, functions=None, tools=None): if args: self.io.ai_output(json.dumps(args, indent=4)) + def _reset_partial_response_flags(self): + self._partial_response_received_flags = { + "content": False, + "reasoning": False, + "tool_calls": False, + "function_call": False, + } + + def _register_partial_response( + self, + *, + content=False, + reasoning=False, + tool_calls=False, + function_call=False, + ): + if not hasattr(self, "_partial_response_received_flags"): + self._reset_partial_response_flags() + + flags = self._partial_response_received_flags + if content: + flags["content"] = True + if reasoning: + flags["reasoning"] = True + if tool_calls: + flags["tool_calls"] = True + if function_call: + flags["function_call"] = True + + def _received_any_partial_response(self, received_content_flag=False): + if not hasattr(self, "_partial_response_received_flags"): + return False + + flags = self._partial_response_received_flags + if received_content_flag: + return True + return any(flags.values()) + + def _get_empty_response_message(self): + """Generate a descriptive warning for empty responses.""" + flags = getattr(self, "_partial_response_received_flags", {}) + + has_content = flags.get("content", False) + has_reasoning = flags.get("reasoning", False) + has_tool_calls = flags.get("tool_calls", False) + has_function_call = flags.get("function_call", False) + + if has_tool_calls and not has_content: + if has_reasoning: + return ( + "Empty response received from LLM. " + "Only tool calls and reasoning content were received, but no text response. " + "Check if the model is configured to return text content." + ) + return ( + "Empty response received from LLM. " + "Only tool calls were received, but no text response. " + "Check if the model is configured to return text content." + ) + if has_reasoning and not has_content: + return ( + "Empty response received from LLM. " + "Only reasoning content was received, but no text response. " + "Check if the model is configured to return text content." + ) + if has_function_call and not has_content: + return ( + "Empty response received from LLM. " + "Only function calls were received, but no text response. " + "Check if the model is configured to return text content." + ) + + return ( + "Empty response received from LLM. " + "No content, tool calls, or reasoning was received. " + "Check your provider account, model availability, or network connectivity." + ) + def show_send_output(self, completion): if self.verbose: print(completion) @@ -3056,6 +3138,31 @@ def show_send_output(self, completion): self.partial_response_chunks.append(completion) + try: + message = None + if completion.choices and completion.choices[0].message: + message = completion.choices[0].message + except (AttributeError, IndexError): + message = None + + if message: + if getattr(message, "tool_calls", None): + self._register_partial_response(tool_calls=True) + + if getattr(message, "function_call", None): + self._register_partial_response(function_call=True) + + reasoning_content = getattr(message, "reasoning_content", None) + if reasoning_content: + self._register_partial_response(reasoning=True) + else: + reasoning_attr = getattr(message, "reasoning", None) + if reasoning_attr: + self._register_partial_response(reasoning=True) + + if getattr(message, "content", None): + self._register_partial_response(content=True) + response, func_err, content_err = self.consolidate_chunks() resp_hash = dict( @@ -3080,7 +3187,10 @@ def show_send_output(self, completion): show_resp = replace_reasoning_tags(show_resp, self.reasoning_tag_name) - self.io.assistant_output(show_resp, pretty=self.show_pretty()) + if show_resp: + self.io.assistant_output(show_resp, pretty=self.show_pretty()) + elif not self._received_any_partial_response(): + self.io.tool_warning(self._get_empty_response_message()) if ( hasattr(completion.choices[0], "finish_reason") @@ -3227,6 +3337,8 @@ def consolidate_chunks(self): for chunk in self.partial_response_chunks: try: if chunk.choices and chunk.choices[0].delta and chunk.choices[0].delta.tool_calls: + if not self.stream: + self._register_partial_response(tool_calls=True) for tool_call in chunk.choices[0].delta.tool_calls: if ( hasattr(tool_call, "provider_specific_fields") @@ -3255,6 +3367,8 @@ def consolidate_chunks(self): try: if response.choices[0].message.tool_calls: + if not self.stream: + self._register_partial_response(tool_calls=True) for i, tool_call in enumerate(response.choices[0].message.tool_calls): # Add provider-specific fields if we collected any for this tool tool_id = tool_call.id @@ -3288,6 +3402,8 @@ def consolidate_chunks(self): self.partial_response_function_call = ( response.choices[0].message.tool_calls[0].function ) + if self.partial_response_function_call and not self.stream: + self._register_partial_response(function_call=True) except AttributeError as e: func_err = e @@ -3299,7 +3415,12 @@ def consolidate_chunks(self): except AttributeError: reasoning_content = None - self.partial_response_reasoning_content = reasoning_content or "" + if reasoning_content: + self.partial_response_reasoning_content = reasoning_content + if not self.stream: + self._register_partial_response(reasoning=True) + else: + self.partial_response_reasoning_content = "" try: content = response.choices[0].message.content @@ -3315,7 +3436,12 @@ def consolidate_chunks(self): for block in content if isinstance(block, dict) and block.get("type") == "text" ) - self.partial_response_content = content or "" + if content: + self.partial_response_content = content + if not self.stream: + self._register_partial_response(content=True) + else: + self.partial_response_content = "" except AttributeError as e: content_err = e diff --git a/cecli/io.py b/cecli/io.py index df51271e5dc..f4a8185f9a0 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -1481,7 +1481,10 @@ def profile(self, *messages, start=False): def assistant_output(self, message, pretty=None): if not message: - self.tool_warning("Empty response received from LLM. Check your provider account?") + self.tool_warning( + "Empty response received from LLM. No text content was returned. " + "Check your provider account, model availability, or network connectivity." + ) return show_resp = message diff --git a/cecli/tui/io.py b/cecli/tui/io.py index 6ae017eaeb6..3a58326e4ed 100644 --- a/cecli/tui/io.py +++ b/cecli/tui/io.py @@ -170,7 +170,10 @@ def assistant_output(self, message, pretty=None): pretty: Whether to use pretty formatting (unused in TUI, kept for compatibility) """ if not message: - self.tool_warning("Empty response received from LLM. Check your provider account?") + self.tool_warning( + "Empty response received from LLM. No text content was returned. " + "Check your provider account, model availability, or network connectivity." + ) return # Use the streaming path so markdown rendering is applied diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index febe38028e3..01e38b41d9a 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -1927,3 +1927,117 @@ async def test_execute_tool_calls_blob_content(self, mock_call_openai_tool): " (application/octet-stream)]" ) assert result[0]["content"] == expected_content + + async def test_reset_partial_response_flags(self): + """Test that _reset_partial_response_flags initializes all flags to False.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + coder._reset_partial_response_flags() + flags = coder._partial_response_received_flags + assert flags["content"] is False + assert flags["reasoning"] is False + assert flags["tool_calls"] is False + assert flags["function_call"] is False + + async def test_register_partial_response(self): + """Test that _register_partial_response correctly sets flags.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + coder._reset_partial_response_flags() + coder._register_partial_response(content=True) + assert coder._partial_response_received_flags["content"] is True + assert coder._partial_response_received_flags["reasoning"] is False + + coder._register_partial_response(reasoning=True) + assert coder._partial_response_received_flags["reasoning"] is True + + coder._register_partial_response(tool_calls=True) + assert coder._partial_response_received_flags["tool_calls"] is True + + coder._register_partial_response(function_call=True) + assert coder._partial_response_received_flags["function_call"] is True + + coder._reset_partial_response_flags() + coder._register_partial_response(content=True, reasoning=True) + assert coder._partial_response_received_flags["content"] is True + assert coder._partial_response_received_flags["reasoning"] is True + assert coder._partial_response_received_flags["tool_calls"] is False + + async def test_received_any_partial_response(self): + """Test that _received_any_partial_response correctly checks flags.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + coder._reset_partial_response_flags() + assert coder._received_any_partial_response() is False + assert coder._received_any_partial_response(received_content_flag=True) is True + + coder._register_partial_response(content=True) + assert coder._received_any_partial_response() is True + + coder._reset_partial_response_flags() + coder._register_partial_response(reasoning=True) + assert coder._received_any_partial_response() is True + + coder._reset_partial_response_flags() + coder._register_partial_response(tool_calls=True) + assert coder._received_any_partial_response() is True + + coder._reset_partial_response_flags() + coder._register_partial_response(function_call=True) + assert coder._received_any_partial_response() is True + + async def test_get_empty_response_message_variants(self): + """Verify _get_empty_response_message returns descriptive strings.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + coder._reset_partial_response_flags() + coder._register_partial_response(tool_calls=True) + msg = coder._get_empty_response_message() + assert "Only tool calls" in msg + + coder._reset_partial_response_flags() + coder._register_partial_response(tool_calls=True, reasoning=True) + msg = coder._get_empty_response_message() + assert "tool calls and reasoning" in msg + + coder._reset_partial_response_flags() + coder._register_partial_response(reasoning=True) + msg = coder._get_empty_response_message() + assert "Only reasoning content" in msg + + coder._reset_partial_response_flags() + coder._register_partial_response(function_call=True) + msg = coder._get_empty_response_message() + assert "Only function calls" in msg + async def test_initialization_in_constructor(self): + """Test that coders initialize the partial response flags.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + assert "_partial_response_received_flags" in coder.__dict__ + flags = coder._partial_response_received_flags + assert flags["content"] is False + assert flags["reasoning"] is False + assert flags["tool_calls"] is False + assert flags["function_call"] is False + + async def test_received_content_flag_override(self): + """Test that received_content_flag overrides other flags.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=True) + coder = await Coder.create(self.GPT35, "diff", io=io) + + coder._reset_partial_response_flags() + assert coder._received_any_partial_response(received_content_flag=True) is True + + coder._register_partial_response(content=True, reasoning=True) + assert coder._received_any_partial_response(received_content_flag=True) is True