@@ -335,6 +335,109 @@ describe('useChat', () => {
335335 expect ( result . current . pendingDecision ) . toBeUndefined ( ) ;
336336 expect ( result . current . isStreaming ) . toBe ( false ) ;
337337 } ) ;
338+
339+ it ( 'finds the pending assistant by toolCallId when a later message was appended' , async ( ) => {
340+ // Simulates the queue-based architecture: a system note (modelled as a
341+ // user-role message) lands after the pause, so the trailing message is
342+ // not the assistant carrying the pending tool call.
343+ const assistantWithToolCall = makeAssistantWithToolCall ( ) ;
344+ const trailingNote = makeUser ( 'queued note arrived after pause' , 99 ) ;
345+ const initial : Message [ ] = [
346+ makeUser ( 'hi' ) ,
347+ assistantWithToolCall ,
348+ trailingNote ,
349+ ] ;
350+ const fetchFn = jest . fn (
351+ async ( _url : RequestInfo | URL , _init ?: RequestInit ) : Promise < Response > =>
352+ streamFromEvents ( [
353+ { type : 'agent_start' } ,
354+ { type : 'agent_end' , messages : initial } ,
355+ ] )
356+ ) ;
357+
358+ const { result } = renderHook ( ( ) =>
359+ useChat ( { api : '/chat' , fetch : fetchFn , initialMessages : initial } )
360+ ) ;
361+
362+ await act ( async ( ) => {
363+ await result . current . respondWithDecision ( 'call_1' , 'allow' ) ;
364+ } ) ;
365+
366+ const sent = JSON . parse ( fetchFn . mock . calls [ 0 ] [ 1 ] ! . body as string ) ;
367+ expect ( sent . messages ) . toHaveLength ( 3 ) ;
368+ expect ( sent . messages [ 1 ] . content [ 0 ] ) . toMatchObject ( {
369+ type : 'toolCall' ,
370+ id : 'call_1' ,
371+ decision : 'allow' ,
372+ } ) ;
373+ // Trailing note is preserved in its position.
374+ expect ( sent . messages [ 2 ] ) . toMatchObject ( { role : 'user' , content : 'queued note arrived after pause' } ) ;
375+ } ) ;
376+
377+ it ( 'throws when no assistant has a pending decision for the toolCallId' , async ( ) => {
378+ const { result } = renderHook ( ( ) => useChat ( { api : '/chat' } ) ) ;
379+
380+ await expect (
381+ act ( async ( ) => {
382+ await result . current . respondWithDecision ( 'call_unknown' , 'allow' ) ;
383+ } )
384+ ) . rejects . toThrow ( / N o p e n d i n g d e c i s i o n f o r t o o l C a l l I d ' c a l l _ u n k n o w n ' / ) ;
385+ } ) ;
386+
387+ it ( 'skips assistants whose matching toolCall already has a decision' , async ( ) => {
388+ // Two assistants with different pending toolCallIds. Only the second
389+ // matches; the first should be ignored even though it has a decision
390+ // already attached for its own (unrelated) call.
391+ const earlierWithResolvedDecision = makeFakeAssistantMessage ( {
392+ stopReason : 'toolUse' ,
393+ content : [
394+ {
395+ type : 'toolCall' ,
396+ id : 'call_resolved' ,
397+ name : 'echo' ,
398+ arguments : { text : 'first' } ,
399+ rawArguments : '{"text":"first"}' ,
400+ decision : 'allow' ,
401+ } ,
402+ ] ,
403+ } ) ;
404+ const laterPending = makeAssistantWithToolCall ( ) ;
405+ const initial : Message [ ] = [
406+ makeUser ( 'first' ) ,
407+ earlierWithResolvedDecision ,
408+ makeUser ( 'second' ) ,
409+ laterPending ,
410+ ] ;
411+ const fetchFn = jest . fn (
412+ async ( _url : RequestInfo | URL , _init ?: RequestInit ) : Promise < Response > =>
413+ streamFromEvents ( [
414+ { type : 'agent_start' } ,
415+ { type : 'agent_end' , messages : initial } ,
416+ ] )
417+ ) ;
418+
419+ const { result } = renderHook ( ( ) =>
420+ useChat ( { api : '/chat' , fetch : fetchFn , initialMessages : initial } )
421+ ) ;
422+
423+ await act ( async ( ) => {
424+ await result . current . respondWithDecision ( 'call_1' , 'allow' ) ;
425+ } ) ;
426+
427+ const sent = JSON . parse ( fetchFn . mock . calls [ 0 ] [ 1 ] ! . body as string ) ;
428+ // Earlier assistant's already-resolved decision is untouched.
429+ expect ( sent . messages [ 1 ] . content [ 0 ] ) . toMatchObject ( {
430+ type : 'toolCall' ,
431+ id : 'call_resolved' ,
432+ decision : 'allow' ,
433+ } ) ;
434+ // Later assistant's matching call gets the new decision.
435+ expect ( sent . messages [ 3 ] . content [ 0 ] ) . toMatchObject ( {
436+ type : 'toolCall' ,
437+ id : 'call_1' ,
438+ decision : 'allow' ,
439+ } ) ;
440+ } ) ;
338441 } ) ;
339442
340443 describe ( 'error handling' , ( ) => {
0 commit comments