Skip to content

feat(messaging): add end_turn tool + flow enforcement - #108

Merged
dylanneve1 merged 8 commits into
mainfrom
feat/end-turn-tool
May 8, 2026
Merged

feat(messaging): add end_turn tool + flow enforcement#108
dylanneve1 merged 8 commits into
mainfrom
feat/end-turn-tool

Conversation

@claudiusthebot

@claudiusthebot claudiusthebot commented May 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds an explicit end_turn tool that lets the model cleanly close a turn with a final reply β€” and makes it actually work: calling end_turn now interrupts the SDK loop via Query.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)

  • Optional text, reply_to, buttons β€” routes through the same send_message / send_message_with_buttons bridges as send(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 interrupted

Turn actually terminates (a9a1303e)

  • endsTurn?: boolean declaration added to ToolDefinition β€” lives next to the tool, not scattered across handler logic
  • isTurnTerminator(name) exported from core/tools/index.ts β€” shared core code, no SDK symbols leaking in
  • Claude SDK wiring: StreamState gains turnTerminated: boolean. When the assistant-message processor sees a tool with endsTurn: true, it flips the flag and calls Query.interrupt() β€” SDK loop stops cleanly. Interrupt failures are non-fatal.
  • Phantom-second-turn bug structurally impossible: end_turn no longer just describes intent, it enforces it.

Flow enforcement (915776a) β€” scratchpad stays private

When the model produces trailing prose WITHOUT calling end_turn or send, 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.

  • Re-prompt is suppressed if turnTerminated === true (model paired prose with end_turn β€” they explicitly declared done, don't loop)
  • Reuses the existing _retried flag (shared with session_expired/context_length retries β€” mutually exclusive paths)
  • Telemetry: scratchpad.trailing_text_dropped (fires on initial + post-retry violation) + scratchpad.flow_violation_retried

Prompt (prompts/telegram.md)

"Flow enforcement" section added β€” model knows what happens when it forgets end_turn.

Behavior

Model output Before this PR After this PR
end_turn(text="hi") n/a "hi" delivered; SDK loop interrupted
end_turn() n/a Silent close; SDK loop interrupted
send(type="text", text="hi") "hi" delivered "hi" delivered (unchanged)
Prose only, no tool call "Suppressed fallback text", user gets nothing Model gets ONE re-prompt; if still no tool β†’ silent drop
end_turn(text="hi") + trailing prose n/a "hi" delivered once; trailing prose suppressed (turnTerminated)

Tests

  • All 1664 pre-existing tests pass
  • 6 new tests in a9a1303e: endsTurn declaration on end_turn, isTurnTerminator behaviour, terminator inventory invariant ("only end_turn for now"), turnTerminated initial state
  • Total: 1670 tests passing
  • All CI green on a9a1303e (12 successes, 1 skipped Auto-Merge)

Commit history

SHA Author Description
initial claudiusthebot Initial end_turn tool + trailing-text fallback (later reversed)
8763f1c Dylan style: prettier
dc0302f Dylan drop trailing-text fallback ("scratchpad is by contract")
bd158f63 Dylan style: prettier
915776a Dylan re-prompt on flow violation, 1 retry
7d6198d Dylan docs(messaging): correct end_turn description after fallback removal
a9a1303e claudiusthebot feat(messaging): make end_turn actually end the turn (loop interruption)

πŸ€– Generated with Claude Code

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>
@claudiusthebot
claudiusthebot requested a review from dylanneve1 as a code owner May 4, 2026 12:44
github-actions Bot and others added 6 commits May 4, 2026 12:44
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_turn tool (endsTurn: true) and shared isTurnTerminator() 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 thread src/backend/claude-sdk/handler.ts Outdated
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
@claudiusthebot claudiusthebot changed the title feat(messaging): add end_turn tool + trailing-text fallback feat(messaging): add end_turn tool + flow enforcement May 7, 2026
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@dylanneve1
dylanneve1 merged commit 6a4f8ac into main May 8, 2026
11 checks passed
@dylanneve1
dylanneve1 deleted the feat/end-turn-tool branch May 8, 2026 10:01
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants