Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions src/claude/sdk_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/test_claude/test_sdk_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
Loading