TanStack AI version
@tanstack/ai v0.54.0 (also v0.53.0)
Framework/Library version
Framework-agnostic (plain Node.js)
Describe the bug and the steps to reproduce it
A cancelled or user-denied tool call is emitted with the same shape as a genuine failure, so a consumer can't tell them apart. Both produce state: 'output-error' + { error: <hardcoded string> } (packages/ai/src/activities/chat/tools/tool-calls.ts):
- Cancel (
:807-813): { error: 'Tool execution cancelled' }
- Deny (
:912-923, :1001-1009, via deniedApprovalResult :486-490): { error: 'User declined tool execution' }
That is identical to real failures — unknown tool (:787), arg-parse (:826), validation (:848). The only discriminator is the hardcoded English string; there's no distinct state, since ToolResultState is 'streaming' | 'complete' | 'error' (types.ts:76).
The framework does know the outcome structurally — the InterruptStore record's status is 'cancelled' — but that's discarded when the TOOL_CALL_RESULT is built, and only { error: <string> } survives into the persisted ModelMessage a UI re-hydrates from. So after reload, matching that localizable string is the only option left.
Runnable repro (no API key) + verified output are in the linked sandbox: an approval-deny and a genuine execute() throw come back byte-identical except for the error string.
Expected: a structured, persistable signal for "user cancelled/declined", distinct from a failure — a terminal state, a discriminator on the result, or (minimally) exported constants for the two strings. Ideally carry through the outcome the InterruptStore already records.
Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
https://stackblitz.com/github/oztune/tanstack-ai-cancel-repro
Screenshots or Videos (Optional)
No response
Do you intend to try to help solve this bug with your own PR?
No response
Terms & Code of Conduct
TanStack AI version
@tanstack/ai v0.54.0 (also v0.53.0)
Framework/Library version
Framework-agnostic (plain Node.js)
Describe the bug and the steps to reproduce it
A cancelled or user-denied tool call is emitted with the same shape as a genuine failure, so a consumer can't tell them apart. Both produce
state: 'output-error'+{ error: <hardcoded string> }(packages/ai/src/activities/chat/tools/tool-calls.ts)::807-813):{ error: 'Tool execution cancelled' }:912-923,:1001-1009, viadeniedApprovalResult:486-490):{ error: 'User declined tool execution' }That is identical to real failures — unknown tool (
:787), arg-parse (:826), validation (:848). The only discriminator is the hardcoded English string; there's no distinct state, sinceToolResultStateis'streaming' | 'complete' | 'error'(types.ts:76).The framework does know the outcome structurally — the
InterruptStorerecord's status is'cancelled'— but that's discarded when theTOOL_CALL_RESULTis built, and only{ error: <string> }survives into the persistedModelMessagea UI re-hydrates from. So after reload, matching that localizable string is the only option left.Runnable repro (no API key) + verified output are in the linked sandbox: an approval-deny and a genuine
execute()throw come back byte-identical except for the error string.Expected: a structured, persistable signal for "user cancelled/declined", distinct from a failure — a terminal state, a discriminator on the result, or (minimally) exported constants for the two strings. Ideally carry through the outcome the
InterruptStorealready records.Your Minimal, Reproducible Example - (Sandbox Highly Recommended)
https://stackblitz.com/github/oztune/tanstack-ai-cancel-repro
Screenshots or Videos (Optional)
No response
Do you intend to try to help solve this bug with your own PR?
No response
Terms & Code of Conduct