Problem
When your app treats cancelled model/tool runs like generic failures, you risk wiping the last good UI: clearing screens, flipping loading flags incorrectly, or showing an alarming error banner for a user-initiated stop. That typically happens when non-ok outcomes are collapsed into one failure path.
Implementation (high level)
Use a small state-policy reducer that returns two orthogonal things: a commit boolean and a meta.kind describing the outcome. Map status values to behavior:
- ok → commit: true, merge payload into state, meta.kind: 'ok'
- cancelled → commit: false, preserve prevState, meta.kind: 'cancelled'
- error → commit: false, preserve prevState, meta.kind: 'error'
Expected behavior
When a tool returns cancelled, the reducer explicitly refuses to commit changes and preserves the prior state. The UI can render a benign cancellation notice tied to meta.kind: 'cancelled' without implying the screen is broken. If the tool returns error, the UI surfaces an actionable failure. For ok, the UI adopts the payload.
Why this matters
Returning an explicit commit flag keeps the data-flow decision in the policy layer and out of the render layer. No-op commits (creating a new state object with the same values) or inferring behavior from messages forces the presentation layer to guess ownership, which can trigger rerenders, reset attached metadata, or hide the distinction between harmless stops and real failures.
Limitations
The example’s merge strategy is intentionally small (a shallow merge) and not a recommendation for nested stores, normalized caches, or immutable collections. This pattern does not address abort propagation, request ownership across multiple layers, stale responses, idempotency, or conflicting writes; those require additional orchestration. Also, if your orchestration layer collapses cancellation and error before the reducer sees them, preserve the distinction earlier in the stack.
Read the full article to see the reducer and tests that assert preserved state on cancellation and the boundaries this pattern does — and does not — cover.
Section IDs referenced:
- why-cancellation-needs-its-own-outcome
- how-the-tool-loop-creates-the-misclassification-risk
- what-the-example-actually-guarantees
- why-an-incomplete-fix-fails
- what-the-tests-prove-and-do-not-prove
- where-this-pattern-stops-being-enough
Working example
export function applyToolResult(prevState, toolResult) {
// toolResult is expected to be one of:
// { status: 'ok', payload: any }
// { status: 'cancelled', reason?: string }
// { status: 'error', error: any }
// The function returns an object describing whether to commit the tool's
// changes into the UI state and metadata to drive UI behavior.
if (!toolResult || typeof toolResult !== 'object') {
return {
commit: false,
state: prevState,
meta: { kind: 'invalid_tool_result', message: 'Tool returned non-object' },
};
}
switch (toolResult.status) {
case 'ok':
// Successful tool run: commit new state derived from payload.
// For the example, we shallow-merge payload into prevState.
return {
commit: true,
state: { ...prevState, ...(toolResult.payload || {}) },
meta: { kind: 'ok' },
};
case 'cancelled':
// Cancellation is not a UI-side error. We deliberately do NOT commit
// changes and we surface a cancellation marker so the UI can preserve
// prior state and optionally show a benign message.
return {
commit: false,
state: prevState,
meta: { kind: 'cancelled', reason: toolResult.reason ?? null },
};
case 'error':
// Real tool error -- treat as actionable failure. Do not commit and
// mark meta for UI error handling. (Different from cancellation.)
return {
commit: false,
state: prevState,
meta: { kind: 'error', error: toolResult.error ?? 'unknown' },
};
default:
return {
commit: false,
state: prevState,
meta: { kind: 'unknown_status', status: toolResult.status },
};
}
}
Read the full article: https://chriseugenerodriguez.com/blog/cancellation-from-a-tool-should-not-be-treated-as-a-ui-side-error
Top comments (0)