fix(claude-sdk): terminate SDK loop on end_turn (MCP-prefix match + PostToolBatch hook) - #122
Conversation
|
Heartbeat #169 addendum β found three improvements sitting uncommitted on this branch, committed them as 1.
Applied the same 2. 3.
|
|
Pushed Reproduced on the live deployment: with v1 applied (prefix fix only), Hits seen between 16:19β22:47 UTC: two group turns, the OpenRouter Model Monitor cron ("Job failed"), and Dylan's "What's up" DM all surfaced the AbortError. Removed the interrupt call. Prefix-stripping stays β the daily-log capture / dedup fixes are independent value. Patch already applied to |
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`.
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)
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.
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
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`.
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).
097b8b4 to
f5fad99
Compare
β¦w, lockfile portability, tarball validation Per Dylan's "overhaul the CI and make it much more comprehensive and robust." Five new jobs gated through the existing `CI Status` aggregate, plus a real package-distribution fix the new tarball checker uncovered. ## What's new ### `integration` β MCP-functional tier in CI (Ubuntu/macOS/Windows Γ Node 22) The new MCP-functional tier from #131 (`talon-mcp-functional.test.ts`) now runs on every PR + push across the full OS matrix. Catches SDKβMCPβbridgeβhandler integration bugs (the PR #122 prefix-mismatch class) without needing a live external service. Builds the stub-claude SEA binary on Windows (Node 20.19+ blocks spawning .cmd shims via child_process.spawn β CVE-2024-27980 mitigation). ### `secrets` β gitleaks scan on every push + PR `gitleaks/gitleaks-action@v2` with `fetch-depth: 0` so it sees full git history, not just the latest commit. Catches API keys, bot tokens, .env files committed by accident, OAuth secrets, private keys. ### `dep-review` β github/dependency-review-action on PRs Diffs lockfile changes against the GitHub Advisory Database. `fail-on-severity: moderate` β moderate or higher CVE in any newly introduced dep blocks the PR. Comments summary on PR on failure. PR-only (no main-branch run); `ci-ok` treats `skipped` as ok. ### `lockfile` β portability check in fresh tmpdir Copies ONLY `package.json` + `package-lock.json` to a fresh tmpdir and runs `npm ci --ignore-scripts --no-audit` there. Same check as `tools/verify-lockfile.sh` locally. Catches the "lockfile drift" / "lockfile only validates with `--include=optional`" classes the heartbeat journal documented (hb #48 / #103). The other test jobs run `npm ci` in the source tree where `node_modules` state could mask portability bugs. This one starts from zero, every run. ### `tarball` β validates `npm pack` contents New `.github/scripts/tarball-check.mjs` β runs `npm pack`, inspects the resulting tarball for forbidden patterns: - `.env*`, `credentials.json`, `*.key`, `*.pem`, `id_rsa`, etc. - `__tests__/`, `*.test.ts`, `*.spec.ts` - `node_modules`, `.git`, `coverage`, sourcemaps, `.vscode`, `.idea`, `.DS_Store` - Tarball size > 5MB (catches accidental large-file commits) - `package.json` / `README` missing Also prints a sorted top-10 largest files for the CI step summary. ## Real bug uncovered The tarball checker's first run flagged **270+ test files were being shipped in the npm tarball**. `package.json#files` listed `"src/"` which swept in the entire test suite. Fixed by replacing the broad `"src/"` entry with explicit subdirs: - "src/backend/", "src/core/", "src/frontend/", "src/plugins/", "src/storage/", "src/util/" - "src/bootstrap.ts", "src/cli.ts", "src/index.ts", "src/login.ts" Tarball size dropped from including 270+ test files to a clean 99 source-only files (180KB β still 180KB but with no test scaffolding). Future versions of `talon-agent` on npm won't ship test fixtures. ## Coverage config `vitest.config.ts`: added an exclude list (test files, integration scaffolding, entry points) and `lcov` reporter for tooling compatibility (codecov, IDE plugins). Thresholds left at 60% for now β ratcheting up is out of scope for this PR (would risk breaking CI on unrelated paths). ## ci-ok gate Updated to require all 10 jobs (was 6). Branch protection in `.github/branch-protection.json` already gates merges on `CI Status`, so adding a new required job means adding it to the `needs:` list + the `check` calls in the gate script β no separate config change. ## Test plan - [x] `tsc --noEmit` clean - [x] `prettier --check` clean on touched files - [x] `npm run test:integration` passes locally (4 tests) - [x] `npm run tarball:check` passes locally β clean tarball - [x] Lockfile portability passes (replicated tmpdir check, 7s npm ci) - [x] YAML syntax valid - [x] Existing test suite unaffected (1711 tests pass + 1 pre-existing main flake)
β¦iew + lockfile portability + tarball validation (#137) * ci: comprehensive overhaul β integration tier, secret scan, dep review, lockfile portability, tarball validation Per Dylan's "overhaul the CI and make it much more comprehensive and robust." Five new jobs gated through the existing `CI Status` aggregate, plus a real package-distribution fix the new tarball checker uncovered. ## What's new ### `integration` β MCP-functional tier in CI (Ubuntu/macOS/Windows Γ Node 22) The new MCP-functional tier from #131 (`talon-mcp-functional.test.ts`) now runs on every PR + push across the full OS matrix. Catches SDKβMCPβbridgeβhandler integration bugs (the PR #122 prefix-mismatch class) without needing a live external service. Builds the stub-claude SEA binary on Windows (Node 20.19+ blocks spawning .cmd shims via child_process.spawn β CVE-2024-27980 mitigation). ### `secrets` β gitleaks scan on every push + PR `gitleaks/gitleaks-action@v2` with `fetch-depth: 0` so it sees full git history, not just the latest commit. Catches API keys, bot tokens, .env files committed by accident, OAuth secrets, private keys. ### `dep-review` β github/dependency-review-action on PRs Diffs lockfile changes against the GitHub Advisory Database. `fail-on-severity: moderate` β moderate or higher CVE in any newly introduced dep blocks the PR. Comments summary on PR on failure. PR-only (no main-branch run); `ci-ok` treats `skipped` as ok. ### `lockfile` β portability check in fresh tmpdir Copies ONLY `package.json` + `package-lock.json` to a fresh tmpdir and runs `npm ci --ignore-scripts --no-audit` there. Same check as `tools/verify-lockfile.sh` locally. Catches the "lockfile drift" / "lockfile only validates with `--include=optional`" classes the heartbeat journal documented (hb #48 / #103). The other test jobs run `npm ci` in the source tree where `node_modules` state could mask portability bugs. This one starts from zero, every run. ### `tarball` β validates `npm pack` contents New `.github/scripts/tarball-check.mjs` β runs `npm pack`, inspects the resulting tarball for forbidden patterns: - `.env*`, `credentials.json`, `*.key`, `*.pem`, `id_rsa`, etc. - `__tests__/`, `*.test.ts`, `*.spec.ts` - `node_modules`, `.git`, `coverage`, sourcemaps, `.vscode`, `.idea`, `.DS_Store` - Tarball size > 5MB (catches accidental large-file commits) - `package.json` / `README` missing Also prints a sorted top-10 largest files for the CI step summary. ## Real bug uncovered The tarball checker's first run flagged **270+ test files were being shipped in the npm tarball**. `package.json#files` listed `"src/"` which swept in the entire test suite. Fixed by replacing the broad `"src/"` entry with explicit subdirs: - "src/backend/", "src/core/", "src/frontend/", "src/plugins/", "src/storage/", "src/util/" - "src/bootstrap.ts", "src/cli.ts", "src/index.ts", "src/login.ts" Tarball size dropped from including 270+ test files to a clean 99 source-only files (180KB β still 180KB but with no test scaffolding). Future versions of `talon-agent` on npm won't ship test fixtures. ## Coverage config `vitest.config.ts`: added an exclude list (test files, integration scaffolding, entry points) and `lcov` reporter for tooling compatibility (codecov, IDE plugins). Thresholds left at 60% for now β ratcheting up is out of scope for this PR (would risk breaking CI on unrelated paths). ## ci-ok gate Updated to require all 10 jobs (was 6). Branch protection in `.github/branch-protection.json` already gates merges on `CI Status`, so adding a new required job means adding it to the `needs:` list + the `check` calls in the gate script β no separate config change. ## Test plan - [x] `tsc --noEmit` clean - [x] `prettier --check` clean on touched files - [x] `npm run test:integration` passes locally (4 tests) - [x] `npm run tarball:check` passes locally β clean tarball - [x] Lockfile portability passes (replicated tmpdir check, 7s npm ci) - [x] YAML syntax valid - [x] Existing test suite unaffected (1711 tests pass + 1 pre-existing main flake) * ci: drop Windows from integration job β MCP-dispatch chain doesn't run there yet The MCP-functional tier passes on Ubuntu + macOS but fails on Windows: the stub's SEA-bundled MCP client + StdioClientTransport spawn-and-fetch chain to the recording handler doesn't complete under SEA-on-Windows. Three of four cases see zero captures at the recording handler, even though `PostToolBatch: terminating SDK loop on mcp__telegram-tools__end_turn` fires (so the SDK loop sees the tool, but MCP dispatch never lands). Likely culprits to investigate (out of scope for this PR): - esbuild SEA bundle handling of the dynamic `await import("@modelcontextprotocol/sdk/...")` - StdioClientTransport.spawn behavior under SEA-on-Windows - Localhost loopback / fetch from spawned subprocess on Windows The other Windows test paths still run β `Tests (windows-latest, Node 22)` and `Functional (windows-latest)` cover the platform without MCP dispatch (sdk-stub, talon-functional). Reinstating Windows for `integration` is gated on root-causing the failure. * Revert "ci: drop Windows from integration job β MCP-dispatch chain doesn't run there yet" This reverts commit 6410446. * fix(integration): static MCP imports + protocol-log-on-failure for Windows debugging Two changes targeting the Windows integration failure: 1. fake-claude.mjs: convert dynamic `await import("@modelcontextprotocol/sdk/...")` to static top-level imports. Likely root cause of the Windows failure β esbuild's CJS bundling preserves `await import(literal)` as `Promise.resolve(require(literal))`, which works locally where node_modules exists, but the SEA binary on Windows ships only the bundled .cjs without node_modules. Static imports force esbuild to inline the SDK module bodies into the bundle. Verified: rebuilt SEA bundle locally, `grep "var StdioClientTransport" fake-claude.cjs` confirms the SDK is now inlined. 2. talon-mcp-functional.test.ts: add `withProtocolLogOnFailure()` helper. The MCP-functional tier has subprocess hops (SDK loop β MCP server spawn β bridge HTTP) that produce no Vitest output when something goes wrong upstream of the actual `expect(...)`. This wrapper dumps the last 60 lines of the stub's protocol log on assertion failure so future Windows regressions are diagnosable from the CI log directly. Wraps the canary first test only (the others' assertions are all downstream of the same MCP path). Local: all 11 MCP-functional tests still pass on Linux. Windows verification has to come from the CI run on this branch. * fix(mcp-server): unify Windows + POSIX command path β node --import tsx, not npx tsx Real root cause of the Windows integration failure. `buildMcpServers` was spawning `npx tsx <path>` on Windows and `node --import <tsx-loader> <path>` on POSIX. The Windows path goes through the MCP launcher (which proxies stdio), so the launcher then runs `child_process.spawn("npx", [...])` to start the actual MCP server. Node 20.19+ refuses to execute `.cmd` shims via `child_process.spawn` without `shell: true` (CVE-2024-27980 mitigation). `npx` on Windows IS a .cmd shim. Result: the launcher's spawn fails immediately, the MCP server never starts, the stub's MCP client tries to connect over a dead pipe and gets `MCP error -32000: Connection closed`. The earlier hypothesis (SEA bundle missing dynamic imports) was wrong β the static-imports change in 9507333 is still the right fix (removes a dynamic-import gotcha entirely), but the actual blocker was this command shape. Fix: use `node --import <tsx-loader> <path>` on every platform. tsx as a Node loader is platform-identical, and we never spawn `npx.cmd`. The launcher's spawn now hits a plain `node` binary, which works under Node's CVE mitigation. Verified locally: 11 / 11 MCP-functional tests still pass on Linux. Windows verification: this branch's CI run. Diagnostic from the previous failed Windows CI run that pinned this: MCP_CONFIG servers: ["telegram-tools"] MCP connect error for telegram-tools: MCP error -32000: Connection closed The launcher's spawned child died before completing initialize. * fix(mcp-server): convert tsx loader path to file:// URL for Windows Second Windows iteration. Same `MCP error -32000: Connection closed` after the previous fix, even with `node --import <path>` everywhere. The remaining issue: `--import` accepts URLs or paths, but on Windows a raw backslash path like `D:\a\talon\talon\node_modules\tsx\dist\esm\index.mjs` is ambiguous between path and URL. Node's loader-hook resolution silently fails to register tsx, every subsequent `.ts` import in `mcp-server.ts` throws, the MCP server crashes, the stub's MCP client gets "Connection closed" on its initialize handshake. Fix: convert via `pathToFileURL(resolve(...)).href` so the loader URL is always a clean `file:///D:/.../index.mjs` regardless of platform. Linux already worked because `/path/to/foo.mjs` is unambiguous as both a path and a URL. Local: 11 / 11 still pass on Linux. Windows: this commit's CI run is the truth-or-not test.
β¦ prefix, markdown (#141) Per Dylan's "expand fuzz testing" follow-up to the bug-finding-tier discussion. Mutation testing on the full suite is too slow for GHA; property tests are the cheap-and-fast equivalent. This adds adversarial pressure to the messaging-tool surface, the MCP prefix normalizer, and the markdownβHTML converter β all paths Talon hits on every outbound message. +45 new fuzz tests, 12 β 57 total. Runtime: ~5s at 1000 iterations, will be ~50s at the 10000-iteration CI default. Well under budget. ## New coverage ### `stripMcpPrefix()` + `isTurnTerminator()` The boundary normalizer between bare tool names (`end_turn`) and MCP- prefixed (`mcp__telegram-tools__end_turn`). PR #122 lived 8 weeks because three call sites compared bare-only and the prefixed shape never matched. Tests: - never throws on arbitrary strings - input without `mcp__` prefix returns unchanged - synthesized `mcp__<server>__<tool>` always reduces to `<tool>` - `isTurnTerminator` agrees on bare/prefixed equivalence (anchors PR #122 regression β if a future change re-introduces the strict-equality pattern, this fails fast) ### Messaging-tool zod schemas (~12 Γ 2 = 24 tests) For every tool in the registry, two properties: - `safeParse` never throws on arbitrary objects - primitives / null / undefined always return `success: false` Catches schema regressions: a future change that turns a `z.string()` into a discriminated union missing a case would fail here. ### Messaging-tool `execute()` (~12 tests) For every tool, generate dictionary input via `fc.dictionary` and call `tool.execute(input, fakeBridge)`. Asserts: - never throws unhandled (catches and tolerates Error throws β schema validation is allowed to fail loudly) - returns object-shaped result or undefined - bridge fan-out is bounded (β€2 calls per execute) ### `markdownToTelegramHtml` + `escapeHtml` Every outbound message flows through these. Tests: - never throws on arbitrary strings - never throws on long arbitrary strings (size-stressed, unicode) - never throws on heavily nested markdown (depth 1-20 nesting of `*`, `_`, `` ` ``) - `escapeHtml` always escapes `<`, `>`, `&`, `"`, `'` (no raw chars in output after entity-stripping) ## Local verification - `npx vitest run src/__tests__/fuzz.test.ts` β 57 / 57 pass at default 100 iterations, 2.3s - `FAST_CHECK_NUM_RUNS=1000` β 57 / 57 pass, 5.3s - tsc clean, prettier clean, oxlint clean
β¦ SDK's native error pipeline (#159) 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. Co-authored-by: Dylan Neve <dylan.neve@intel.com>
β¦atus (#233) `handleMcpToolCall` was accepting both `in_progress` and `completed` status events for the same item. Combined with the `seenToolCallIds` dedup gate, that meant the handler acted on whichever shape arrived first β and Codex emits `in_progress` first, every time. For delivery tools (`end_turn` / `send` / `react`) this is a race in the same shape as the Claude SDK send/end_turn issue PR #122 fixed via PostToolBatch: `turnTerminated` flips before the bridge has had a chance to execute the delivery. The next pass of the for-await loop fires `abortController.abort()`, which kills the Codex subprocess and the MCP tool subprocess with it. If the bridge HTTP call hasn't gone out yet, the message is never sent. Fix: gate `handleMcpToolCall` on `status === "completed"`. By that point Codex has the MCP server's result in hand β the bridge call has already returned and delivery is confirmed/failed. `in_progress` is analytics-only on Codex (we record tool use at completion via the same path), so dropping the early emit costs nothing. Adds a regression test that emits `in_progress` β `agent_message` β `completed` for the same `end_turn` item and verifies (a) onToolUse fires exactly once, (b) the in_progress event doesn't pre-emptively flip the abort, (c) the completed event correctly triggers it. Updates the comment on the existing dedup test to clarify that it exercises the double-`completed` path, not the `in_progress` β `completed` lifecycle (which is now covered by the new test).
Summary
Two-part fix for the typing-indicator-lingers-after-
end_turnbug Dylan flagged in chat. The first half (commitsff08fe8+031a463+d5ce30f) makes the existing turn-terminator detection actually match MCP-prefixed tool names. The second half (commitse0262e0+097b8b4) replaces the droppedqi.interrupt()approach with an SDK-sanctionedPostToolBatchhook that terminates the query loop cleanly without the abort race.Background
PR #108 added a
qi.interrupt()call after observing a turn-terminator tool, intending to abort the SDK loop instead of letting it do a wasted follow-up API call to "wrap up" afterend_turn. The check sits inisTurnTerminator(toolName)against a set built from tool registry names β so the set contains"end_turn".But tools served through MCP arrive at the SDK with a server prefix:
mcp__telegram-tools__end_turn. The check used strict equality, soisTurnTerminator("mcp__telegram-tools__end_turn")returnedfalse. The entire interrupt path was dead code in production.state.turnTerminatedstayed false,qi.interrupt()never fired, and the SDK ran its full natural cycle including the unnecessary second API call.captureDeliveredTexthad the same bug: it comparedtoolName === "end_turn"andtoolName === "send"literally, so cross-tool dedup also never matched on the MCP-routed names.Diagnosis
Diagnostic logging on a temp branch (now removed) confirmed:
numApiCalls=2β the second call is the wasted wrap-up afterend_turnreturned a tool result. That's exactly the 3s gap.Fix part 1 β MCP prefix detection (
ff08fe8,031a463)stripMcpPrefix()helper insrc/core/tools/index.tsβ stripsmcp__<server>__(non-greedy.+?match, first__aftermcp__is the server-name boundary). No-op if no prefix matches.isTurnTerminatornow checks both the raw name and the stripped form.captureDeliveredTextinsrc/backend/claude-sdk/handler.tsnormalizes viastripMcpPrefixbefore its equality checks.Fix part 2 β Drop
qi.interrupt()(d5ce30f)After landing the prefix fix,
qi.interrupt()started actually firing β and surfaced a different, worse bug. The SDK frequently emits multipletool_useblocks in the same assistant message, fired as a parallel batch.end_turnitself is one MCP tool; siblings (e.g.mempalace_add_drawer, areact, etc.) might still be in-flight whenend_turnresolves.interrupt()cancels theirAbortControllermid-flight, surfacing asMCP error -32001: AbortErrorin the SDK result and bubbling up to the user as "Something went wrong."Removed the interrupt; accepted the ~2-3s typing lag as the temporary cost of not breaking turns.
state.turnTerminatedis still tracked so the flow-violation re-prompt path can skip its retry when the model explicitly ended its turn.Fix part 3 β
PostToolBatchhook (e0262e0,097b8b4)Restores the loop-termination behavior cleanly using the SDK's documented hook system instead of the racy interrupt.
The SDK exposes
SyncHookJSONOutput.continue: falseas the canonical way to terminate the query loop, andTerminalReason: 'hook_stopped'is a documented exit condition. The hook event we want isPostToolBatchβ per the SDK type docs:That's the magic property. The race that killed
qi.interrupt()was sibling MCP tools getting theirAbortControllers cancelled mid-flight.PostToolBatchfires AFTER every tool in the batch has resolved β by definition no in-flight siblings to race with.Implementation in
src/backend/claude-sdk/options.ts:Registered on the options:
The same
isTurnTerminatorhelper from part 1 β it already strips the MCP prefix, so the hook works transparently for both bare and prefixed tool names.Live verification
After the fix landed in
/home/dylan/telegram-claude-agent/and Talon was restarted:Hook fires, SDK exits with
TerminalReason: 'hook_stopped', no follow-up API call, no wasted tokens, no abort race. Confirmed working in production.Tests
5 prefix-detection cases on top of PR #108's existing tests, plus 6 new cases covering the hook:
isTurnTerminatorpasses onmcp__telegram-tools__end_turn,mcp__teams-tools__end_turn,mcp__some-server-name__end_turnisTurnTerminatorstill rejectsmcp__telegram-tools__send,mcp__telegram-tools__reactstripMcpPrefixstrips correctly on real tool name shapesstripMcpPrefixis a no-op when no prefix matches (end_turn,Read,mcp__incomplete,not_mcp__server__tool)options.hooks.PostToolBatchcontinue: falsefor MCP-prefixedend_turnin the batchcontinue: falsefor bareend_turncontinue: truewhen no terminator is in the batchcontinue: trueon empty batchPostToolBatchevents defensivelyvitest runβ 1690/1691 pass. The 1 failure ispackage.functional.test.tshost-isolation gap (talon statustest expects "Stopped" but Talon is running on the test host) β passes in CI, fails locally on the production VPS. Unrelated to this change.Test plan
end_turn, log line confirmed, typing-indicator drops instantlyπ€ Generated with Claude Code