Skip to content

Commit 24622c7

Browse files
fix(ai-persistence): preserve cancelled tool resumes (#1090)
* fix(ai-persistence): preserve cancelled tool resumes * test(ai-persistence): cover cancelled client-tool hydrate resume * chore: add changeset for cancelled client-tool resume --------- Co-authored-by: Alem Tuzlak <t.zlak@hotmail.com>
1 parent 79d8812 commit 24622c7

3 files changed

Lines changed: 136 additions & 42 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@tanstack/ai-persistence': patch
3+
---
4+
5+
`withPersistence` now maps cancelled client-tool and approval resume entries
6+
into `cancelledToolCallIds`. A resume batch that is only cancellations still
7+
produces a `resumeToolState`, so the engine can complete the turn instead of
8+
emitting another `client_tool_*` interrupt.

packages/ai-persistence/src/middleware.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ function resumeToolStateFromPending(
396396
): ChatResumeToolState | undefined {
397397
const approvals = new Map<string, ToolApprovalResolution>()
398398
const clientToolResults = new Map<string, unknown>()
399+
const cancelledToolCallIds = new Set<string>()
399400

400401
for (const interrupt of pending) {
401402
const entry = resumeByInterruptId.get(interrupt.interruptId)
@@ -405,6 +406,10 @@ function resumeToolStateFromPending(
405406
const reason = stringField(interrupt.payload, 'reason')
406407
const toolCallId = stringField(interrupt.payload, 'toolCallId')
407408

409+
if (entry.status === 'cancelled' && toolCallId) {
410+
cancelledToolCallIds.add(toolCallId)
411+
}
412+
408413
if (kind === 'approval' || reason === 'approval_required') {
409414
approvals.set(interrupt.interruptId, resolvedApprovalDecision(entry))
410415
continue
@@ -419,8 +424,14 @@ function resumeToolStateFromPending(
419424
}
420425
}
421426

422-
if (approvals.size === 0 && clientToolResults.size === 0) return undefined
423-
return { approvals, clientToolResults }
427+
if (
428+
approvals.size === 0 &&
429+
clientToolResults.size === 0 &&
430+
cancelledToolCallIds.size === 0
431+
) {
432+
return undefined
433+
}
434+
return { approvals, clientToolResults, cancelledToolCallIds }
424435
}
425436

426437
/**

packages/ai-persistence/tests/interrupts.test.ts

Lines changed: 115 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it, vi } from 'vitest'
2-
import { EventType, chat } from '@tanstack/ai'
2+
import { EventType, chat, defineChatMiddleware } from '@tanstack/ai'
33
import type { AnyTextAdapter, StreamChunk, Tool } from '@tanstack/ai'
44
import { memoryPersistence } from '../src/memory'
55
import { withPersistence } from '../src/middleware'
@@ -87,6 +87,39 @@ const runFinished = (runId = 'r1'): StreamChunk => ({
8787
timestamp: 1,
8888
})
8989

90+
const toolCallFinished = (runId = 'r1'): StreamChunk => ({
91+
type: EventType.RUN_FINISHED,
92+
runId,
93+
threadId: 't1',
94+
finishReason: 'tool_calls',
95+
timestamp: 1,
96+
})
97+
98+
const toolCallChunks = () => [
99+
runStarted(),
100+
toolStart(),
101+
toolArgs(),
102+
toolCallFinished(),
103+
]
104+
105+
async function persistClientToolTurn(
106+
persistence: ReturnType<typeof memoryPersistence>,
107+
tools: Array<Tool>,
108+
) {
109+
const first = mockAdapter([toolCallChunks()])
110+
await collect(
111+
chat({
112+
adapter: first.adapter,
113+
messages: [{ role: 'user', content: 'hi' }],
114+
tools,
115+
runId: 'r1',
116+
threadId: 't1',
117+
middleware: [withPersistence(persistence)],
118+
}) as AsyncIterable<StreamChunk>,
119+
)
120+
return first
121+
}
122+
90123
const clientTool = (name: string): Tool => ({
91124
name,
92125
description: `${name} client tool`,
@@ -144,20 +177,7 @@ describe('interrupt persistence', () => {
144177
it('does not persist duplicate records before terminal interrupt outcome', async () => {
145178
const persistence = memoryPersistence()
146179
const create = vi.spyOn(persistence.stores.interrupts!, 'create')
147-
const { adapter } = mockAdapter([
148-
[
149-
runStarted(),
150-
toolStart(),
151-
toolArgs(),
152-
{
153-
type: EventType.RUN_FINISHED,
154-
runId: 'r1',
155-
threadId: 't1',
156-
finishReason: 'tool_calls',
157-
timestamp: 1,
158-
},
159-
],
160-
])
180+
const { adapter } = mockAdapter([toolCallChunks()])
161181

162182
await collect(
163183
chat({
@@ -260,29 +280,9 @@ describe('interrupt persistence', () => {
260280
// interrupt. Feeding the client output then drives exactly one model call.
261281
it('applies persisted approval and client-tool resume decisions with empty client messages', async () => {
262282
const persistence = memoryPersistence()
263-
const toolCallChunks = () => [
264-
runStarted(),
265-
toolStart(),
266-
toolArgs(),
267-
{
268-
type: EventType.RUN_FINISHED,
269-
runId: 'r1',
270-
threadId: 't1',
271-
finishReason: 'tool_calls',
272-
timestamp: 1,
273-
} as StreamChunk,
274-
]
275-
const first = mockAdapter([toolCallChunks()])
276-
await collect(
277-
chat({
278-
adapter: first.adapter,
279-
messages: [{ role: 'user', content: 'hi' }],
280-
tools: [approvalClientTool('clientSearch')],
281-
runId: 'r1',
282-
threadId: 't1',
283-
middleware: [withPersistence(persistence)],
284-
}) as AsyncIterable<StreamChunk>,
285-
)
283+
await persistClientToolTurn(persistence, [
284+
approvalClientTool('clientSearch'),
285+
])
286286

287287
const approvalInterrupt = await persistence.stores.interrupts!.get(
288288
'approval_tool-call-1',
@@ -373,6 +373,65 @@ describe('interrupt persistence', () => {
373373
expect(await persistence.stores.interrupts!.listPending('t1')).toEqual([])
374374
})
375375

376+
// Issue #1088: cancelling a hydrated client-tool interrupt under
377+
// withPersistence must complete the turn. Persistence clears `config.resume`
378+
// and must therefore put the cancelled toolCallId on `cancelledToolCallIds`.
379+
// Otherwise the engine treats the stored tool call as unhandled and emits
380+
// another `client_tool_*` interrupt instead of an output-error.
381+
it('completes a cancelled client-tool resume from persisted state with empty client messages', async () => {
382+
const persistence = memoryPersistence()
383+
await persistClientToolTurn(persistence, [clientTool('clientSearch')])
384+
385+
const pending = await persistence.stores.interrupts!.get(
386+
'client_tool_tool-call-1',
387+
)
388+
expect(pending?.status).toBe('pending')
389+
390+
const afterCancel = mockAdapter([
391+
[runStarted(), text('cancelled-and-done'), runFinished('r1')],
392+
])
393+
const chunks = await collect(
394+
chat({
395+
adapter: afterCancel.adapter,
396+
messages: [],
397+
tools: [clientTool('clientSearch')],
398+
runId: 'r1',
399+
threadId: 't1',
400+
resume: [
401+
{
402+
interruptId: 'client_tool_tool-call-1',
403+
status: 'cancelled',
404+
},
405+
],
406+
middleware: [withPersistence(persistence)],
407+
}) as AsyncIterable<StreamChunk>,
408+
)
409+
410+
expect(afterCancel.calls).toHaveLength(1)
411+
expect(chunks).toContainEqual(
412+
expect.objectContaining({
413+
type: EventType.TOOL_CALL_RESULT,
414+
toolCallId: 'tool-call-1',
415+
content: JSON.stringify({ error: 'Tool execution cancelled' }),
416+
}),
417+
)
418+
expect(
419+
chunks.find(
420+
(chunk) =>
421+
chunk.type === EventType.RUN_FINISHED &&
422+
chunk.outcome?.type === 'interrupt',
423+
),
424+
).toBeUndefined()
425+
expect(chunks).toContainEqual(
426+
expect.objectContaining({ delta: 'cancelled-and-done' }),
427+
)
428+
expect(
429+
(await persistence.stores.interrupts!.get('client_tool_tool-call-1'))
430+
?.status,
431+
).toBe('cancelled')
432+
expect(await persistence.stores.interrupts!.listPending('t1')).toEqual([])
433+
})
434+
376435
it('rejects invalid resume entries against pending interrupts', async () => {
377436
const persistence = memoryPersistence()
378437
const first = mockAdapter([[runStarted(), interruptFinished()]])
@@ -775,21 +834,29 @@ describe('interrupt persistence', () => {
775834
})
776835

777836
const run = mockAdapter([[runStarted(), text('ok'), runFinished('r1')]])
837+
const resumeStates: Array<ReadonlySet<string> | undefined> = []
838+
const observeResumeState = defineChatMiddleware({
839+
name: 'observe-resume-state',
840+
onConfig(_ctx, config) {
841+
resumeStates.push(config.resumeToolState?.cancelledToolCallIds)
842+
},
843+
})
778844
await collect(
779845
chat({
780846
adapter: run.adapter,
781847
messages: [],
782848
runId: 'r1',
783849
threadId: 't1',
784850
resume: [{ interruptId: 'approval-1', status: 'cancelled' }],
785-
middleware: [withPersistence(persistence)],
851+
middleware: [withPersistence(persistence), observeResumeState],
786852
}) as AsyncIterable<StreamChunk>,
787853
)
788854

789855
const approvals = (
790856
run.calls[0] as { approvals?: ReadonlyMap<string, boolean> }
791857
).approvals
792858
expect(approvals?.get('approval-1')).toBe(false)
859+
expect(resumeStates[0]?.has('tc1')).toBe(true)
793860
expect(
794861
(await persistence.stores.interrupts!.get('approval-1'))?.status,
795862
).toBe('cancelled')
@@ -806,6 +873,13 @@ describe('interrupt persistence', () => {
806873
})
807874

808875
const run = mockAdapter([[runStarted(), text('ok'), runFinished('r1')]])
876+
const resumeStates: Array<ReadonlySet<string> | undefined> = []
877+
const observeResumeState = defineChatMiddleware({
878+
name: 'observe-resume-state',
879+
onConfig(_ctx, config) {
880+
resumeStates.push(config.resumeToolState?.cancelledToolCallIds)
881+
},
882+
})
809883
await collect(
810884
chat({
811885
adapter: run.adapter,
@@ -819,7 +893,7 @@ describe('interrupt persistence', () => {
819893
payload: { answer: 99 },
820894
},
821895
],
822-
middleware: [withPersistence(persistence)],
896+
middleware: [withPersistence(persistence), observeResumeState],
823897
}) as AsyncIterable<StreamChunk>,
824898
)
825899

@@ -829,6 +903,7 @@ describe('interrupt persistence', () => {
829903
run.calls[0] as { clientToolResults?: ReadonlyMap<string, unknown> }
830904
).clientToolResults
831905
expect(clientToolResults?.get('tc1')).toBeUndefined()
906+
expect(resumeStates[0]?.has('tc1')).toBe(true)
832907
expect((await persistence.stores.interrupts!.get('client-1'))?.status).toBe(
833908
'cancelled',
834909
)

0 commit comments

Comments
 (0)