anthropics / anthropics/claude-agent-sdk-typescript

Interrupt during an in-flight tool call is misclassified as error_during_execution, and the internal [ede_diagnostic] marker leaks to Agent SDK consumers as a fatal error

Open
#405 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Shell
Stars
1.8k
Forks
226
PR merge metrics
No merged PRs in 30d

Description

## Environment

- `@anthropic-ai/claude-code`: 2.1.220 (native binary, linux-arm64)
- `@anthropic-ai/claude-agent-sdk`: 0.3.165
- Node.js: v24.18.0
- OS: Ubuntu 26.04 LTS, Linux 7.0.0-1008-aws, aarch64
- Consumption path: headless `claude` process driven by the `@anthropic-ai/claude-agent-sdk` `Query` API (JS/TS SDK), not the interactive terminal UI

## Summary

When a user (or an SDK caller) aborts a turn while a tool call is in flight — i.e. the last assistant message has `stop_reason: "tool_use"` and a `tool_use` block has already produced a `tool_result`, and the CLI has appended its own `[Request interrupted by user]` sentinel as the new last message — the CLI engine's terminal-state validator has no matching case for this state and falls through to `subtype: "error_during_execution"`. This is a normal, expected interruption, not an execution error: no work is lost, and the next turn resumes normally.

Separately, and independently, the internal diagnostic string the engine attaches to this synthetic error (`[ede_diagnostic] result_type=... last_content_type=... stop_reason=...`) is explicitly filtered out by the CLI's own local/cloud-session renderer before being shown to a human, which shows the string is intended to be internal-only. The `@anthropic-ai/claude-agent-sdk` package applies no equivalent filter: it joins every entry in `result.errors` (including the `[ede_diagnostic]`-prefixed one) into the `Error` it throws from `Query.readMessages()`. Any headless/SDK consumer of a normal user interrupt therefore sees a raw internal diagnostic string surfaced as a fatal, unhandled error, e.g.:

```
Claude Code returned an error result: [ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use
```

## Root-cause analysis

### 1. The terminal-state predicate has no case for an interrupted tool-in-flight turn

Decompiled from the shipped `claude` binary (`@anthropic-ai/claude-code@2.1.220`, function name `kCo` in the minified bundle):

```js
function kCo(e, t = null) {
if (!e) return false;
if (e.type === "assistant") {
let r = h8(e.message.content);
return r?.type === "text" || r?.type === "thinking" || r?.type === "redacted_thinking";
}
if (e.type === "user") {
let r = e.message.content;
if (Array.isArray(r) && r.length > 0 && r.every(n => "type" in n && n.type === "tool_result")) return true;
}
return t === "end_turn";
}
```

`kCo` is called at the end of a turn as `kCo(Ln, Dt)`, where `Ln` is the last message in the conversation (`type: "assistant"` or `type: "user"`) and `Dt` is the last assistant `stop_reason`. If `kCo` returns `false`, the engine yields:

```js
if (!kCo(Ln, Dt)) {
yield resultEvent({
// ...
variant: {
subtype: "error_during_execution",
errors: (() => {
let allErrors = getRecentErrors(), sliceFrom = ...;
return [
`[ede_diagnostic] result_type=${Ro} last_content_type=${Hi} stop_reason=${Dt}`,
...allErrors.slice(sliceFrom).map(e => e.error)
];
})()
}
});
return;
}
```

When a user aborts a turn while a tool call is in flight, the conversation's last message becomes the interrupt sentinel:

```json
{"type": "user", "message": {"content": [{"type": "text", "text": "[Request interrupted by user]"}]}}
```

while the last assistant `stop_reason` recorded before the interrupt is still `"tool_use"`. Walking `kCo` against this state:

- `e.type === "assistant"` — false (last message is `user`).
- `e.type === "user"` with every content item being `tool_result` — false (the sentinel is a `text` block, not `tool_result`).
- `t === "end_turn"` — false (`t` is `"tool_use"`).

All three arms fail, `kCo` returns `false`, and the engine reports a routine interrupt as `error_during_execution`.

### 2. The diagnostic marker is filtered for humans but not for SDK consumers

The CLI's own session/result renderer strips the `[ede_diagnostic]` line before it reaches a human-facing surface, which demonstrates the string is meant to stay internal:

```js
let filtered = e.errors.filter(r => !r.startsWith("[ede_diagnostic]"));
if (filtered.length === 0) return null;
return { type: "system", subtype: "informational", content: xi(filtered.join(", ")), level: "warning", ... };
```

`@anthropic-ai/claude-agent-sdk@0.3.165` (`sdk.mjs`, class `Query`, method `readMessages`) applies no such filter. It records every error string verbatim:

```js
this.lastErrorResultText = e.is_error
? (e.subtype === "success" ? e.result : e.errors.join("; "))
: void 0;
```

and, on stream close, throws it directly as a fatal `Error`:

```js
if (this.lastErrorResultText !== void 0 && !(e instanceof AbortError)) {
let err = Error(`Claude Code returned an error result: ${this.lastErrorResultText}`);
...
this.inputStream.error(err);
this.cleanup(err);
return;
}
```

`grep -c 'ede_diagnostic' sdk.mjs` returns `0` — the string, and any filter for it, is entirely absent from the SDK package. Consequently every consumer of `@anthropic-ai/claude-agent-sdk` (headless drivers, custom UIs, CI integrations) that surfaces caught errors to a human or a log sees the raw internal marker as an unexplained fatal error on what was a normal interrupt.

## Reproduction steps

1. Drive `claude` headless through `@anthropic-ai/claude-agent-sdk`'s `query()`/`Query` API with a tool-heavy prompt (anything that will call at least one tool).
2. Let the model emit a `tool_use` block and let the tool execute (a `tool_result` is produced).
3. In the short window after the `tool_use`/`tool_result` pair and before the next assistant message begins, call `query.interrupt()` (or send SIGINT to the underlying process from the caller side).
4. Observe the terminal `result` event's `subtype`.

### Expected

The interrupted turn resolves as a clean cancellation (a dedicated subtype, or at minimum `is_error: false`), and no internal diagnostic string is ever visible outside the CLI process.

### Actual

The terminal `result` event is:

```
subtype = "error_during_execution"
is_error = true
stop_reason = "tool_use"
errors = ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"]
```

and `@anthropic-ai/claude-agent-sdk`'s `Query.readMessages()` throws:

```
Error: Claude Code returned an error result: [ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use
```

## Local evidence

- 10 occurrences captured over roughly 4 weeks of normal headless usage, in two bursts (6 in one day, 4 on another), all with `result_type=user`, `last_content_type=n/a`; 8 with `stop_reason=tool_use`, 2 with `stop_reason=null`.
- Canonical trace for the `stop_reason=tool_use` case: assistant emits a `tool_use` block → matching `tool_result` is produced → `[Request interrupted by user]` is appended ~1.15s later → the engine yields `error_during_execution` on the next turn-close.
- Confirmed this is not an OOM, crash, or process restart: no work is lost, and the very next turn resumes and completes normally with `stop_reason: "end_turn"`.

## Impact

- **Headless/Agent SDK consumers** (anything built on `@anthropic-ai/claude-agent-sdk`, including CI drivers, custom chat UIs, and orchestration tooling) cannot distinguish a routine user-initiated interrupt from a genuine execution failure without pattern-matching on an undocumented, internal-only string prefix (`[ede_diagnostic]`).
- Consumers that log or surface caught SDK errors to end users (our case: a custom headless UI) render this as a red/fatal error banner on completely ordinary interrupt behavior, which is confusing and erodes trust in error reporting.
- Any retry/error-handling logic built on `is_error`/`subtype` in an SDK consumer will incorrectly treat routine interrupts as failures (e.g. incrementing error counters, tripping alerting, or refusing to resume).

## Related existing issues (not full duplicates)

Two open issues cover the same underlying `kCo`-style predicate gap from different angles; neither covers the specific `stop_reason=tool_use` sub-case and the SDK-filter gap reported here:

- [`anthropics/claude-agent-sdk-typescript#366`](https://github.com/anthropics/claude-agent-sdk-typescript/issues/366) ("`interrupt()` during the thinking phase resolves the aborted turn as an `is_error` `error_during_execution` result...instead of a clean cancellation", open, filed 2026-07-06) reports the identical `[ede_diagnostic]`/misclassification mechanism, but for the **thinking-phase interrupt sub-case** (`stop_reason=null`, interrupt lands before any assistant text or tool call). It does not cover the `stop_reason=tool_use` sub-case (interrupt while a tool call is in flight — the majority, 8/10, of our captured occurrences), nor the independent SDK-filter gap (item 2 below).
- [`anthropics/claude-code#82235`](https://github.com/anthropics/claude-code/issues/82235) ("Headless: background subagent notification arriving after end_turn is interrupted and reclassifies a completed turn as error_during_execution (2.1.215)", open, filed 2026-07-29) reports the same predicate (`wao` in that version's minified bundle, `kCo` here) failing on yet another last-message shape: a self-triggered `` follow-on turn injected and immediately interrupted *after* `stop_reason=end_turn` (also `stop_reason=null` in the result). It is scoped to headless CLI stdout only and does not mention the Agent SDK's separate, unfiltered `errors.join("; ")` leak path (item 2 below).

Both confirm the structural gap independently: `kCo` enumerates only `end_turn` and all-`tool_result` shapes as terminal, with no case at all for the interrupt sentinel, regardless of which `stop_reason` preceded it. Filing this report against `anthropics/claude-agent-sdk-typescript` (same repo as #366, the closest sibling) for maintainer triage/cross-linking with both, since a fix to `kCo` in the underlying `claude-code` engine bundle would most naturally close all three reports at once.

## Suggested fixes

1. **Treat the interrupt sentinel as a valid terminal state in `kCo`.** Add an explicit case recognizing the last message being the `[Request interrupted by user]` sentinel (or, more robustly, an explicit "aborted" flag threaded through from the interrupt call site) as a clean-cancellation terminal state, distinct from `error_during_execution`, regardless of the preceding `stop_reason` (`tool_use`, `null`, or otherwise). This directly fixes the misclassification for both the tool-in-flight and thinking-phase sub-cases.
2. **Apply the existing `[ede_diagnostic]` filter on the SDK error path too.** `@anthropic-ai/claude-agent-sdk`'s `Query.readMessages()` should apply the same `errors.filter(r => !r.startsWith("[ede_diagnostic]"))` logic the CLI's own session renderer already uses, before joining `result.errors` into `lastErrorResultText`/the thrown `Error`. This is a narrow, low-risk parity fix independent of fix 1, and stops the internal-only string from ever reaching a human or a log outside the CLI process even if some other terminal-state gap is found later.

Fix 1 addresses the root cause (a real interrupt should never be reported as an execution error). Fix 2 is a defense-in-depth parity fix (the diagnostic string should never have been externally observable in the first place) and should be applied regardless of fix 1.

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.