Skip to content

fix(claude-sdk): terminate SDK loop on end_turn (MCP-prefix match + PostToolBatch hook) - #122

Merged
claudiusthebot merged 5 commits into
mainfrom
fix/turn-terminator-mcp-prefix
May 9, 2026
Merged

fix(claude-sdk): terminate SDK loop on end_turn (MCP-prefix match + PostToolBatch hook)#122
claudiusthebot merged 5 commits into
mainfrom
fix/turn-terminator-mcp-prefix

Conversation

@claudiusthebot

@claudiusthebot claudiusthebot commented May 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two-part fix for the typing-indicator-lingers-after-end_turn bug Dylan flagged in chat. The first half (commits ff08fe8 + 031a463 + d5ce30f) makes the existing turn-terminator detection actually match MCP-prefixed tool names. The second half (commits e0262e0 + 097b8b4) replaces the dropped qi.interrupt() approach with an SDK-sanctioned PostToolBatch hook 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" after end_turn. The check sits in isTurnTerminator(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, so isTurnTerminator("mcp__telegram-tools__end_turn") returned 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.

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.

Diagnosis

Diagnostic logging on a temp branch (now removed) confirmed:

[DIAG] terminator tool mcp__telegram-tools__end_turn observed at t=174109
[DIAG] typing cleared at t=177232  (+3123ms post-end_turn)
SDK result: numApiCalls=2

numApiCalls=2 β€” the second call is the wasted wrap-up after end_turn returned a tool result. That's exactly the 3s gap.

Fix part 1 β€” MCP prefix detection (ff08fe8, 031a463)

  • New stripMcpPrefix() helper in src/core/tools/index.ts β€” strips mcp__<server>__ (non-greedy .+? match, first __ after mcp__ is the server-name boundary). No-op if no prefix matches.
  • isTurnTerminator now checks both the raw name and the stripped form.
  • captureDeliveredText in src/backend/claude-sdk/handler.ts normalizes via stripMcpPrefix before 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 multiple tool_use blocks in the same assistant message, fired as a parallel batch. end_turn itself is one MCP tool; siblings (e.g. mempalace_add_drawer, a react, etc.) might still be in-flight when end_turn resolves. interrupt() cancels their AbortController mid-flight, surfacing as MCP error -32001: AbortError in 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.turnTerminated is still tracked so the flow-violation re-prompt path can skip its retry when the model explicitly ended its turn.

Fix part 3 β€” PostToolBatch hook (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: false as the canonical way to terminate the query loop, and TerminalReason: 'hook_stopped' is a documented exit condition. The hook event we want is PostToolBatch β€” per the SDK type docs:

PostToolUse fires per-tool and may run concurrently for parallel tool calls; PostToolBatch fires exactly once with the full batch.

That's the magic property. The race that killed qi.interrupt() was sibling MCP tools getting their AbortControllers cancelled mid-flight. PostToolBatch fires 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:

const turnTerminatorHook: HookCallback = async (input) => {
  if (input.hook_event_name !== "PostToolBatch") {
    return { continue: true };
  }
  const batch = input as PostToolBatchHookInput;
  const terminator = batch.tool_calls.find((tc) =>
    isTurnTerminator(tc.tool_name),
  );
  if (terminator) {
    log("agent", `PostToolBatch: terminating SDK loop on ${terminator.tool_name} (batch size: ${batch.tool_calls.length})`);
    return { continue: false, stopReason: "turn terminated by end_turn / send" };
  }
  return { continue: true };
};

Registered on the options:

hooks: {
  PostToolBatch: [{ hooks: [turnTerminatorHook] }],
},

The same isTurnTerminator helper 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:

{"level":30,"time":1778322236,"component":"agent","msg":"PostToolBatch: terminating SDK loop on mcp__telegram-tools__end_turn (batch size: 1)"}

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:

  • 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 on real tool name shapes
  • stripMcpPrefix is a no-op when no prefix matches (end_turn, Read, mcp__incomplete, not_mcp__server__tool)
  • Hook is registered on options.hooks.PostToolBatch
  • Hook returns continue: false for MCP-prefixed end_turn in the batch
  • Hook returns continue: false for bare end_turn
  • Hook returns continue: true when no terminator is in the batch
  • Hook returns continue: true on empty batch
  • Hook ignores non-PostToolBatch events defensively

vitest run β†’ 1690/1691 pass. The 1 failure is package.functional.test.ts host-isolation gap (talon status test 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

  • tsc clean
  • prettier --check clean on touched files
  • oxlint clean on touched files
  • full test suite passes (modulo unrelated pre-existing host-isolation gap)
  • live smoke test on production: hook fires on every end_turn, log line confirmed, typing-indicator drops instantly

πŸ€– Generated with Claude Code

@claudiusthebot
claudiusthebot requested a review from dylanneve1 as a code owner May 8, 2026 16:15
@claudiusthebot

Copy link
Copy Markdown
Collaborator Author

Heartbeat #169 addendum β€” found three improvements sitting uncommitted on this branch, committed them as 031a463:

1. telegram/handlers.ts β€” processAndReply onToolUse
The original PR fixed isTurnTerminator and captureDeliveredText in the SDK layer, but processAndReply in handlers.ts still did bare-name equality on the onToolUse callback. That meant:

  • end_turn deliveries (arriving as mcp__telegram-tools__end_turn in production) were silently not captured in the daily log
  • Only bare send was captured, which would also miss the prefixed form in production

Applied the same stripMcpPrefix pattern. Now both end_turn and send log to the daily diary regardless of how they arrive.

2. end-turn.test.ts β€” production wire-shape contract
Added a new describe block that tests isTurnTerminator + processAssistantMessage with actual SDK-emitted names (mcp__telegram-tools__end_turn, mcp__teams-tools__end_turn, etc.). Auto-derived from ALL_TOOLS.filter(t => t.endsTurn) Γ— known MCP servers β€” adding a new endsTurn tool stays covered without manual additions. These are exactly the tests that would have caught the original bug.

3. handlers.test.ts β€” onToolUse matrix
Replaced the single bare-send test with 6 cases: 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 + format:check + vitest run all clean (143 tests in the two affected files). CI restarted on 031a463 β€” the Windows Functional flake (package.functional.test.ts) is pre-existing and unrelated to this change, same as on main.

@claudiusthebot

Copy link
Copy Markdown
Collaborator Author

Pushed d5ce30f β€” drops the qi.interrupt() call.

Reproduced on the live deployment: with v1 applied (prefix fix only), isTurnTerminator correctly matches MCP-prefixed names β†’ qi.interrupt() actually fires β†’ races with in-flight MCP tool dispatches in the same assistant message β†’ MCP error -32001: AbortError β†’ turns bomb.

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. state.turnTerminated is still tracked because the flow-violation re-prompt path uses it β€” just no more aborting in-flight MCP work. Net effect: ~2–3s of typing lag at end_turn (one wasted SDK follow-up call where the model says nothing) but no broken turns.

Prefix-stripping stays β€” the daily-log capture / dedup fixes are independent value.

Patch already applied to /home/dylan/telegram-claude-agent and tsc clean. Restart whenever for the live fix.

dylanneve1 pushed a commit that referenced this pull request May 9, 2026
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`.
@claudiusthebot claudiusthebot changed the title fix(claude-sdk): match MCP-prefixed names in turn-terminator detection fix(claude-sdk): terminate SDK loop on end_turn (MCP-prefix match + PostToolBatch hook) 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)
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).
@claudiusthebot
claudiusthebot force-pushed the fix/turn-terminator-mcp-prefix branch from 097b8b4 to f5fad99 Compare May 9, 2026 10:32
@claudiusthebot
claudiusthebot enabled auto-merge (squash) May 9, 2026 10:32
@claudiusthebot
claudiusthebot merged commit a92452a into main May 9, 2026
15 checks passed
dylanneve1 pushed a commit that referenced this pull request May 10, 2026
…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)
claudiusthebot added a commit that referenced this pull request May 10, 2026
…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.
claudiusthebot added a commit that referenced this pull request May 10, 2026
… 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
dylanneve1 added a commit that referenced this pull request May 19, 2026
… 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>
dylanneve1 pushed a commit that referenced this pull request May 21, 2026
…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).
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.

1 participant