feat(messaging): add end_turn tool + flow enforcement - #108
Merged
Conversation
Fixes the "scratchpad bug" β assistant prose silently dropped when no delivery tool was called. The user got no message, frontend logged "Suppressed fallback text", and the model had no way to know. Two structural changes, no regex on model output: 1. New `end_turn` tool (telegram + teams). Schema-typed with optional text / reply_to / buttons. Functionally a thin wrapper over send_message (and send_message_with_buttons), but the explicit name gives the model a single unambiguous "this is my final reply" call to make at end-of-turn. Empty/omitted text = silent end with no bridge call. The model is taught (prompts/telegram.md) that this is the canonical happy path. 2. Trailing-text fallback in the SDK handler. After the SDK loop ends, if the last assistant message had text after all tool_use blocks (or text-only with no tools) AND that text wasn't already routed through end_turn / send(type="text"), the handler fires onTextBlock with the trailing text so it reaches the channel. Dedup uses normalized substring comparison (trim, lowercase, collapse whitespace, strip emoji, MIN_DEDUP_LENGTH=10) against text args captured from delivery-tool calls during the turn. The two paths are belt-and-braces: - Happy path: model calls end_turn β message delivered, no fallback fires (trailingText is empty when end_turn was the only content). - Forgetful path: model writes prose without calling end_turn β fallback fires, prose delivered as a regular message. Silent turns become structurally impossible. - Both paths: model writes prose AND calls end_turn with similar text β dedup catches it, only one delivery. The "Suppressed fallback text" log in src/frontend/telegram/handlers.ts and src/frontend/teams/index.ts is removed entirely. Teams gains an onTextBlock callback so it benefits from the same fallback (Teams previously didn't have one β assistant text-before-tool-use was dropped too). Tests: 17 new unit tests for the dedup helpers and the end_turn tool definition. Existing "Suppressed fallback" log assertion repurposed to verify the log no longer fires. All 1664 tests pass, typecheck clean. Prompt: prompts/telegram.md rewritten β introduces end_turn as the documented response flow, explains the fallback as a safety net, and clarifies when to reach for `send` (rich content: photos, polls, voice, scheduled, multi-message, multi-target). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Dylan's clarification: there should be no fallback delivery at all. The output stream is private scratchpad by design, and silent ends are valid intentional close states (model only reacted, or had nothing to do). The previous fallback path quietly auto-delivered trailing prose when the model forgot to call end_turn / send β that hides forgetful turns behind a safety net and blurs the silence-vs-forgetful boundary. Now: - end_turn(text=...) β message delivered, turn closes (canonical happy path) - end_turn() with no args β explicit silent close - No tool call at all β also valid silent close (model genuinely had nothing) - send(...) β mid-turn rich content, doesn't close the turn - react(...) β emoji reaction, often pairs with end_turn() to close cleanly When trailing prose is written WITHOUT being delivered, the handler: - increments a `scratchpad.trailing_text_dropped` counter - logs a diagnostic line so missed end_turn calls show up in metrics The dedup helpers stay β they're still useful for the cross-tool case (model calls both end_turn and send with similar text mid-turn). Tests renamed to drop the "fallback" framing. Prompt rewritten: "Response flow β IMPORTANT" section explicitly states "There is no fallback. Prose written without an end_turn / send call is scratchpad β dropped." Lists end_turn / send / react as the only delivery paths and `end_turn()` (no args) as the explicit silent close. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the model produces trailing prose without calling end_turn / send, re-prompt it ONCE in the same session with a synthetic user message containing the contract reminder. Model sees its broken turn in history + the [FLOW VIOLATION] reminder, gets a fresh turn to redo the response correctly. Reuses the existing `_retried` flag (same single-retry budget shared with session_expired and context_length retries β they're mutually exclusive code paths so no composition issue). If the model violates again on the retry, accept the silent drop, log loudly, increment counter β better than infinite loops. New counter: `scratchpad.flow_violation_retried` shows enforcement frequency in metrics. Existing `scratchpad.trailing_text_dropped` now fires on BOTH the initial violation AND the post-retry violation if it persists. Costs ~2x tokens for that exchange (broken turn + retry), so the prompt now nudges the model to "just call end_turn the first time." Once the model's behavior converges, retries should be rare. Prompt update: prompts/telegram.md gains a "Flow enforcement" note describing the retry behavior so the model knows what happens when it forgets. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Block comment + tool description still claimed "prose is delivered as a fallback" β that was true in the original commit (6fbcff0) but commit dc0302f ripped the fallback out and replaced it with the flow-violation re-prompt. Documentation drifted; the model was reading false promises about its own runtime behavior. Both spots now describe the actual contract: - output stream is private scratchpad - handler re-prompts ONCE on trailing prose without delivery - persistent violation drops silently + increments `scratchpad.trailing_text_dropped` No code change. Caught while end-to-end testing the live deployment of this branch β the schema description fed back to the model included the stale fallback note. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The end_turn tool was a thin wrapper over send(type="text") with no mechanism
to stop the SDK loop. After calling end_turn, the model could keep generating
thinking, more tool calls, or trailing prose β and any prose afterwards trips
the flow-violation re-prompt path, producing a phantom "second turn" the user
sees as the bot doing something after it said it was done.
Backend-agnostic abstraction:
- Add `endsTurn?: boolean` to ToolDefinition (declarative, lives next to the
tool). Set on `end_turn` in messaging.ts.
- Export `isTurnTerminator(name)` from core/tools/index.ts. Detection is
shared core code β no SDK symbols leak in.
- Each backend handler is responsible for its own loop abort. Claude SDK
uses Query.interrupt(); other backends manage their own.
Claude SDK wiring:
- StreamState gains `turnTerminated: boolean`.
- When the assistant-message processor sees a tool with endsTurn: true, it
flips the flag and the handler awaits qi.interrupt() to stop the loop
cleanly. Interrupt failures are non-fatal β the natural end-of-stream
path will still run.
- The flow-violation re-prompt is gated on !turnTerminated. If the model
explicitly declared "I'm done" we respect it, even if some prose slipped
in earlier in the same assistant message β re-prompting in that case
would loop endlessly with a model that pairs prose with end_turn.
Tests: 1670 (was 1664) β 6 new covering endsTurn declaration, isTurnTerminator
behavior, terminator inventory invariant ("only end_turn for now"), and
turnTerminated initial state.
Contributor
There was a problem hiding this comment.
Pull request overview
Adds an explicit βturn terminatorβ messaging tool and supporting backend/frontend wiring to improve how final replies are delivered (and how βtrailing proseβ is handled) in Telegram/Teams flows.
Changes:
- Introduces a new
end_turntool (endsTurn: true) and sharedisTurnTerminator()detection. - Adds Claude SDK stream state for trailing text + delivered-text normalization/dedup helpers, and terminator-driven
qi.interrupt()behavior. - Updates Telegram/Teams frontends and tests to remove the legacy βSuppressed fallback textβ logging behavior; updates Telegram prompt guidance around
end_turn.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/frontend/telegram/handlers.ts | Removes legacy suppressed-fallback logging branch; updates inline commentary. |
| src/frontend/teams/index.ts | Wires onTextBlock to post assistant text blocks into Teams; removes suppressed-fallback behavior. |
| src/core/tools/types.ts | Adds endsTurn?: boolean to tool definitions. |
| src/core/tools/messaging.ts | Adds the end_turn tool and marks it as endsTurn: true. |
| src/core/tools/index.ts | Adds isTurnTerminator() based on endsTurn. |
| src/backend/claude-sdk/stream.ts | Extends stream state and adds normalization/dedup helpers. |
| src/backend/claude-sdk/handler.ts | Tracks terminators, captures delivered text norms, interrupts the SDK loop, and adds trailing-prose flow-violation retry logic. |
| src/tests/handlers.test.ts | Updates expectations to ensure the βSuppressed fallbackβ log no longer appears. |
| src/tests/end-turn.test.ts | Adds unit tests for end_turn, dedup helpers, and turn-terminator declaration. |
| prompts/telegram.md | Updates Telegram prompt instructions to prefer end_turn and documents flow enforcement behavior. |
π‘ Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+784
to
+787
| // No fallback delivery β turns that don't call `end_turn` / `send` are | ||
| // intentional silent ends. Trailing prose written without a tool call is | ||
| // scratchpad and dropped (the SDK handler logs a `scratchpad.trailing_ | ||
| // text_dropped` metric so missed end_turn calls show up in counters). |
Comment on lines
+359
to
+362
| // Deliver assistant text (progress text before tool calls AND | ||
| // the end-of-turn trailing-text fallback) to the Teams chat. | ||
| // Without this, prose-only assistant turns would be silently | ||
| // dropped β same scratchpad bug Telegram hit. |
Comment on lines
783
to
+787
|
|
||
| if ( | ||
| result.bridgeMessageCount === 0 && | ||
| !stream.sentTextBlock && | ||
| result.text?.trim() | ||
| ) { | ||
| log( | ||
| "bot", | ||
| `Suppressed fallback text (${result.text.length} chars) β no send tool used`, | ||
| ); | ||
| } | ||
| // No fallback delivery β turns that don't call `end_turn` / `send` are | ||
| // intentional silent ends. Trailing prose written without a tool call is | ||
| // scratchpad and dropped (the SDK handler logs a `scratchpad.trailing_ | ||
| // text_dropped` metric so missed end_turn calls show up in counters). |
Comment on lines
+46
to
+50
| reply_to: idSchema | ||
| .optional() | ||
| .describe("Message ID to reply to (typically the user's [msg_id:N])"), | ||
| buttons: z | ||
| .array( |
Comment on lines
+67
to
+68
| // invoked (via deliveredTextNorms staying empty), and trailing-text | ||
| // fallback won't fire because there was no trailing prose. |
Comment on lines
+99
to
+101
| // so the end-of-turn trailing-text fallback can dedupe against content | ||
| // already delivered. Without this, a model that writes prose AND calls a | ||
| // delivery tool with similar text would surface twice in the chat. |
Comment on lines
+140
to
+142
| // Track the trailing text from this assistant message. Multiple | ||
| // assistant messages can fire per turn (one per tool-use round-trip); | ||
| // only the LAST one's trailingText is the user-facing final reply. |
Comment on lines
+291
to
+294
| // ONCE with a synthetic system message in the same session: it sees its | ||
| // broken turn in history + a reminder of the contract, and gets a fresh | ||
| // turn to deliver via end_turn. If it violates again on the retry, we | ||
| // give up loudly and accept the silent drop. |
Comment on lines
+283
to
+287
| // ββ Trailing-prose contract + flow-violation retry ββββββββββββββββββββββ | ||
| // The output stream is private scratchpad by design. Final replies must go | ||
| // through `end_turn` (canonical) or `send` (mid-turn rich content). When a | ||
| // turn ends with no tool call AND no trailing prose, that's valid silent | ||
| // close (model only reacted, or had nothing to do). When the model wrote |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
5 tasks
dylanneve1
pushed a commit
that referenced
this pull request
May 8, 2026
Once `isTurnTerminator` correctly matches MCP-prefixed names, the `qi.interrupt()` call PR #108 added actually fires. It then races with the SDK's in-flight MCP tool dispatches in the same assistant message: - `end_turn` is itself an MCP tool β the SDK is mid-dispatch when we observe the assistant message and call interrupt - the model frequently emits sibling tool_use blocks in the same message - interrupt cancels their AbortController mid-flight - that surfaces as `MCP error -32001: AbortError` in the SDK result - the cron job / chat turn / DM bombs with "Something went wrong" Reproduced on the live deployment after applying the v1 fix: - "What's up" DM (msg 2278) β AbortError, no reply - OpenRouter Model Monitor cron at 20:01 UTC β AbortError, job failed - two earlier group turns at 16:19 / 16:20 β AbortError The natural-close path is fine: the SDK does one more API call after end_turn returns, the model produces a stop turn quickly (~2-3s typing lag), then the iterator exits cleanly. We accept the typing lag in exchange for not breaking turns. `state.turnTerminated` is still tracked so the flow-violation re-prompt below can skip its retry when the model explicitly ended its turn. The prefix-stripping fix stays β it correctly fixes: - daily-log capture (zero `[Talon]` entries since PR #108 merged Apr 23) - cross-tool dedup in `captureDeliveredText` - the `state.turnTerminated` flag itself, gating flow-violation retry
claudiusthebot
added a commit
that referenced
this pull request
May 9, 2026
PR #108 added `qi.interrupt()` after observing a turn-terminator tool so the SDK loop aborts instead of doing a wasted follow-up API call to "wrap up" after `end_turn`. The check sat in `isTurnTerminator(toolName)` against a set built from `ALL_TOOLS.filter(t => t.endsTurn).map(t => t.name)` β so the set contains `"end_turn"`. Tools served through MCP arrive at the SDK with a server prefix: `mcp__telegram-tools__end_turn`. The set check is strict equality, so `isTurnTerminator("mcp__telegram-tools__end_turn")` returns false. The entire interrupt path was dead code in production: `state.turnTerminated` stayed false, `qi.interrupt()` never fired, and the SDK ran its full natural cycle including the unnecessary second API call. Symptom: the typing indicator lingered ~3s after the assistant message landed. Diagnostic logging (now removed) confirmed: end_turn observed: t=174109 typing cleared: t=177232 (+3123ms) with `numApiCalls=2` in the SDK result β the wasted wrap-up call was exactly the 3s gap. `captureDeliveredText` had the same bug: it compared `toolName === "end_turn"` and `toolName === "send"` literally, so cross-tool dedup also never matched on the MCP-routed names. Fix: add a `stripMcpPrefix()` helper that strips `mcp__<server>__` from a tool name (non-greedy `.+?` match β the first `__` after `mcp__` is the server-name boundary in MCP's naming scheme). `isTurnTerminator` now checks both the raw name and the stripped form. `captureDeliveredText` normalizes via `stripMcpPrefix` before its equality checks. Tests: - isTurnTerminator passes on `mcp__telegram-tools__end_turn`, `mcp__teams-tools__end_turn`, `mcp__some-server-name__end_turn` - isTurnTerminator still rejects `mcp__telegram-tools__send`, `mcp__telegram-tools__react` - stripMcpPrefix strips correctly and is a no-op when no prefix matches - 1674/1675 tests pass (1 pre-existing flake on main: package.functional.test.ts)
claudiusthebot
added a commit
that referenced
this pull request
May 9, 2026
Once `isTurnTerminator` correctly matches MCP-prefixed names, the `qi.interrupt()` call PR #108 added actually fires. It then races with the SDK's in-flight MCP tool dispatches in the same assistant message: - `end_turn` is itself an MCP tool β the SDK is mid-dispatch when we observe the assistant message and call interrupt - the model frequently emits sibling tool_use blocks in the same message - interrupt cancels their AbortController mid-flight - that surfaces as `MCP error -32001: AbortError` in the SDK result - the cron job / chat turn / DM bombs with "Something went wrong" Reproduced on the live deployment after applying the v1 fix: - "What's up" DM (msg 2278) β AbortError, no reply - OpenRouter Model Monitor cron at 20:01 UTC β AbortError, job failed - two earlier group turns at 16:19 / 16:20 β AbortError The natural-close path is fine: the SDK does one more API call after end_turn returns, the model produces a stop turn quickly (~2-3s typing lag), then the iterator exits cleanly. We accept the typing lag in exchange for not breaking turns. `state.turnTerminated` is still tracked so the flow-violation re-prompt below can skip its retry when the model explicitly ended its turn. The prefix-stripping fix stays β it correctly fixes: - daily-log capture (zero `[Talon]` entries since PR #108 merged Apr 23) - cross-tool dedup in `captureDeliveredText` - the `state.turnTerminated` flag itself, gating flow-violation retry
claudiusthebot
added a commit
that referenced
this pull request
May 9, 2026
β¦ostToolBatch hook) (#122) * fix(claude-sdk): match MCP-prefixed names in turn-terminator detection PR #108 added `qi.interrupt()` after observing a turn-terminator tool so the SDK loop aborts instead of doing a wasted follow-up API call to "wrap up" after `end_turn`. The check sat in `isTurnTerminator(toolName)` against a set built from `ALL_TOOLS.filter(t => t.endsTurn).map(t => t.name)` β so the set contains `"end_turn"`. Tools served through MCP arrive at the SDK with a server prefix: `mcp__telegram-tools__end_turn`. The set check is strict equality, so `isTurnTerminator("mcp__telegram-tools__end_turn")` returns false. The entire interrupt path was dead code in production: `state.turnTerminated` stayed false, `qi.interrupt()` never fired, and the SDK ran its full natural cycle including the unnecessary second API call. Symptom: the typing indicator lingered ~3s after the assistant message landed. Diagnostic logging (now removed) confirmed: end_turn observed: t=174109 typing cleared: t=177232 (+3123ms) with `numApiCalls=2` in the SDK result β the wasted wrap-up call was exactly the 3s gap. `captureDeliveredText` had the same bug: it compared `toolName === "end_turn"` and `toolName === "send"` literally, so cross-tool dedup also never matched on the MCP-routed names. Fix: add a `stripMcpPrefix()` helper that strips `mcp__<server>__` from a tool name (non-greedy `.+?` match β the first `__` after `mcp__` is the server-name boundary in MCP's naming scheme). `isTurnTerminator` now checks both the raw name and the stripped form. `captureDeliveredText` normalizes via `stripMcpPrefix` before its equality checks. Tests: - isTurnTerminator passes on `mcp__telegram-tools__end_turn`, `mcp__teams-tools__end_turn`, `mcp__some-server-name__end_turn` - isTurnTerminator still rejects `mcp__telegram-tools__send`, `mcp__telegram-tools__react` - stripMcpPrefix strips correctly and is a no-op when no prefix matches - 1674/1675 tests pass (1 pre-existing flake on main: package.functional.test.ts) * fix(claude-sdk): extend MCP prefix strip to handlers + integration tests Three additions on top of the core fix in `isTurnTerminator` / `captureDeliveredText`: 1. **`telegram/handlers.ts` β `processAndReply` onToolUse**: apply `stripMcpPrefix` so `end_turn` (arriving as `mcp__telegram-tools__end_turn` in production) is captured in the daily log. Previously only bare `send` was captured, meaning `end_turn` deliveries were silently dropped from the daily log. 2. **`end-turn.test.ts` β production wire-shape contract**: new describe block that exercises `isTurnTerminator` + `processAssistantMessage` with the actual SDK-emitted MCP-prefixed names (`mcp__telegram-tools__end_turn`, etc.). These are the tests that would have caught the original bug β they fail against the pre-fix code, pass against the fixed code. 3. **`handlers.test.ts` β onToolUse test matrix**: replaces the single bare-name `send` test with a 6-case matrix: MCP-prefixed send, MCP-prefixed end_turn, bare send, bare end_turn (defensive), non-text send (should NOT capture), react tool (should NOT capture). All six pass. typecheck + prettier + vitest run all clean. * fix(claude-sdk): drop qi.interrupt() β races with in-flight MCP tools Once `isTurnTerminator` correctly matches MCP-prefixed names, the `qi.interrupt()` call PR #108 added actually fires. It then races with the SDK's in-flight MCP tool dispatches in the same assistant message: - `end_turn` is itself an MCP tool β the SDK is mid-dispatch when we observe the assistant message and call interrupt - the model frequently emits sibling tool_use blocks in the same message - interrupt cancels their AbortController mid-flight - that surfaces as `MCP error -32001: AbortError` in the SDK result - the cron job / chat turn / DM bombs with "Something went wrong" Reproduced on the live deployment after applying the v1 fix: - "What's up" DM (msg 2278) β AbortError, no reply - OpenRouter Model Monitor cron at 20:01 UTC β AbortError, job failed - two earlier group turns at 16:19 / 16:20 β AbortError The natural-close path is fine: the SDK does one more API call after end_turn returns, the model produces a stop turn quickly (~2-3s typing lag), then the iterator exits cleanly. We accept the typing lag in exchange for not breaking turns. `state.turnTerminated` is still tracked so the flow-violation re-prompt below can skip its retry when the model explicitly ended its turn. The prefix-stripping fix stays β it correctly fixes: - daily-log capture (zero `[Talon]` entries since PR #108 merged Apr 23) - cross-tool dedup in `captureDeliveredText` - the `state.turnTerminated` flag itself, gating flow-violation retry * fix(claude-sdk): terminate query loop on end_turn via PostToolBatch hook When `end_turn` (or any turn-terminator tool) resolved, the SDK still made one more API call before exiting β the model had nothing to say but generated a stop_turn after ~2-3s of typing lag, often producing trailing prose that the handler then suppressed at the delivery layer. Real tokens spent on output that nobody sees. Previous attempt (`qi.interrupt()`, removed in d5ce30f) raced with in-flight sibling MCP tools in the same assistant message β interrupting their AbortControllers surfaced as `MCP error -32001: AbortError`. Fix: register a PostToolBatch hook in the SDK options. PostToolBatch fires exactly once after every tool call in the batch has resolved, so there are no in-flight siblings to race with. When any tool in the batch is a turn-terminator (matched via the existing `isTurnTerminator` helper which strips MCP prefixes), the hook returns `{ continue: false, stopReason: ... }`. The SDK exits the iterator cleanly with `TerminalReason: 'hook_stopped'` β no follow-up round-trip, no trailing prose generation, no race. Also adds 6 unit tests covering hook registration, MCP-prefixed and bare end_turn detection, no-terminator pass-through, empty batch, and defensive handling of unexpected hook event types. Refs PR #122. The MCP-prefix-strip in `isTurnTerminator` (this branch's ff08fe8) is the prerequisite that makes the hook detection work for prefixed tool names like `mcp__telegram-tools__end_turn`. * chore(claude-sdk): log when PostToolBatch hook terminates SDK loop Adds an info-level log line when the turn-terminator hook fires, so we have observable evidence the loop short-circuit is happening (e.g. "PostToolBatch: terminating SDK loop on mcp__telegram-tools__end_turn"). Helpful when verifying the fix in production traces vs the previous behavior (silent ~2-3s wrap-up call).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an explicit
end_turntool that lets the model cleanly close a turn with a final reply β and makes it actually work: callingend_turnnow interrupts the SDK loop viaQuery.interrupt(), eliminating the phantom-second-turn UX bug where the model could keep generating after saying it was done.Architecture discussed in chat (#103834β#103836). Iterated across 6 Dylan pushes after the initial PR.
What's in this PR (current head
a9a1303e)New tool:
end_turn(telegram + teams)text,reply_to,buttonsβ routes through the samesend_message/send_message_with_buttonsbridges assend(type="text")end_turn()with no args = explicit silent close (no bridge call, turn closes cleanly)end_turn(text="hi")= canonical happy path β message delivered, SDK loop interruptedTurn actually terminates (
a9a1303e)endsTurn?: booleandeclaration added toToolDefinitionβ lives next to the tool, not scattered across handler logicisTurnTerminator(name)exported fromcore/tools/index.tsβ shared core code, no SDK symbols leaking inStreamStategainsturnTerminated: boolean. When the assistant-message processor sees a tool withendsTurn: true, it flips the flag and callsQuery.interrupt()β SDK loop stops cleanly. Interrupt failures are non-fatal.end_turnno longer just describes intent, it enforces it.Flow enforcement (
915776a) β scratchpad stays privateWhen the model produces trailing prose WITHOUT calling
end_turnorsend, the handler re-prompts the model ONCE with a synthetic[FLOW VIOLATION]reminder. If it violates again on retry β silent drop, log loudly, increment counter. No infinite loops. Scratchpad remains private by contract.turnTerminated === true(model paired prose withend_turnβ they explicitly declared done, don't loop)_retriedflag (shared with session_expired/context_length retries β mutually exclusive paths)scratchpad.trailing_text_dropped(fires on initial + post-retry violation) +scratchpad.flow_violation_retriedPrompt (
prompts/telegram.md)"Flow enforcement" section added β model knows what happens when it forgets
end_turn.Behavior
end_turn(text="hi")end_turn()send(type="text", text="hi")end_turn(text="hi")+ trailing proseTests
a9a1303e:endsTurndeclaration onend_turn,isTurnTerminatorbehaviour, terminator inventory invariant ("only end_turn for now"),turnTerminatedinitial statea9a1303e(12 successes, 1 skipped Auto-Merge)Commit history
end_turntool + trailing-text fallback (later reversed)8763f1cdc0302fbd158f63915776a7d6198dend_turndescription after fallback removala9a1303eπ€ Generated with Claude Code