@@ -336,3 +336,196 @@ describe("mergeConsecutiveUserTurns", () => {
336336 expect ( merged . timestamp ) . toBe ( 1000 ) ;
337337 } ) ;
338338} ) ;
339+
340+ describe ( "validateAnthropicTurns strips dangling tool_use blocks" , ( ) => {
341+ it ( "should strip tool_use blocks without matching tool_result" , ( ) => {
342+ // Simulates: user asks -> assistant has tool_use -> user responds without tool_result
343+ // This happens after compaction trims history
344+ const msgs = asMessages ( [
345+ { role : "user" , content : [ { type : "text" , text : "Use tool" } ] } ,
346+ {
347+ role : "assistant" ,
348+ content : [
349+ { type : "toolUse" , id : "tool-1" , name : "test" , input : { } } ,
350+ { type : "text" , text : "I'll check that" } ,
351+ ] ,
352+ } ,
353+ { role : "user" , content : [ { type : "text" , text : "Hello" } ] } ,
354+ ] ) ;
355+
356+ const result = validateAnthropicTurns ( msgs ) ;
357+
358+ expect ( result ) . toHaveLength ( 3 ) ;
359+ // The dangling tool_use should be stripped, but text content preserved
360+ const assistantContent = ( result [ 1 ] as { content ?: unknown [ ] } ) . content ;
361+ expect ( assistantContent ) . toEqual ( [ { type : "text" , text : "I'll check that" } ] ) ;
362+ } ) ;
363+
364+ it ( "should preserve tool_use blocks with matching tool_result" , ( ) => {
365+ const msgs = asMessages ( [
366+ { role : "user" , content : [ { type : "text" , text : "Use tool" } ] } ,
367+ {
368+ role : "assistant" ,
369+ content : [
370+ { type : "toolUse" , id : "tool-1" , name : "test" , input : { } } ,
371+ { type : "text" , text : "Here's result" } ,
372+ ] ,
373+ } ,
374+ {
375+ role : "user" ,
376+ content : [
377+ { type : "toolResult" , toolUseId : "tool-1" , content : [ { type : "text" , text : "Result" } ] } ,
378+ { type : "text" , text : "Thanks" } ,
379+ ] ,
380+ } ,
381+ ] ) ;
382+
383+ const result = validateAnthropicTurns ( msgs ) ;
384+
385+ expect ( result ) . toHaveLength ( 3 ) ;
386+ // tool_use should be preserved because matching tool_result exists
387+ const assistantContent = ( result [ 1 ] as { content ?: unknown [ ] } ) . content ;
388+ expect ( assistantContent ) . toEqual ( [
389+ { type : "toolUse" , id : "tool-1" , name : "test" , input : { } } ,
390+ { type : "text" , text : "Here's result" } ,
391+ ] ) ;
392+ } ) ;
393+
394+ it ( "should insert fallback text when all content would be removed" , ( ) => {
395+ const msgs = asMessages ( [
396+ { role : "user" , content : [ { type : "text" , text : "Use tool" } ] } ,
397+ {
398+ role : "assistant" ,
399+ content : [ { type : "toolUse" , id : "tool-1" , name : "test" , input : { } } ] ,
400+ } ,
401+ { role : "user" , content : [ { type : "text" , text : "Hello" } ] } ,
402+ ] ) ;
403+
404+ const result = validateAnthropicTurns ( msgs ) ;
405+
406+ expect ( result ) . toHaveLength ( 3 ) ;
407+ // Should insert fallback text since all content would be removed
408+ const assistantContent = ( result [ 1 ] as { content ?: unknown [ ] } ) . content ;
409+ expect ( assistantContent ) . toEqual ( [ { type : "text" , text : "[tool calls omitted]" } ] ) ;
410+ } ) ;
411+
412+ it ( "should handle multiple dangling tool_use blocks" , ( ) => {
413+ const msgs = asMessages ( [
414+ { role : "user" , content : [ { type : "text" , text : "Use tools" } ] } ,
415+ {
416+ role : "assistant" ,
417+ content : [
418+ { type : "toolUse" , id : "tool-1" , name : "test1" , input : { } } ,
419+ { type : "toolUse" , id : "tool-2" , name : "test2" , input : { } } ,
420+ { type : "text" , text : "Done" } ,
421+ ] ,
422+ } ,
423+ { role : "user" , content : [ { type : "text" , text : "OK" } ] } ,
424+ ] ) ;
425+
426+ const result = validateAnthropicTurns ( msgs ) ;
427+
428+ expect ( result ) . toHaveLength ( 3 ) ;
429+ const assistantContent = ( result [ 1 ] as { content ?: unknown [ ] } ) . content ;
430+ // Only text content should remain
431+ expect ( assistantContent ) . toEqual ( [ { type : "text" , text : "Done" } ] ) ;
432+ } ) ;
433+
434+ it ( "should handle mixed tool_use with some having matching tool_result" , ( ) => {
435+ const msgs = asMessages ( [
436+ { role : "user" , content : [ { type : "text" , text : "Use tools" } ] } ,
437+ {
438+ role : "assistant" ,
439+ content : [
440+ { type : "toolUse" , id : "tool-1" , name : "test1" , input : { } } ,
441+ { type : "toolUse" , id : "tool-2" , name : "test2" , input : { } } ,
442+ { type : "text" , text : "Done" } ,
443+ ] ,
444+ } ,
445+ {
446+ role : "user" ,
447+ content : [
448+ {
449+ type : "toolResult" ,
450+ toolUseId : "tool-1" ,
451+ content : [ { type : "text" , text : "Result 1" } ] ,
452+ } ,
453+ { type : "text" , text : "Thanks" } ,
454+ ] ,
455+ } ,
456+ ] ) ;
457+
458+ const result = validateAnthropicTurns ( msgs ) ;
459+
460+ expect ( result ) . toHaveLength ( 3 ) ;
461+ // tool-1 should be preserved (has matching tool_result), tool-2 stripped, text preserved
462+ const assistantContent = ( result [ 1 ] as { content ?: unknown [ ] } ) . content ;
463+ expect ( assistantContent ) . toEqual ( [
464+ { type : "toolUse" , id : "tool-1" , name : "test1" , input : { } } ,
465+ { type : "text" , text : "Done" } ,
466+ ] ) ;
467+ } ) ;
468+
469+ it ( "should not modify messages when next is not user" , ( ) => {
470+ const msgs = asMessages ( [
471+ { role : "user" , content : [ { type : "text" , text : "Use tool" } ] } ,
472+ {
473+ role : "assistant" ,
474+ content : [ { type : "toolUse" , id : "tool-1" , name : "test" , input : { } } ] ,
475+ } ,
476+ // Next is assistant, not user - should not strip
477+ { role : "assistant" , content : [ { type : "text" , text : "Continue" } ] } ,
478+ ] ) ;
479+
480+ const result = validateAnthropicTurns ( msgs ) ;
481+
482+ expect ( result ) . toHaveLength ( 3 ) ;
483+ // Original tool_use should be preserved
484+ const assistantContent = ( result [ 1 ] as { content ?: unknown [ ] } ) . content ;
485+ expect ( assistantContent ) . toEqual ( [ { type : "toolUse" , id : "tool-1" , name : "test" , input : { } } ] ) ;
486+ } ) ;
487+
488+ it ( "is replay-safe across repeated validation passes" , ( ) => {
489+ const msgs = asMessages ( [
490+ { role : "user" , content : [ { type : "text" , text : "Use tools" } ] } ,
491+ {
492+ role : "assistant" ,
493+ content : [
494+ { type : "toolUse" , id : "tool-1" , name : "test1" , input : { } } ,
495+ { type : "toolUse" , id : "tool-2" , name : "test2" , input : { } } ,
496+ { type : "text" , text : "Done" } ,
497+ ] ,
498+ } ,
499+ {
500+ role : "user" ,
501+ content : [
502+ {
503+ type : "toolResult" ,
504+ toolUseId : "tool-1" ,
505+ content : [ { type : "text" , text : "Result 1" } ] ,
506+ } ,
507+ ] ,
508+ } ,
509+ ] ) ;
510+
511+ const firstPass = validateAnthropicTurns ( msgs ) ;
512+ const secondPass = validateAnthropicTurns ( firstPass ) ;
513+
514+ expect ( secondPass ) . toEqual ( firstPass ) ;
515+ } ) ;
516+
517+ it ( "does not crash when assistant content is non-array" , ( ) => {
518+ const msgs = [
519+ { role : "user" , content : [ { type : "text" , text : "Use tool" } ] } ,
520+ {
521+ role : "assistant" ,
522+ content : "legacy-content" ,
523+ } ,
524+ { role : "user" , content : [ { type : "text" , text : "Thanks" } ] } ,
525+ ] as unknown as AgentMessage [ ] ;
526+
527+ expect ( ( ) => validateAnthropicTurns ( msgs ) ) . not . toThrow ( ) ;
528+ const result = validateAnthropicTurns ( msgs ) ;
529+ expect ( result ) . toHaveLength ( 3 ) ;
530+ } ) ;
531+ } ) ;
0 commit comments