fix(claude-sdk): preserve SDK loop on terminator delivery failure via SDK's native error pipeline - #159
Merged
Conversation
β¦ SDK's native error pipeline
When a turn-terminator tool (`end_turn`, strict `react`) failed to deliver
(e.g. Telegram rejected `end_turn` for "Message too long", invalid chat_id,
network blip), the PostToolBatch hook terminated the SDK loop anyway β the
model saw the error in its tool result but had no turn left to react. End
result: silent dropped turn, user sees nothing.
Canonical incident (2026-05-13 13:11Z Pandario reply 226264): end-of-turn
delivery of a 4326-char message hit Telegram's 4096 cap, bridge returned
`{ok: false, error: "Message too long..."}`, hook fired regardless, turn
silently ended.
Supersedes #158 (content-sniffing approach was fragile β frontend-coupled,
schema-drift-vulnerable, false-positive-prone on responses that happened
to contain `"ok":false` substrings).
This PR uses the SDK's NATIVE error pipeline instead of inspecting bodies.
Implementation:
1. `end_turn.execute` and `react.execute` THROW when the bridge returns
`{ok: false}` instead of returning the failure object silently. A new
`throwIfFailed` helper wraps the bridge result and raises a typed
`Error("<tool> delivery failed: <bridge error>")`. The "what counts
as a failure" decision now lives in the tool implementation, where the
contract is owned.
2. The SDK observes the throw and fires `PostToolUseFailure` with a typed
`{tool_name, tool_input, tool_use_id, error, is_interrupt}` payload β
no string sniffing, no `unknown` parsing.
3. New `PostToolUseFailure` hook records the failed `tool_use_id` in a
per-session `Set<string>`. Ignores interrupts (`is_interrupt: true`)
and non-terminator failures (e.g. `send`).
4. `PostToolBatch` hook now consults the Set β if the terminator's
`tool_use_id` was flagged, it deletes the flag and returns
`{continue: true}` to keep the SDK loop alive. Otherwise terminates
as before (perf win from PR #122 preserved on the happy path).
5. The two hooks share state via closure β `buildTurnTerminatorHooks()`
creates a fresh Set per `buildSdkOptions()` call, so concurrent chat
sessions stay isolated.
Frontend-agnostic by design: any frontend whose tools throw on delivery
failure gets the same recovery behaviour. No bridge envelope shape is
baked into the SDK options layer.
Tests:
- 9 new `PostToolUseFailure + PostToolBatch coordination` cases
(terminator failure preserves loop, success terminates, interrupt
ignored, non-terminator failure ignored, soft-react `end_turn:false`
ignored, defensive non-failure events, flag-consumed-on-match,
per-session isolation).
- 8 new messaging-tools cases for `end_turn` / `react` throw behaviour
(text path throws on {ok:false}, buttons path throws, generic
message when error field missing, success path unchanged, react
strict + soft both throw, react strips end_turn param).
- All 33 existing PostToolBatch hook tests still pass.
- 2001/2014 vitest pass β same pre-existing `package.functional` flake
as PR #157 (irrelevant: running tests on a host where Talon daemon is
already live).
- typecheck clean, prettier clean, no new lint warnings.
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
When a turn-terminator tool (
end_turn, strictreact) failed to deliver β Telegram rejected for "Message too long", invalidchat_id, network blip β thePostToolBatchhook terminated the SDK loop anyway. The model saw the error in its tool result but had no turn left to react. End result: silent dropped turn, user sees nothing.Canonical incident (2026-05-13 13:11Z, Pandario reply 226264): end-of-turn delivery of a 4326-char message hit Telegram's 4096 cap, bridge returned
{ok:false, error:\"Message too long...\"}, hook fired regardless, user pinged the bot 24 min later asking what happened.Supersedes #158 (closed). That PR took a content-sniffing approach in the hook (parse
tool_responselooking for\"ok\":false). Dylan flagged three real problems: frontend coupling (Telegram bridge shape baked into a generic SDK hook), schema drift (if the bridge envelope changes the check goes blind), false positives (any string containing\"ok\":falsewould trigger the recovery). This PR uses the SDK's native error pipeline instead.How it works
end_turn.executeandreact.executeTHROW on{ok:false}instead of returning the failure object silently. A newthrowIfFailedhelper raisesError(\"<tool> delivery failed: <bridge error>\"). The "what counts as a failure" decision now lives in the tool implementation, where the contract is owned.SDK observes the throw and fires
PostToolUseFailurewith a typed{tool_name, tool_input, tool_use_id, error, is_interrupt}payload. No string sniffing, nounknownparsing.New
PostToolUseFailurehook records the failedtool_use_idin a per-sessionSet<string>. Ignores interrupts (is_interrupt: true) and non-terminator failures (send, etc).PostToolBatchhook consults the Set β if the terminator'stool_use_idwas flagged, deletes the flag and returns{continue: true}to keep the SDK loop alive. Otherwise terminates as before (PR fix(claude-sdk): terminate SDK loop on end_turn (MCP-prefix match + PostToolBatch hook)Β #122's perf win preserved on the happy path).Hooks share state via closure β
buildTurnTerminatorHooks()creates a fresh Set perbuildSdkOptions()call. Concurrent chat sessions stay isolated.Why this is better than #158
PostToolUseFailureHookInputhas named fields (tool_name,error,is_interrupt). Nounknownparsing.\"ok\":false\"somewhere in its payload no longer accidentally triggers the recovery path. Only a thrown exception counts.execute()decides what's a failure, and the SDK propagates that.Test plan
PostToolUseFailure + PostToolBatch coordinationcases:end_turnβ loop preservedreactβ loop preservedend_turnβ terminates as usualis_interrupt: trueignored (not a real failure)send) ignoredreact(end_turn: false) failure ignored{continue: true}end_turnthrows on text path / buttons path / missing error fieldend_turnsuccess path unchanged (returns the bridge result)reactstrict throws on{ok:false}reactsoft (end_turn: false) also throws on{ok:false}reactsuccess path unchangedreactstill strips theend_turnparam before bridgingnpm run typecheckcleannpm run lintβ no new warningsnpm run testβ 2001/2014 pass, 1 pre-existingpackage.functionalflake (verified same flake on main when running tests on a host where Talon daemon is up).π€ Generated with Claude Code