diff --git a/CHANGELOG.md b/CHANGELOG.md index 874799ca..f1e7d883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **OpenRouter empty-result fallback**: Anthropic-compatible providers that return an empty `ResultMessage.result` now fall back to the text in `AssistantMessage` instead of producing "No content to display" (#171) - **A run that stops early no longer reports success**: Claude produces no final text when the CLI kills a run at the turn limit, so the bot fell through to its "✅ Task completed" placeholder and told the user the work was done. A run that died at turn 10 mid-task and a run that finished were indistinguishable in Telegram (#172). `ResultMessage.subtype` is now read alongside the cost and session id, the placeholder claims completion only for `success`, and the reply carries a footer saying why the run ended — turn limit, cost budget, cancellation, or an unrecognised reason named by its raw subtype — with the turn count and a prompt to send another message to continue. Every place the bot renders a Claude reply carries it — typed messages, document, photo and voice input in agentic mode, the same four in classic mode, `/continue`, the Continue Session button and the quick-action buttons, whose `✅ … Complete` heading now reads `⚠️ … Stopped` when the run was cut short. Webhook-triggered and scheduled runs carry it too: nobody is watching those, so the reason a nightly job came back short is the whole of what its notification can say about it (#230) - **`ClaudeResponse.num_turns` is the turn count the CLI reports**: it was derived by counting `UserMessage` and `AssistantMessage` objects, which over-reports — every tool result arrives as another user message — so a run stopped at turn 10 could be recorded as having taken roughly twice that. It only reached the logs and the session store before; the stop-reason footer now shows it to the user, which made the approximation worth removing. `ResultMessage.num_turns` is used where the CLI supplies it, with the message count kept as the fallback for a result that carries none - **Inline code spans accept backtick runs of any length**: `markdown_to_telegram_html` matched a code span only between single backticks with no backtick inside, so ``` ``a`b`` ``` rendered as the span `a` followed by loose text. A run of N backticks now opens a span that closes on a run of N, and one space is stripped from each end when both are present, as CommonMark specifies. Pairing the runs is done by scanning rather than by a backreferencing regex: `` (`+)([^\n]*?)\1 `` re-scans the rest of the line for every opener that never closes, which is superlinear on a reply whose backtick runs are all of different lengths — 2.5 seconds on a 256KB reply, on the event loop, against under a millisecond for the scan. This is what lets the blocked-tool-call line print an argument containing backticks: `` Bash(`echo `whoami``) `` names the command that was actually refused, where dropping the backticks would have named a different one diff --git a/src/claude/sdk_integration.py b/src/claude/sdk_integration.py index c67b16f8..f4231004 100644 --- a/src/claude/sdk_integration.py +++ b/src/claude/sdk_integration.py @@ -781,9 +781,15 @@ async def _cancel_on_interrupt() -> None: previous_session_id=session_id, ) - # Use ResultMessage.result if available, fall back to message extraction - if result_content is not None: - content = str(result_content).strip() + # Some Anthropic-compatible providers (including OpenRouter) return + # an empty ResultMessage.result even though the AssistantMessage + # contains the actual reply. Only prefer a non-empty result so those + # providers still reach the message-extraction fallback (#171). + result_text = ( + str(result_content).strip() if result_content is not None else "" + ) + if result_text: + content = result_text else: content_parts = [] for msg in messages: diff --git a/tests/unit/test_claude/test_sdk_integration.py b/tests/unit/test_claude/test_sdk_integration.py index 5c81dadc..9b013431 100644 --- a/tests/unit/test_claude/test_sdk_integration.py +++ b/tests/unit/test_claude/test_sdk_integration.py @@ -218,6 +218,26 @@ async def test_execute_command_falls_back_to_messages(self, sdk_manager): assert response.content == "Extracted from messages" + @pytest.mark.parametrize("empty_result", ["", " \n"]) + async def test_execute_command_falls_back_when_result_is_empty( + self, sdk_manager, empty_result + ): + """Empty provider results must not hide AssistantMessage text (#171).""" + mock_factory = _mock_client_factory( + _make_assistant_message("OpenRouter response"), + _make_result_message(result=empty_result), + ) + + with patch( + "src.claude.sdk_integration.ClaudeSDKClient", side_effect=mock_factory + ): + response = await sdk_manager.execute_command( + prompt="Test prompt", + working_directory=Path("/test"), + ) + + assert response.content == "OpenRouter response" + async def test_execute_command_with_streaming(self, sdk_manager): """Test command execution with streaming callback.""" stream_updates = []