@@ -610,3 +610,179 @@ describe('ensureToolResultPairing', () => {
610610 expect ( lastMsg . type ) . toBe ( 'user' )
611611 } )
612612} )
613+
614+ // ─── CC-1215: normalizeMessagesForAPI must not merge assistants across tool_results ──
615+
616+ describe ( 'normalizeMessagesForAPI – thinking + tool_use same turn (CC-1215)' , ( ) => {
617+ test ( 'does not merge same-id assistants across a tool_result boundary' , ( ) => {
618+ // Simulate the streaming sequence when extended thinking + tool_use appear
619+ // in the same turn, and StreamingToolExecutor inserts a tool_result
620+ // between the two assistant content-block messages.
621+ const sharedMessageId = 'msg_shared_001'
622+ const toolUseId = 'toolu_cc1215'
623+
624+ // assistant[thinking] — first content_block_stop yield
625+ const thinkingMsg = createAssistantMessage ( {
626+ content : [
627+ { type : 'thinking' , thinking : 'Let me think...' , signature : 'sig1' } ,
628+ ] ,
629+ } )
630+ thinkingMsg . message . id = sharedMessageId
631+
632+ // user[tool_result] — from StreamingToolExecutor completing fast
633+ const toolResultMsg = createUserMessage ( {
634+ content : [
635+ {
636+ type : 'tool_result' ,
637+ tool_use_id : toolUseId ,
638+ content : '/home/user' ,
639+ } ,
640+ ] ,
641+ } )
642+
643+ // assistant[tool_use] — second content_block_stop yield
644+ const toolUseMsg = createAssistantMessage ( {
645+ content : [
646+ {
647+ type : 'tool_use' ,
648+ id : toolUseId ,
649+ name : 'Bash' ,
650+ input : { command : 'pwd' } ,
651+ } ,
652+ ] ,
653+ } )
654+ toolUseMsg . message . id = sharedMessageId
655+
656+ const messages : Message [ ] = [
657+ makeUserMsg ( 'Run pwd' ) ,
658+ thinkingMsg ,
659+ toolResultMsg ,
660+ toolUseMsg ,
661+ ]
662+
663+ const result = normalizeMessagesForAPI ( messages )
664+
665+ // Before the fix, the backward walk would skip the tool_result and merge
666+ // thinking + tool_use into one assistant. This produced duplicate tool_use
667+ // IDs after ensureToolResultPairing ran, leading to orphaned tool_results
668+ // and consecutive user messages → API 400.
669+ //
670+ // After the fix, the backward walk stops at the tool_result, so the two
671+ // assistants remain separate. The result should have 4 messages:
672+ // user, assistant[thinking], user[tool_result], assistant[tool_use]
673+ expect ( result ) . toHaveLength ( 4 )
674+ expect ( result [ 0 ] ! . type ) . toBe ( 'user' )
675+ expect ( result [ 1 ] ! . type ) . toBe ( 'assistant' )
676+ expect ( result [ 2 ] ! . type ) . toBe ( 'user' )
677+ expect ( result [ 3 ] ! . type ) . toBe ( 'assistant' )
678+
679+ // The thinking assistant should NOT have been merged with the tool_use one
680+ const thinkingAssistant = result [ 1 ] as AssistantMessage
681+ const thinkingContent = thinkingAssistant . message . content as Array < {
682+ type : string
683+ } >
684+ expect ( thinkingContent . some ( b => b . type === 'tool_use' ) ) . toBe ( false )
685+
686+ const toolUseAssistant = result [ 3 ] as AssistantMessage
687+ const toolUseContent = toolUseAssistant . message . content as Array < {
688+ type : string
689+ } >
690+ expect ( toolUseContent . some ( b => b . type === 'tool_use' ) ) . toBe ( true )
691+ } )
692+
693+ test ( 'still merges consecutive same-id assistants without intervening tool_result' , ( ) => {
694+ const sharedMessageId = 'msg_shared_002'
695+
696+ const thinkingMsg = createAssistantMessage ( {
697+ content : [ { type : 'thinking' , thinking : 'Hmm' , signature : 'sig2' } ] ,
698+ } )
699+ thinkingMsg . message . id = sharedMessageId
700+
701+ const toolUseMsg = createAssistantMessage ( {
702+ content : [
703+ {
704+ type : 'tool_use' ,
705+ id : 'toolu_merge' ,
706+ name : 'Bash' ,
707+ input : { command : 'ls' } ,
708+ } ,
709+ ] ,
710+ } )
711+ toolUseMsg . message . id = sharedMessageId
712+
713+ // No tool_result between them — they should still be merged
714+ const messages : Message [ ] = [
715+ makeUserMsg ( 'List files' ) ,
716+ thinkingMsg ,
717+ toolUseMsg ,
718+ ]
719+
720+ const result = normalizeMessagesForAPI ( messages )
721+
722+ // Should be: user, assistant[thinking + tool_use]
723+ expect ( result ) . toHaveLength ( 2 )
724+ expect ( result [ 0 ] ! . type ) . toBe ( 'user' )
725+
726+ const merged = result [ 1 ] as AssistantMessage
727+ const content = merged . message . content as Array < { type : string } >
728+ expect ( content . some ( b => b . type === 'thinking' ) ) . toBe ( true )
729+ expect ( content . some ( b => b . type === 'tool_use' ) ) . toBe ( true )
730+ } )
731+
732+ test ( 'full pipeline: normalize + ensureToolResultPairing produces valid role alternation' , ( ) => {
733+ const sharedMessageId = 'msg_shared_003'
734+ const toolUseId = 'toolu_pipeline'
735+
736+ const thinkingMsg = createAssistantMessage ( {
737+ content : [
738+ { type : 'thinking' , thinking : 'Planning...' , signature : 'sig3' } ,
739+ ] ,
740+ } )
741+ thinkingMsg . message . id = sharedMessageId
742+
743+ const toolResultMsg = createUserMessage ( {
744+ content : [
745+ {
746+ type : 'tool_result' ,
747+ tool_use_id : toolUseId ,
748+ content : 'file.txt' ,
749+ } ,
750+ ] ,
751+ } )
752+
753+ const toolUseMsg = createAssistantMessage ( {
754+ content : [
755+ {
756+ type : 'tool_use' ,
757+ id : toolUseId ,
758+ name : 'Bash' ,
759+ input : { command : 'ls' } ,
760+ } ,
761+ ] ,
762+ } )
763+ toolUseMsg . message . id = sharedMessageId
764+
765+ // Full pipeline: normalize → ensureToolResultPairing
766+ const normalized = normalizeMessagesForAPI ( [
767+ makeUserMsg ( 'Run ls' ) ,
768+ thinkingMsg ,
769+ toolResultMsg ,
770+ toolUseMsg ,
771+ ] )
772+ const result = ensureToolResultPairing ( normalized )
773+
774+ // Verify strict role alternation: user → assistant → user → assistant → ...
775+ for ( let i = 1 ; i < result . length ; i ++ ) {
776+ const prev = result [ i - 1 ] !
777+ const curr = result [ i ] !
778+ if ( prev . type === 'user' && curr . type === 'user' ) {
779+ expect . unreachable ( `Consecutive user messages at index ${ i - 1 } -${ i } ` )
780+ }
781+ if ( prev . type === 'assistant' && curr . type === 'assistant' ) {
782+ expect . unreachable (
783+ `Consecutive assistant messages at index ${ i - 1 } -${ i } ` ,
784+ )
785+ }
786+ }
787+ } )
788+ } )
0 commit comments