11import { describe , expect , it , vi } from 'vitest'
2- import { EventType , chat } from '@tanstack/ai'
2+ import { EventType , chat , defineChatMiddleware } from '@tanstack/ai'
33import type { AnyTextAdapter , StreamChunk , Tool } from '@tanstack/ai'
44import { memoryPersistence } from '../src/memory'
55import { 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+
90123const 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