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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Fixed
- **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
- **The Claude review workflow posts one review per pull request instead of one per push**: the job reruns on every `synchronize` and posted a fresh comment each time, so #236 collected nine full reviews in four and a half hours, each restating what the last had already settled. `use_sticky_comment` was set but does nothing here — it only applies to the action's tag mode, and this workflow supplies `prompt`, so the action posts nothing itself and the review is whatever the prompt tells Claude to post. The prompt now edits its own previous comment with `gh pr comment --edit-last --create-if-none`, so the pull request carries one review at the current head and GitHub keeps the superseded text in the comment's edit history. The inert input is removed rather than left to look load-bearing
- **The review reports only what should block the merge**: most of the length of those nine reviews was praise, an account of what had been checked, and cosmetic nits ("after 1 turns"), and every nit drew another push, which triggered another review — that loop, not the reviewing, was the spam. The prompt now names what qualifies (a security regression, a bug, an untested behaviour change, a missing setting or CHANGELOG entry) and rules out the rest, including anything `black`, `isort` or `flake8` already gates, and findings that cannot be confirmed from the code. It also reads its own previous review first so it does not repeat itself, but what settles a finding is the code at the current head rather than a reply claiming a fix: the replies are contributor-authored and untrusted like the rest of the pull request, so an earlier finding is re-checked against the diff and raised again unchanged when the code does not carry the claimed fix
- **The `review` check no longer fails red on every fork pull request**: `actions/checkout@v6` refuses to check out fork PR code from a `pull_request_target` workflow unless `allow-unsafe-pr-checkout: true` is set, so a fork PR died in about 9 seconds before reading any code, and `allowed_non_write_users: "*"` from #228 was doing nothing for the outside contributors who are most of this repository's traffic. Opting in was the wrong fix: the Claude CLI reads `.claude/settings.json` from the tree it runs in, so a fork that added a hook there would execute it with the job's secrets in the environment, and the action's secret scrubbing is documented as best-effort — the read-only tool allowlist is no boundary against that, because a hook does not go through it. The checkout now takes the pull request head only for an in-repo branch and the *base* commit for a fork, so fork code is never fetched. The reviewer takes the change from `gh pr diff`, which needs no checkout, and uses the working tree for surrounding context; the prompt states which case it is in, so it cannot mistake a file that predates the change for evidence that something is missing. `CLAUDE.md` is now read from the base branch too, so a fork can no longer edit the file the prompt sends the reviewer to read
- **The `review` check no longer goes red when the reviewer runs out of turns**: exhausting `--max-turns` is not a graceful stop — the action exits with no output, so the check fails and reads like the pull request is broken, which is what happened on #236 once its diff reached thirteen files. The narrower prompt above is the fix; the ceiling also moves from 40 to 80 for headroom

### Added
- **Blocked tool calls are reported to the user**: `ResultMessage.permission_denials` is now surfaced as a footer line listing what was refused and its most identifying argument, e.g. `🚫 2 tool calls were blocked: Write(/etc/hosts), Bash(cd /)`. This bot generates those denials itself — every `APPROVED_DIRECTORY` rejection, every Bash boundary violation, every Deny on an interactive approval prompt — and until now the only account of them a user saw was Claude's own narration of what it thought had happened, which is not authoritative. The line appears on successful runs too, since a denial does not by itself end a run
- **`stop_reason`, `terminal_reason` and `errors` on `ClaudeResponse`**: the remaining stop-reason fields the 0.2 SDK added are captured and written to the structlog event for any run that did not end cleanly, so the reason is in the logs even where it is not worth showing in Telegram. They are not persisted yet; that is a `claude_interactions` schema change

## [1.8.0] - 2026-09-22

Released as a minor rather than a patch: `claude-agent-sdk` moves from the 0.1
Expand Down
94 changes: 83 additions & 11 deletions src/bot/handlers/callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,74 @@
from telegram.ext import ContextTypes

from ...claude.facade import ClaudeIntegration
from ...claude.sdk_integration import ClaudeResponse
from ...config.settings import Settings
from ...security.audit import AuditLogger
from ...security.validators import SecurityValidator
from ..utils.html_format import escape_html
from ..utils.formatting import format_stop_reason
from ..utils.html_format import escape_html, markdown_to_telegram_html

logger = structlog.get_logger()

# Telegram caps a message at 4096 characters; ResponseFormatter works to 4000
# for the same reason, so the hand-built callback messages do too.
MAX_CALLBACK_MESSAGE_LEN = 4000
TRUNCATION_NOTE = "...\n\n<i>(Response truncated)</i>"


def _stop_reason_html(claude_response: ClaudeResponse) -> str:
"""The stop-reason footer as Telegram HTML, or "" when there is none.

These two handlers build their message as HTML by hand rather than going
through ResponseFormatter, so the footer is converted here instead.
"""
footer = format_stop_reason(claude_response)
return markdown_to_telegram_html(footer) if footer else ""


def _clip_escaped(body: str, limit: int) -> str:
"""Clip already-escaped HTML without cutting an entity in half.

The clip has to happen after escaping, because escaping is what can
quintuple the length and blow the budget. But a cut inside ``&amp;``
leaves ``&am``, which Telegram rejects outright with "can't parse
entities" -- and the handler's except would report a generic failure for
the stopped run this footer exists to explain. Every ``&`` here opens an
entity, since escape_html escaped the literal ones, so a trailing ``&``
with no ``;`` after it is a cut one and goes.
"""
clipped = body[:limit]
opener = clipped.rfind("&")
if opener != -1 and ";" not in clipped[opener:]:
clipped = clipped[:opener]
return clipped


def _compose_reply(
heading: str, claude_response: ClaudeResponse, body_limit: int
) -> str:
"""Heading, Claude's reply and its stop-reason footer, within the cap.

The heading and footer are sized first and the body is clipped to what is
left. Appending the footer to an already-clipped body can push the message
past Telegram's limit -- HTML escaping alone turns 500 characters of ``&``
into 2500 -- and reply_text does not split: the send raises, the handler's
except reports a failure, and the run that most needs its stop reason is
the one that loses it. The body is what gives, because it is truncated
already and the footer cannot be reconstructed from anything else on
screen.
"""
footer = _stop_reason_html(claude_response)
prefix = f"{heading}\n\n"
room = min(body_limit, MAX_CALLBACK_MESSAGE_LEN - len(prefix) - len(footer))

body = escape_html(claude_response.content)
if len(body) > room:
body = _clip_escaped(body, max(0, room - len(TRUNCATION_NOTE)))
body += TRUNCATION_NOTE

return f"{prefix}{body}{footer}"


def _is_within_root(path: Path, root: Path) -> bool:
"""Check whether path is within root directory."""
Expand Down Expand Up @@ -584,10 +645,19 @@ async def _handle_continue_action(query, context: ContextTypes.DEFAULT_TYPE) ->
# Update session ID in context
context.user_data["claude_session_id"] = claude_response.session_id

# Send Claude's response
# The session did continue either way, so the words stay; it is
# the tick that would be the false report, sitting above a footer
# that says the run was cut short.
tick = "✅" if claude_response.completed_normally else "⚠️"

# This is a preview of a resumed session rather than the reply
# itself, so it keeps its short body limit.
await query.message.reply_text(
f"✅ <b>Session Continued</b>\n\n"
f"{escape_html(claude_response.content[:500])}{'...' if len(claude_response.content) > 500 else ''}",
_compose_reply(
f"{tick} <b>Session Continued</b>",
claude_response,
body_limit=500,
),
parse_mode="HTML",
)
else:
Expand Down Expand Up @@ -924,15 +994,17 @@ async def handle_quick_action_callback(
)

if claude_response:
# Format and send the response
response_text = escape_html(claude_response.content)
if len(response_text) > 4000:
response_text = (
response_text[:4000] + "...\n\n<i>(Response truncated)</i>"
)
# The heading must not say "Complete" for a run that was cut
# short -- that is the same false report as #172, in a header.
if claude_response.completed_normally:
heading = f"✅ <b>{action.icon} {escape_html(action.name)} Complete</b>"
else:
heading = f"⚠️ <b>{action.icon} {escape_html(action.name)} Stopped</b>"

await query.message.reply_text(
f"✅ <b>{action.icon} {escape_html(action.name)} Complete</b>\n\n{response_text}",
_compose_reply(
heading, claude_response, body_limit=MAX_CALLBACK_MESSAGE_LEN
),
parse_mode="HTML",
)
else:
Expand Down
4 changes: 2 additions & 2 deletions src/bot/handlers/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -425,11 +425,11 @@ async def continue_session(update: Update, context: ContextTypes.DEFAULT_TYPE) -
await status_msg.delete()

# Format and send Claude's response
from ..utils.formatting import ResponseFormatter
from ..utils.formatting import ResponseFormatter, with_stop_reason

formatter = ResponseFormatter(settings)
formatted_messages = formatter.format_claude_response(
claude_response.content
with_stop_reason(claude_response)
)

for msg in formatted_messages:
Expand Down
9 changes: 5 additions & 4 deletions src/bot/handlers/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from ...security.audit import AuditLogger
from ...security.rate_limiter import RateLimiter
from ...security.validators import SecurityValidator
from ..utils.formatting import with_stop_reason
from ..utils.html_format import escape_html
from ..utils.image_extractor import (
ImageAttachment,
Expand Down Expand Up @@ -425,7 +426,7 @@ async def stream_handler(update_obj):

formatter = ResponseFormatter(settings)
formatted_messages = formatter.format_claude_response(
claude_response.content
with_stop_reason(claude_response)
)

except Exception as e:
Expand Down Expand Up @@ -833,7 +834,7 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) ->

formatter = ResponseFormatter(settings)
formatted_messages = formatter.format_claude_response(
claude_response.content
with_stop_reason(claude_response)
)

# Delete progress message
Expand Down Expand Up @@ -955,7 +956,7 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No

formatter = ResponseFormatter(settings)
formatted_messages = formatter.format_claude_response(
claude_response.content
with_stop_reason(claude_response)
)

# Delete progress message
Expand Down Expand Up @@ -1085,7 +1086,7 @@ async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No

formatter = ResponseFormatter(settings)
formatted_messages = formatter.format_claude_response(
claude_response.content
with_stop_reason(claude_response)
)

await progress_msg.delete()
Expand Down
24 changes: 12 additions & 12 deletions src/bot/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1113,17 +1113,15 @@ async def agentic_text(
logger.warning("Failed to log interaction", error=str(e))

# Format response (no reply_markup — strip keyboards)
from .utils.formatting import ResponseFormatter
from .utils.formatting import ResponseFormatter, with_stop_reason

formatter = ResponseFormatter(self.settings)

response_content = claude_response.content
if claude_response.interrupted:
response_content = (
response_content or ""
) + "\n\n_(Interrupted by user)_"

formatted_messages = formatter.format_claude_response(response_content)
# with_stop_reason carries the interruption note, the reason the
# run stopped, and any blocked tool calls (#230, #172).
formatted_messages = formatter.format_claude_response(
with_stop_reason(claude_response)
)

except Exception as e:
success = False
Expand Down Expand Up @@ -1346,11 +1344,11 @@ async def agentic_document(
claude_response, context, self.settings, user_id
)

from .utils.formatting import ResponseFormatter
from .utils.formatting import ResponseFormatter, with_stop_reason

formatter = ResponseFormatter(self.settings)
formatted_messages = formatter.format_claude_response(
claude_response.content
with_stop_reason(claude_response)
)

try:
Expand Down Expand Up @@ -1558,10 +1556,12 @@ async def _handle_agentic_media_message(
claude_response, context, self.settings, user_id
)

from .utils.formatting import ResponseFormatter
from .utils.formatting import ResponseFormatter, with_stop_reason

formatter = ResponseFormatter(self.settings)
formatted_messages = formatter.format_claude_response(claude_response.content)
formatted_messages = formatter.format_claude_response(
with_stop_reason(claude_response)
)

try:
await progress_msg.delete()
Expand Down
Loading
Loading