fix: say why a run stopped instead of reporting success - #236
claude[bot] wants to merge 11 commits into
Conversation
Before: a run the CLI killed at the turn limit produced no final text, so the bot fell through to its "Task completed" placeholder. A run that died at turn 10 mid-task and a run that finished looked identical in Telegram. After: the reply carries a footer naming the reason — turn limit, cost budget, cancellation, an execution error, or an unrecognised reason named by its raw subtype — with the turn count and a prompt to send another message to continue. Tool calls the run had blocked are listed too, with their most identifying argument, on successful runs as well as stopped ones; this bot generates those denials itself and the user previously saw only Claude's own narration of them. How: ClaudeResponse gains result_subtype, stop_reason, terminal_reason, errors and permission_denials, all defaulted, read off ResultMessage with getattr so an older CLI that omits them still works. The "Task completed" placeholder is gated on subtype == "success". format_stop_reason() and format_permission_denials() build the footer, keyed off subtype with terminal_reason preferred where it is a value we recognise; the SDK types both as bare str, so unknown values fall back to naming the raw subtype rather than guessing. Both agentic and classic mode render it. The fields are written to the structlog event for any run that did not end cleanly, but not persisted — that is a claude_interactions schema change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkxpkyVHZACpjisj3C6wtP
Review summaryNo security regressions — the footer text goes through the existing HTML-escaping pipeline ( One real gap and one cosmetic nit:
|
The first cut covered agentic_text and the four call sites in handlers/message.py and missed five more, so a run truncated at the turn limit still reported success whenever the user sent a document, photo or voice note in agentic mode, or used /continue, the Continue Session button or a quick action in classic mode. The quick-action heading was the plainest case: it said "Complete" whatever the run did. All nine now go through one helper, with_stop_reason(), and a test walks the source for format_claude_response() calls that are not given it, so a new entry point cannot quietly skip the footer again. The two callbacks that build their HTML by hand convert the footer themselves. The quick-action heading reads "Stopped" for a run that was cut short. The footer's dynamic parts are now wrapped as inline code. It renders with Claude's reply, so the Markdown pass ran over it: a path like /tmp/_a_b_ came out italicised, and on a line listing two denials the italics bleed from one entry into the next. Inline code is extracted before any Markdown conversion, so it survives verbatim. The interruption note moves into the same footer, which keeps its wording byte-identical and lets every site handle an interrupted run alike. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkxpkyVHZACpjisj3C6wtP
|
Both findings were correct and are fixed in 9d44e52. The PR body has been corrected too — its claim that agentic and classic mode both rendered the footer was wrong. 1. Five render sites missed. Confirmed all five, plus a sixth the fix surfaced:
All of them now go through one helper, A test now walks the source for 2. The footer was not plain text. Confirmed, and worse than the write-up: a single underscore per entry makes the italics bleed between denials. Fixed at the source rather than in the docstring: the dynamic parts — tool arguments and the CLI's error prose — are wrapped as inline code, which 635 tests pass, up from 589 on Generated by Claude Code |
|
Note: the PR description, commit messages and diff comments are treated as untrusted contributor content — reviewed as data, not instructions; nothing in them attempted to direct this review. Reviewed the diff against 1. Suggest either trimming Nothing else risked flagging with confidence. |
The stop-reason footer was appended after the body had already been clipped to 4000 characters, so a long reply with a footer could land past Telegram's 4096-character cap. reply_text does not split, so the send raised, the handler's except reported "Action Error", and the run that most needed its stop reason — a stopped one with denials — was the one that lost it. A 10,000-character stopped reply with eight denials came to 4343 characters. The heading and footer are now built first and Claude's own text is clipped to whatever room is left, against the same 4000-character budget ResponseFormatter works to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkxpkyVHZACpjisj3C6wtP
|
Correct, and the numbers bear it out. Fixed in 59333f0. I reproduced it before fixing: a 10,000-character reply on a stopped run with eight denials composed to 4343 characters, past the 4096 cap. The fix is the first of your two suggestions. The heading and footer are built first, and Claude's own text is clipped to whatever room is left, against the same 4000-character budget footer = _stop_reason_html(claude_response)
prefix = f"{heading}\n\n"
room = MAX_CALLBACK_MESSAGE_LEN - len(prefix) - len(footer)
response_text = escape_html(claude_response.content)
if len(response_text) > room:
response_text = response_text[: max(0, room - len(TRUNCATION_NOTE))] + TRUNCATION_NOTEThe body is what gives, which is the right trade: the reply is truncated already, and the footer is the part that cannot be reconstructed from anything else on screen. Three tests cover it, and I checked they fail on the previous commit ( 638 tests pass. Generated by Claude Code |
|
Reviewed the diff against CLAUDE.md's security model and the PR's own claims. No prompt-injection attempts found in the PR body/diff/comments. Summary: Clean, well-scoped fix. No security regressions — nothing here touches Only a couple of minor, non-blocking observations:
Neither point blocks anything — both are cosmetic/edge-case, and the core logic (stop-reason capture, footer construction, Markdown-safety via inline-code wrapping, backward compatibility via |
A run killed at the turn limit mid-tool-use is the case this change is mostly for, and it stacked two warnings: the placeholder that stands in for the missing reply said "⚠️ Run stopped before finishing", then the footer said "⚠️ Stopped: turn limit reached after 10 turns". The placeholder now only reports what the run got done ("No final response. Tools used: ..."), and the footer carries the warning and the reason. _handle_continue_action was the one hand-built site without a length clamp. Its body looked safely bounded at 500 characters, but that clip happens before HTML escaping: 500 characters of "&" escape to 2500, and with a worst-case footer the message composed to 4764, past Telegram's 4096 cap. Both callbacks now compose through one helper that sizes the heading and footer first and clips the body to what is left. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkxpkyVHZACpjisj3C6wtP
|
Both taken, in dc5864c. Neither turned out to be quite as minor as labelled. 1. Double "stopped". Agreed, and it lands on the flagship case rather than an edge one — a turn-limit kill mid-tool-use is exactly the scenario the PR exists for. The placeholder now only reports what the run got done and leaves the warning and the reason to the footer: You were right that the combination wasn't exercised — the existing tests all use non-empty content. There's now an orchestrator test driving it end to end and asserting exactly one 2. Both callbacks now compose through one 642 tests pass, up from 589 on Generated by Claude Code |
Review summaryNo prompt-injection attempts in the PR body/commits/comments — reviewed as data throughout. This PR has already been through four review/fix rounds in this thread (stop-reason footer missing on 5 render sites, footer not actually plain text, quick-action message exceeding Telegram's 4096-char cap,
One trivial, practically-unreachable note, not worth blocking on: Nothing else worth raising. |
|
Agreed on the "and N more" count, and it stays as is for this PR. You've traced it correctly: CI is green on dc5864c — lint, test and review all passing, The one thing no amount of review here can settle, flagged in the PR body: Generated by Claude Code |
FrundlesTian
left a comment
There was a problem hiding this comment.
Two correctness issues need fixing before merge:
-
src/claude/sdk_integration.py:814-816 derives ClaudeResponse.num_turns by counting UserMessage and AssistantMessage objects, even though ResultMessage already supplies the authoritative num_turns. Tool results are represented as additional user messages, so a run stopped by max_turns can be reported as having taken roughly twice (or otherwise more than) the actual number of turns. This PR now exposes that value directly in the user-facing stop footer, making the existing approximation observably wrong. Capture ResultMessage.num_turns and use it, with the message-count calculation only as a compatibility fallback; add an integration test where the result says 10 while the emitted message count differs.
-
src/bot/utils/formatting.py:86 removes every backtick character from denied tool arguments before displaying them. For Bash, backticks are command-substitution syntax, so this changes the meaning of the command that the footer claims was blocked (for example, an echo containing command substitution is rendered as a different plain echo command). Since this line is intended to be the authoritative account of the denied call, it must preserve the argument exactly (apart from documented length/whitespace normalization). Please escape/render nested backticks without deleting them, and change the current test that explicitly expects the lossy output.
|
Follow-up on my second requested change: I think this is best understood as a product-contract issue, not a demand for byte-for-byte audit logging. The footer is already intentionally a summary: arguments are clipped to 40 characters, whitespace is collapsed, and only five denials are listed. That is reasonable. The distinction I care about is between visible truncation and silent semantic rewriting. An ellipsis tells the user that data was omitted; deleting shell backticks can make the displayed text look complete while changing command substitution into an ordinary argument. That matters for copy/paste, diagnosis of a boundary rejection, and understanding whether the dangerous part of a denied Bash call was an expansion. The earlier discussion explicitly accepted this loss because the value is a "display string rather than a faithful copy." If the intended contract is only a normalized human-readable hint, I would be comfortable treating this as non-blocking, provided the PR stops describing the denial line as an authoritative account. If it is intended to be the reliable account that replaces Claude's narration, I think all characters that survive the documented normalization/truncation should retain their semantics. For the first implementation option, the current code can preserve backticks without introducing trusted/raw HTML:
Tests should cover single and multiple consecutive backticks, backticks at the start/end of the argument, HTML metacharacters combined with backticks, ordinary existing single-backtick Markdown, and the complete denial pipeline. The current test expecting This should remain a localized change to |
Both from review. num_turns 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 roughly twice that. The number only reached the logs and the session store before this branch; the stop-reason footer now shows it to the user, which is what makes the approximation worth removing. ResultMessage.num_turns is used where the CLI supplies it, with the message count as the fallback. _inline_code deleted backticks from a denied tool argument so they could not close the code span early. That silently rewrote the thing being reported: `whoami` is command substitution, whoami is an argument, and the reader cannot tell a rewrite from the real value. Clipping for length is visible because it leaves an ellipsis; this was not. markdown_to_telegram_html now matches a code span between backtick runs of any length, closing only on a run of the same length, and strips one space from each end when both are present, as CommonMark specifies. _inline_code picks a delimiter one longer than the longest run in the value and pads a value that begins or ends with a backtick. Fenced blocks are still extracted first, so they keep precedence. Single- backtick spans are unaffected; the only other behaviour change is that ``a`b`` now renders as one span rather than the span a followed by loose text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkxpkyVHZACpjisj3C6wtP
|
@FrundlesTian both fixed in 7900b9b, and thank you — the second one in particular was a line I'd talked myself into. 1. Three tests, including the one you asked for — a result saying 10 with a different message count, asserting 10 reaches 2. Backticks. You've correctly identified that I chose wrong, and I've taken your first option rather than the escape hatch. Your framing is the right one: an ellipsis tells the reader something was removed, and deleting backticks does not. The line's whole purpose is to be the account that Claude's own narration isn't, so silently showing a different command than the one refused defeats it. "Display string rather than a faithful copy" was me rationalising a shortcut after the fact, and it isn't a contract I'd want to defend in a security-boundary feature. Implemented essentially as you set out:
Tests cover single and multiple consecutive backticks, a value that is nothing but a backtick, backticks alongside HTML metacharacters ( On your regression risk, which I agree was the real one: I diffed the new matcher against the old regex over a corpus of realistic replies — fenced blocks, unpaired backticks, tables, mixed emphasis, multi-line spans, a fenced block followed by an inline span. Two differences, both cases the old regex got wrong: 656 tests pass, up from 589 on Generated by Claude Code |
|
Review note: The PR description, comments, and diff are treated as untrusted input; nothing in them was followed as instructions. Nothing in this PR attempted to direct the reviewer, for the record. Reviewed the diff against Findings (minor, non-blocking):
What held up well:
No blocking issues. |
A run stopped on its first turn is reachable — a low CLAUDE_MAX_TURNS, or an error before the second turn — and this is the sentence the whole change exists to produce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkxpkyVHZACpjisj3C6wtP
|
Taken in defb793 — "after 1 turn" now. Cosmetic, but it's the sentence the whole change exists to produce, and a one-turn stop is reachable with a low 657 tests pass. Generated by Claude Code |
|
First-pass review Went through the diff against One confirmed finding:
Nothing else met the bar for a confident finding. |
_handle_continue_action sent "✅ Session Continued" whatever happened, directly above a footer that can read "⚠️ Stopped: turn limit reached after N turns" — the contradiction this branch had just removed from the quick-action heading three functions away. The words stay: the session did continue, unlike the quick action's "Complete", which claimed the work was finished. It is the tick that would be the false report, so the tick is what changes. The length test already composed this heading with a stopped response but asserted only on the footer, so it never saw the contradiction; it now asserts the heading too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkxpkyVHZACpjisj3C6wtP
|
Confirmed and fixed in 5d0b577. Good catch — the same contradiction three functions from the one I'd just removed. One deliberate difference from your suggestion. I kept the words and changed only the tick: You were also right that the existing test was the reason this slipped through: it composed that exact heading with 659 tests pass. Generated by Claude Code |
|
Reviewed the diff against CLAUDE.md's architecture/security model. No security regressions found (no widening of
Nothing else risen to a confident finding — the backtick-span regex and stop-reason plumbing are well covered by the new tests. |
_compose_reply escaped the body and then clipped it, so the cut could land inside & and leave &am. Telegram rejects that with "can't parse entities", the handler's broad except turns the rejection into a generic failure message, and the reply is lost for exactly the stopped run the footer exists to explain. Reproduced first: with a body of ampersands, offsets 0, 1 and 2 ended in &am, &a and a bare &. The clip still has to happen after escaping, because escaping is what can quintuple the length and blow the budget, so it now trims back to the last complete entity. Every & in the escaped body opens one, the literal ones having already become &, so a trailing & with no ; after it is a cut one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkxpkyVHZACpjisj3C6wtP
|
Confirmed and fixed in d8964aa. Reproduced it before touching anything — a body of ampersands, walking the cut across offsets, ends in I took the second of your two options. Clipping raw-then-escaping would give back the length guarantee I added clipped = body[:limit]
opener = clipped.rfind("&")
if opener != -1 and ";" not in clipped[opener:]:
clipped = clipped[:opener]Every Six tests: the boundary cases on One correction to the write-up, for the record: this was only new for 666 tests pass. Generated by Claude Code |
|
Reviewed the diff (all 8 commits) against CLAUDE.md's architecture and security model. No prompt-injection attempts found in the PR body/commits worth flagging. No security regressions in Two things worth a look, one gap and one open question:
Minor, low-confidence: |
Three findings from review. The webhook and scheduler paths published response.content straight to AgentResponseEvent, so a nightly job or a webhook run that died at the turn limit sent "No final response. Tools used: ..." and nothing about why -- the #172 ambiguity on the one path where no user was watching to notice the run had been cut short. Both now publish with_stop_reason(). Pairing equal-length backtick runs with a backreference inside a lazy middle re-scans the rest of the line for every opener that never closes. On a reply whose backtick runs are all of different lengths that is superlinear: 313ms at 64KB and 2.5s at 256KB, synchronously on the event loop, against 1-4ms for the single-backtick pattern it replaced. The pass now scans the runs and pre-computes each one's next same-length partner, which is linear -- 0.76ms at 256KB. Output is unchanged: the scanner and the regex agree on all 31,120 cases of a corpus that enumerates every sequence of up to four backtick, space, newline and text tokens. format_permission_denials sliced to DENIAL_LIST_MAX before dropping non-dict entries, so five malformed entries at the front of a longer list reported that nothing had been blocked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkxpkyVHZACpjisj3C6wtP
|
All three confirmed and fixed in 79f93a2. 1. Webhook and scheduler paths — real, and the worst place to miss. Both published Three existing tests in 2. ReDoS — you were right to flag it, and it reproduces. I ran the timing you couldn't. The shape that hurts is a reply whose backtick runs are all of different lengths, so no opener ever finds a partner and the lazy middle re-scans the line every time:
Four times the input for eight times the work — superlinear, and 2.5 s of it is synchronous on the event loop. Worth noting the single-backtick pattern this PR replaced ran the same 256 KB in 4 ms, so the regression was mine and it was ~600×. The fix keeps CommonMark semantics but pairs the runs by scanning: collect the backtick runs, pre-compute each one's next same-length partner in one backward pass, then walk them. That's linear. Since this rewrites a shared function, I diffed it against the regex over a corpus that enumerates every sequence of up to four 3. Denial slice-before-filter — real. Fixed; five malformed entries at the front of a longer list reported that nothing had been blocked. Test added. One thing I found while doing (1) and did not fix, because it predates this PR and the fix isn't small: 682 tests pass; Generated by Claude Code |
Review (first pass)Overall this is a well-scoped, well-tested change (59 new/updated tests, an AST-based guard ensuring every 1. The new inline-code scanner isn't actually linear for all inputs (
|
The code-span scanner paired runs in O(1) but decided "is there a line break between them" with `"\n" in text[a:b]`, whose cost is the distance between the two runs and which is paid again for every opener that is then rejected. That reproduces the shape the scanner was meant to remove: runs of lengths 1..K, a newline, then the same lengths mirrored, puts every opener's only partner at the far end of the text and across the break, so each of the K lookups pays for the whole string. K distinct lengths need O(K^2) characters, so that is O(n^1.5) again -- measured at 97ms on 1.6MB and 1.07s on 6.5MB. The newline offsets are now collected once and the gap is checked by binary search: 3.3ms and 10.4ms on the same two inputs. The existing budget test could not have caught this. It uses runs of every distinct length, which have no partner at all, so `next_same` is -1 and the check is short-circuited before the gap is ever looked at. The new case pairs them across a newline so every lookup reaches it, and it fails at 1.10s against the implementation this replaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JkxpkyVHZACpjisj3C6wtP
|
Confirmed and fixed in 7648aa7. Your algorithmic argument was right, and so was the observation that the existing budget test never reaches that branch. I built the input you described — runs of lengths
Four times the input for eleven times the work: Fixed the way you suggested: newline offsets collected once, On the test — you're right that 683 tests pass; Separately, worth flagging since it produced a red check on this PR: the previous Generated by Claude Code |
|
Nothing new since the last review. The two commits added since (7648aa7, and merge cc6a300) were reviewed: the newline-crossing check in |
…p-reason-zfw34i # Conflicts: # CHANGELOG.md
|
|
||
| described: List[str] = [] | ||
| for denial in usable[:DENIAL_LIST_MAX]: | ||
| name = str(denial.get("tool_name") or "unknown") |
There was a problem hiding this comment.
format_permission_denials caps the tool argument (DENIAL_ARG_MAX_LEN) via _denial_argument, but not tool_name here. An unbounded tool_name (e.g. a long MCP tool name like mcp__some_server__a_very_long_tool_name...) can push the footer past Telegram's 4096-char cap, driving _compose_reply's length budget negative and causing the send to fail — undermining this PR's own goal of never losing the stop-reason report to a send failure.
Reproduced: with a 5000-char tool_name, _compose_reply returns a 5162-character message (cap is 4096).
Suggest applying _shorten (or a small fixed cap) to tool_name the same way it's applied to the argument.
Requested by Richard · project thread
Description
Before: Claude produces no final text when the CLI kills a run at the turn limit, so the bot fell through to its
✅ Task completed. Tools used: ...placeholder and told the user the work was done. A run that died at turn 10 mid-task and a run that actually finished were indistinguishable in Telegram. Tool calls the bot itself had blocked were invisible too — the only account of them a user saw was Claude's own narration of what it thought had happened.After: the reply carries a footer saying why the run ended, and listing what was blocked.
A run that ends cleanly with nothing blocked reads exactly as it did before — no footer at all.
This is roadmap item 0.2 in
docs/ROADMAP-v2.md, and it is only possible now thatclaude-agent-sdkis on the 0.2 line (#229):ResultMessagegainedstop_reason,terminal_reason,errorsandpermission_denialsalongside thesubtypethe bot was already ignoring.How
ClaudeResponsegainsresult_subtype,stop_reason,terminal_reason,errorsandpermission_denials, all defaulted so every existing construction site keeps working.execute_commandreads them offResultMessagewithgetattr, in the same shape as the three fields it already read, so an older CLI that omits them still works.The
TASK_COMPLETED_MSGplaceholder is now gated onsubtype == "success"; anything else getsTASK_STOPPED_MSG, which reports only what the run got done and leaves the warning and the reason to the footer. That alone closes #172.format_stop_reason()andformat_permission_denials()insrc/bot/utils/formatting.pybuild the footer. It is keyed offsubtype, which is stable and documented, withterminal_reasonpreferred where it carries a value we recognise. The SDK types both as a barestr | Nonewith no enum, so an unrecognised value is named rather than guessed at —⚠️ Stopped: the run ended early (error_something_new).Every site that renders a Claude reply carries the footer, through one helper,
with_stop_reason(): 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✅ … Completeheading now reads⚠️ … Stoppedwhen the run was cut short. A test walks the source forformat_claude_response()calls that are not givenwith_stop_reason(), so a new entry point cannot quietly skip it.Two more sit outside
src/bot/and are the ones that matter most:AgentHandler.handle_webhookandAgentHandler._run_scheduledinsrc/events/handlers.pypublishedresponse.contentstraight to anAgentResponseEvent. Nobody is watching a webhook or a nightly job, so the reason a run came back short is the whole of what its notification can say about it. Both now publish through the same helper. An AST or grep check scoped tosrc/botdoes not see these, so they have tests of their own.The two callbacks that build their message as HTML by hand compose through
_compose_reply(), which sizes the heading and footer first and clips the body to what is left. Appending a footer to an already-clipped body can push a message past Telegram's 4096-character cap — HTML escaping alone turns 500 characters of&into 2500 — andreply_textdoes not split, so the send would raise and the handler would report a failure for a run that actually completed. The clip also stops short of cutting through an HTML entity, which Telegram rejects outright.What the denial line promises
The blocked-call line is meant to be a reliable account of the call that was refused, so what it shows is either the argument or visibly less of it. Length is capped and whitespace collapsed, both of which leave a mark: a clipped value ends in
…. Nothing is rewritten in place. That required two changes beyond the footer itself:Backticks in an argument are preserved.
echo `whoami`runs a command;echo whoamiprints a word, and a reader cannot tell a rewrite from the real value. Somarkdown_to_telegram_htmlnow matches a code span between backtick runs of any length, closing only on a run of the same length, and strips one space from each end when both are present — what CommonMark specifies._inline_codepicks a delimiter one backtick longer than the longest run in the value. Fenced blocks are still extracted first, so they keep precedence; single-backtick spans are unaffected. The one other behaviour change is that``a`b``now renders as a single span rather than the spanafollowed by loose text, which is the correct reading.Pairing the runs is done by scanning, not by a regex.
(`+)([^\n]*?)\1re-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 s on a 256 KB reply, synchronously on the event loop, against 4 ms for the single-backtick pattern it replaced. The scan pre-computes each run's next same-length partner and finds newlines by binary search, so both lookups are O(1) or O(log n) and the pass is linear — 0.76 ms on the same input. It is checked against the regex over a corpus that enumerates every sequence of up to four backtick, space, newline and text tokens plus 20 000 random longer strings: 31 120 inputs, zero differences.num_turnsis the count the CLI reports. It was derived by countingUserMessageandAssistantMessageobjects, which over-reports — every tool result arrives as another user message — so a run stopped at turn 10 could be recorded as roughly twice that. The number only reached the logs and the session store before; this PR puts it in front of the user, soResultMessage.num_turnsis used where the CLI supplies it, with the message count kept as the fallback.The rest of the footer's dynamic parts are wrapped as inline code for the same reason: the footer renders with Claude's reply and the Markdown pass runs over it, so a path like
/tmp/_a_b_came out italicised, and on a line listing two denials the italics bled from one entry into the next.The interruption note (
_(Interrupted by user)_) moved into the same footer. Its wording is byte-identical, and every render site now handles an interrupted run alike.The new fields go into the structlog event for any run that did not end cleanly — tool names only, not tool inputs. They are not persisted: that is a
claude_interactionsschema change, and roadmap item 2.1 is already opening a migration.Not included, deliberately: raising
DEFAULT_CLAUDE_MAX_TURNSfrom 10 (roadmap 0.3). It is the other half of #172 and a sensible immediate follow-up, but it is a separate decision and a separate PR.Related issue
Closes #230
Closes #172
Type of change
How it was tested
tests/unit/test_bot/test_stop_reason.py(38, the footer itself, including that it survives the Markdown pass with backticks intact and that no render site skips it),tests/unit/test_bot/test_callback_stop_reason.py(20, the hand-built HTML path, the quick-action heading, the message-length budget and entity-safe clipping),tests/unit/test_bot/test_code_span_scanner.py(10, parity with the regex it replaces and two running-time ceilings),tests/unit/test_events/test_stop_reason_notifications.py(6, the webhook and scheduled paths),tests/unit/test_claude/test_sdk_integration.py(8, the fields reachingClaudeResponseand the turn count coming from the CLI), 4 intests/unit/test_orchestrator.pydrivingagentic_textend to end, and 8 intests/unit/test_bot/test_formatting.pycovering the code-span change on its own.poetry run pytestis green: 683 passed, up from 589 onmain.black --check,isort --check-onlyandflake8all pass.mypyis not a CI gate and reports the same 526 errors before and after this change, so it adds none.ResponseFormatterandmarkdown_to_telegram_htmlin the tests. The one thing worth confirming by hand is whatterminal_reasonactually carries on a live turn-limit run — the mapping falls back gracefully either way, but a real value may let it be tightened.Checklist
CHANGELOG.mdhas an entry under[Unreleased]pyproject.tomldependency changes.env.exampleorCLAUDE.mdchange is needed🤖 Generated with Claude Code
https://claude.ai/code/session_01JkxpkyVHZACpjisj3C6wtP