Skip to content

fix: mark interrupted tool calls as cancelled, not failed - #35

Merged
saucam merged 2 commits into
mainfrom
fix/tool-interrupt-state
Jun 25, 2026
Merged

fix: mark interrupted tool calls as cancelled, not failed#35
saucam merged 2 commits into
mainfrom
fix/tool-interrupt-state

Conversation

@saucam

@saucam saucam commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • #completeActiveTools() in session.ts closed orphaned tool calls with { phase: "completed", success: false } whenever a turn ended while a tool was still executing (user hit Stop, error, or turn end before tool_result arrived)
  • The web UI's MessageRow.tsx renders completed && success === false as an "edit failed — no error message" error banner — a misleading error for what was actually a user-initiated interruption
  • Fix: use { phase: "cancelled", reason: "interrupted" } instead, which is already handled correctly by PhaseBadge (shows a danger badge) and ToolStateBody ("cancelled — interrupted")

Why this is the right state

resume-reconcile.ts already uses cancelled/interrupted for the identical scenario on daemon restart. The ToolCancelledState type is defined in the protocol for exactly this purpose. This change makes turn-end interruption consistent with restart recovery.

Test plan

  • src/tests/tool-interrupt-state.test.ts — 17 new tests:
    • Verifies the cancelled/interrupted state transition mirrors what #completeActiveTools() now produces
    • Regression test: completed/false state would trigger the error banner (the old bug)
    • Verifies cancelled state does not trigger the error banner
    • Cross-checks against reconcileResumedMessage — both paths now produce identical terminal state
  • Full suite: 600 pass, 6 fail (all 6 pre-existing, none introduced by this change)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Interrupted tool calls now reliably finalize as cancelled with an interrupted reason, rather than appearing as completed failures.
    • Tool state and live updates (including subsequent scrollback replay) remain consistent across interruption and recovery.
    • Resumed sessions preserve the interrupted tool status correctly after restart/recovery, and interrupted states no longer trigger the error banner.
  • Tests
    • Added automated coverage to prevent regressions in interrupted vs completed tool terminal-state handling.

#completeActiveTools() was closing orphaned tool calls with
{ phase: "completed", success: false } when a turn ended mid-execution.
The web UI's MessageRow rendered that as an "edit failed — no error
message" banner — a misleading error for what was a user-initiated
interruption.

Switch to { phase: "cancelled", reason: "interrupted" }, consistent with
the restart-recovery path in resume-reconcile.ts which already used this
pattern. The web UI's PhaseBadge and ToolStateBody already handle the
cancelled phase correctly.

Adds tool-interrupt-state.test.ts (17 tests) verifying the invariant
and including a regression test that the old completed/false state would
have triggered the error banner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The daemon now marks interrupted open tool calls as cancelled with reason: "interrupted" and emits that state. Tests cover the interrupted terminal-state invariant, the error-banner predicate, resume reconciliation, and session replay behavior.

Changes

Interrupted tool terminal state

Layer / File(s) Summary
Session interruption state
src/daemon/session.ts
Open tool calls now end as phase: "cancelled" with reason: "interrupted", and the session updates scrollback, transcript, chunker state, and emitted deltas with that terminal state.
Interrupted-state invariants
src/tests/tool-interrupt-state.test.ts
Adds helpers and tests that map interrupted tool states to cancelled/interrupted and verify the direct completeActiveTools terminal-state transition.
Banner and resume reconciliation
src/tests/tool-interrupt-state.test.ts
Adds tests for the error-banner predicate and for reconcileResumedMessage turning resumed executing or streaming tool states into cancelled/interrupted.
Session broadcast and replay tests
src/tests/tool-interrupt-state.test.ts
Adds real-session coverage for broadcast deltas, scrollback replay, and the missing-scrollback path when applying interrupted tool state.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the core change: interrupted tool calls are now finalized as cancelled instead of failed.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tool-interrupt-state

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jun 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.30435% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.69%. Comparing base (1f0f6d7) to head (ad5d4fc).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/daemon/session.ts 91.30% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #35      +/-   ##
==========================================
+ Coverage   58.34%   59.69%   +1.35%     
==========================================
  Files          46       47       +1     
  Lines        6943     7255     +312     
==========================================
+ Hits         4051     4331     +280     
- Misses       2892     2924      +32     
Flag Coverage Δ
daemon 59.69% <91.30%> (+1.35%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/daemon/session.ts 9.25% <91.30%> (+1.31%) ⬆️

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/tests/tool-interrupt-state.test.ts (2)

42-42: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Comment overstates the banner condition.

The doc says the banner shows when phase === "completed" && success === false && no output, but neither the mirrored triggersErrorBanner nor MessageRow.tsx gates on output — output is only used for the displayed text, not the predicate. Drop the && no output to avoid implying a third condition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tests/tool-interrupt-state.test.ts` at line 42, The test doc comment
overstates the error banner predicate by adding a no-output requirement that is
not used anywhere else. Update the comment in tool-interrupt-state.test.ts so it
matches the actual logic in triggersErrorBanner and MessageRow.tsx: the banner
appears when phase is "completed" and success is false, without mentioning
output as a condition.

36-50: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Tests mirror the logic instead of exercising it.

interruptTool and triggersErrorBanner re-implement the state transition from #completeActiveTools and the banner predicate from MessageRow.tsx. A regression in the actual session.ts (e.g. reverting to completed/false) would leave these tests green, so the "invariant" they protect isn't actually anchored to production code. Consider importing/extracting the real transition (a small exported helper used by both #completeActiveTools and the test) so the test fails if the daemon's behavior drifts. The reconcileResumedMessage block below is the right pattern — it imports and runs the real function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tests/tool-interrupt-state.test.ts` around lines 36 - 50, The test
helpers `interruptTool` and `triggersErrorBanner` are duplicating production
logic instead of validating it, so changes in `session.ts` or `MessageRow.tsx`
could slip through unnoticed. Refactor the state transition and banner predicate
into a small exported helper used by `#completeActiveTools` and
`MessageRow.tsx`, then update `tool-interrupt-state.test.ts` to import and
exercise that real helper instead of re-implementing the behavior; follow the
same pattern already used by `reconcileResumedMessage`.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/tests/tool-interrupt-state.test.ts`:
- Line 42: The test doc comment overstates the error banner predicate by adding
a no-output requirement that is not used anywhere else. Update the comment in
tool-interrupt-state.test.ts so it matches the actual logic in
triggersErrorBanner and MessageRow.tsx: the banner appears when phase is
"completed" and success is false, without mentioning output as a condition.
- Around line 36-50: The test helpers `interruptTool` and `triggersErrorBanner`
are duplicating production logic instead of validating it, so changes in
`session.ts` or `MessageRow.tsx` could slip through unnoticed. Refactor the
state transition and banner predicate into a small exported helper used by
`#completeActiveTools` and `MessageRow.tsx`, then update
`tool-interrupt-state.test.ts` to import and exercise that real helper instead
of re-implementing the behavior; follow the same pattern already used by
`reconcileResumedMessage`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3dd7d611-373c-4e84-bef2-98508eedd961

📥 Commits

Reviewing files that changed from the base of the PR and between 1f0f6d7 and 7409ed6.

📒 Files selected for processing (2)
  • src/daemon/session.ts
  • src/tests/tool-interrupt-state.test.ts

#completeActiveTools() loop body is unreachable without a live SDK turn,
giving the patch 0% coverage. Extract the per-tool logic into a
TypeScript-private (not JS `#` private) _applyInterruptedStateToTool()
method so tests can exercise it directly via `as unknown as SessionInternal`
without mocking the SDK.

Adds four new Session-level tests in tool-interrupt-state.test.ts that
call the extracted method with a real Session, seeded scrollback, and
captured broadcast — covering the actual changed code in session.ts:
- delta broadcast carries cancelled/interrupted state
- scrollback replay reflects the updated state after the call
- no-op when msgId not in scrollback (no crash)
- broadcast state does not trigger the web UI error banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/tests/tool-interrupt-state.test.ts`:
- Around line 303-325: The scrollback test is currently relying on
restoreScrollback to pre-convert the tool call state, which hides the behavior
being tested. Update this test (and the one below) to seed the stored message in
the executing phase directly in the session scrollback, then call
_applyInterruptedStateToTool on SessionInternal and verify the replayed message
changes to cancelled/interrupted. Use makeToolCallMessage, restoreScrollback,
and _applyInterruptedStateToTool to locate the relevant setup and keep the
assertion focused on the scrollback replay result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 74482546-a684-4596-97b2-d403d07905e7

📥 Commits

Reviewing files that changed from the base of the PR and between 7409ed6 and ad5d4fc.

📒 Files selected for processing (2)
  • src/daemon/session.ts
  • src/tests/tool-interrupt-state.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/daemon/session.ts

Comment on lines +303 to +325
it("updates the scrollback entry so replay shows the cancelled state", () => {
const session = makeSession();
const msgId = randomUUID();
const toolMsg = makeToolCallMessage({ phase: "executing" }, msgId);
(toolMsg as SessionMessage & { sessionId: string }).sessionId = session.id;

session.restoreScrollback([toolMsg]);

(session as unknown as SessionInternal)._applyInterruptedStateToTool(msgId);

// Re-attach to trigger a scrollback replay — the replayed message must
// show the updated (cancelled) state, not the original (executing) state.
const { client, received } = makeClient("replay-client");
session.attach(client);

const replay = received.find((m) => m.type === "scrollback.replay") as
| { messages: SessionMessage[] }
| undefined;
expect(replay).toBeDefined();
const updated = replay!.messages.find((m) => m.messageId === msgId);
expect(updated?.tool?.state.phase).toBe("cancelled");
expect((updated?.tool?.state as ToolCancelledState).reason).toBe("interrupted");
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f 'resume-reconcile.ts' --exec cat -n {}

Repository: saucam/codeoid

Length of output: 2577


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the test and the session implementation around the relevant methods.
ast-grep outline src/tests/tool-interrupt-state.test.ts --view expanded || true
printf '\n--- session.ts outline ---\n'
ast-grep outline src/session.ts --view expanded || true

printf '\n--- relevant snippets ---\n'
rg -n "restoreScrollback|_applyInterruptedStateToTool|replay" src/tests/tool-interrupt-state.test.ts src/session.ts src/**/*.ts

Repository: saucam/codeoid

Length of output: 11138


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/tests/tool-interrupt-state.test.ts --view expanded || true
printf '\n--- session.ts outline ---\n'
ast-grep outline src/session.ts --view expanded || true

printf '\n--- relevant snippets ---\n'
rg -n "restoreScrollback|_applyInterruptedStateToTool|replay" src/tests/tool-interrupt-state.test.ts src/session.ts src/**/*.ts

Repository: saucam/codeoid

Length of output: 11138


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/tests/tool-interrupt-state.test.ts --view expanded || true
printf '\n--- session.ts outline ---\n'
ast-grep outline src/session.ts --view expanded || true

printf '\n--- relevant snippets ---\n'
rg -n "restoreScrollback|_applyInterruptedStateToTool|replay" src/tests/tool-interrupt-state.test.ts src/session.ts src/**/*.ts

Repository: saucam/codeoid

Length of output: 11138


🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -t f 'session.ts' --exec sh -c 'printf "\nFILE: %s\n" "$1"; cat -n "$1" | sed -n "1,260p"' sh {}

Repository: saucam/codeoid

Length of output: 12680


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the exact restoreScrollback and _applyInterruptedStateToTool implementations.
sed -n '1440,1495p' src/daemon/session.ts
printf '\n---\n'
sed -n '2528,2565p' src/daemon/session.ts
printf '\n--- test excerpt ---\n'
sed -n '296,360p' src/tests/tool-interrupt-state.test.ts

Repository: saucam/codeoid

Length of output: 6382


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the exact restoreScrollback and _applyInterruptedStateToTool implementations.
sed -n '1440,1495p' src/daemon/session.ts
printf '\n---\n'
sed -n '2528,2565p' src/daemon/session.ts
printf '\n--- test excerpt ---\n'
sed -n '296,360p' src/tests/tool-interrupt-state.test.ts

Repository: saucam/codeoid

Length of output: 6382


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the exact restoreScrollback and _applyInterruptedStateToTool implementations.
sed -n '1440,1495p' src/daemon/session.ts
printf '\n---\n'
sed -n '2528,2565p' src/daemon/session.ts
printf '\n--- test excerpt ---\n'
sed -n '296,360p' src/tests/tool-interrupt-state.test.ts

Repository: saucam/codeoid

Length of output: 6382


Seed the scrollback entry without resume reconciliation restoreScrollback already rewrites executing tool calls to cancelled/interrupted, so this test can pass before _applyInterruptedStateToTool runs. Seed the stored message directly in executing so the scrollback update is exercised; the same applies to the test below.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tests/tool-interrupt-state.test.ts` around lines 303 - 325, The
scrollback test is currently relying on restoreScrollback to pre-convert the
tool call state, which hides the behavior being tested. Update this test (and
the one below) to seed the stored message in the executing phase directly in the
session scrollback, then call _applyInterruptedStateToTool on SessionInternal
and verify the replayed message changes to cancelled/interrupted. Use
makeToolCallMessage, restoreScrollback, and _applyInterruptedStateToTool to
locate the relevant setup and keep the assertion focused on the scrollback
replay result.

@saucam
saucam merged commit 5bc05ec into main Jun 25, 2026
5 checks passed
@saucam saucam mentioned this pull request Jun 28, 2026
saucam added a commit that referenced this pull request Jun 28, 2026
Patch release rolling up six fixes/doc changes since v0.1.1:

- file explorer switches workdir on session change (#37)
- interrupted tool calls marked cancelled, not failed (#35)
- writeBatch guarded against in-flight streams; no TUI double-print (#33)
- TUI reconnects + re-mints token on JWT expiry (#34)
- Telegram /attach disconnects old session before switching (#29)
- richer README badges (#27)

Bumps package.json to 0.1.2 so the release.yml tag check passes, and
backfills the previously-undocumented 0.1.1 entry in the CHANGELOG.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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