@@ -664,3 +664,213 @@ describe('GeminiChat.sanitizeRequestContents > 协议不变量', () => {
664664 assertContractInvariants ( GeminiChat . sanitizeRequestContents ( input ) ) ;
665665 } ) ;
666666} ) ;
667+
668+ // ─────────────────────────────────────────────────────────────────────
669+ // 9. 并行同名工具调用兜底(用户实拍 400 回归)
670+ //
671+ // 状态栏 [Gemini] gemini-3.5-flash 全程未切模型,
672+ // 错误是 Gemini 原生上游 400:
673+ // "Please ensure that the number of function response parts is equal
674+ // to the number of function call parts of the function call turn."
675+ //
676+ // 触发条件:单个 model turn 里**并行调用 N 次同名工具**(如同时
677+ // read_file 多个文件、replace 多处),按 Gemini 原生协议 functionCall /
678+ // functionResponse 的 id 都允许缺省,于是 N 个 fr 都同名无 id,去重阶段
679+ // 误判为「重复响应」只保留 1 个 → 400。
680+ //
681+ // 修复行为:sanitize 在最开头做 FIFO 合成 id 配对,仅当**同名 ≥2 个无 id
682+ // fc** 触发;任一侧已带 id / 单条无 id 都不动(保护现有 Claude 跨模型场景)。
683+ // ─────────────────────────────────────────────────────────────────────
684+ describe ( 'GeminiChat.sanitizeRequestContents > 并行同名工具调用兜底' , ( ) => {
685+ it ( '单 turn 内并行调用 2 次同名工具(双方都无 id):两组 fc/fr 全部保留' , ( ) => {
686+ const input : Content [ ] = [
687+ { role : MESSAGE_ROLES . USER , parts : [ { text : '同时读两个文件' } as any ] } ,
688+ {
689+ role : MESSAGE_ROLES . MODEL ,
690+ parts : [
691+ { functionCall : { name : 'read_file' , args : { absolute_path : '/a.css' } } as any } ,
692+ { functionCall : { name : 'read_file' , args : { absolute_path : '/b.css' } } as any } ,
693+ ] ,
694+ } ,
695+ {
696+ role : MESSAGE_ROLES . USER ,
697+ parts : [
698+ { functionResponse : { name : 'read_file' , response : { output : 'AAA' } } as any } ,
699+ { functionResponse : { name : 'read_file' , response : { output : 'BBB' } } as any } ,
700+ ] ,
701+ } ,
702+ ] ;
703+
704+ const result = GeminiChat . sanitizeRequestContents ( input ) ;
705+
706+ // 关键不变量:fc 的数量必须 === fr 的数量(这正是 Gemini 400 的判定式)
707+ const fcCount = result . flatMap ( c => c . parts || [ ] ) . filter ( ( p : any ) => p . functionCall ) . length ;
708+ const frCount = result . flatMap ( c => c . parts || [ ] ) . filter ( ( p : any ) => p . functionResponse ) . length ;
709+ expect ( fcCount ) . toBe ( 2 ) ;
710+ expect ( frCount ) . toBe ( 2 ) ;
711+
712+ // 两组 fr 的内容必须都被保留(按出现顺序 FIFO 配对到 fc)
713+ const frs = result . flatMap ( c => c . parts || [ ] ) . filter ( ( p : any ) => p . functionResponse ) ;
714+ const outputs = frs . map ( ( p : any ) => p . functionResponse . response . output ) . sort ( ) ;
715+ expect ( outputs ) . toEqual ( [ 'AAA' , 'BBB' ] ) ;
716+ } ) ;
717+
718+ it ( '并行 N 次同名工具(N=5,双方都无 id):全部 5 组 fc/fr 都保留,数量严格相等' , ( ) => {
719+ const input : Content [ ] = [
720+ {
721+ role : MESSAGE_ROLES . MODEL ,
722+ parts : Array . from ( { length : 5 } , ( _ , i ) => ( {
723+ functionCall : { name : 'replace' , args : { idx : i } } as any ,
724+ } ) ) ,
725+ } ,
726+ {
727+ role : MESSAGE_ROLES . USER ,
728+ parts : Array . from ( { length : 5 } , ( _ , i ) => ( {
729+ functionResponse : { name : 'replace' , response : { idx : i } } as any ,
730+ } ) ) ,
731+ } ,
732+ ] ;
733+
734+ const result = GeminiChat . sanitizeRequestContents ( input ) ;
735+ const fcCount = result . flatMap ( c => c . parts || [ ] ) . filter ( ( p : any ) => p . functionCall ) . length ;
736+ const frCount = result . flatMap ( c => c . parts || [ ] ) . filter ( ( p : any ) => p . functionResponse ) . length ;
737+ expect ( fcCount ) . toBe ( 5 ) ;
738+ expect ( frCount ) . toBe ( 5 ) ;
739+ } ) ;
740+
741+ it ( '并行调用混合多个名字(read_file × 2 + glob × 2):各自按 name 独立 FIFO,互不串扰' , ( ) => {
742+ const input : Content [ ] = [
743+ {
744+ role : MESSAGE_ROLES . MODEL ,
745+ parts : [
746+ { functionCall : { name : 'read_file' , args : { p : 'a' } } as any } ,
747+ { functionCall : { name : 'glob' , args : { p : '**/*' } } as any } ,
748+ { functionCall : { name : 'read_file' , args : { p : 'b' } } as any } ,
749+ { functionCall : { name : 'glob' , args : { p : '*.ts' } } as any } ,
750+ ] ,
751+ } ,
752+ {
753+ role : MESSAGE_ROLES . USER ,
754+ parts : [
755+ { functionResponse : { name : 'read_file' , response : { from : 'a' } } as any } ,
756+ { functionResponse : { name : 'glob' , response : { from : '**/*' } } as any } ,
757+ { functionResponse : { name : 'read_file' , response : { from : 'b' } } as any } ,
758+ { functionResponse : { name : 'glob' , response : { from : '*.ts' } } as any } ,
759+ ] ,
760+ } ,
761+ ] ;
762+
763+ const result = GeminiChat . sanitizeRequestContents ( input ) ;
764+ const frs = result
765+ . flatMap ( c => c . parts || [ ] )
766+ . filter ( ( p : any ) => p . functionResponse )
767+ . map ( ( p : any ) => `${ p . functionResponse . name } :${ p . functionResponse . response . from } ` )
768+ . sort ( ) ;
769+ expect ( frs ) . toEqual ( [ 'glob:**/*' , 'glob:*.ts' , 'read_file:a' , 'read_file:b' ] ) ;
770+ } ) ;
771+
772+ it ( '并行调用但 fr 比 fc 少(fc=3 fr=2):少出来的那个 fc 应触发 cancel 补全' , ( ) => {
773+ const input : Content [ ] = [
774+ {
775+ role : MESSAGE_ROLES . MODEL ,
776+ parts : [
777+ { functionCall : { name : 'read_file' , args : { p : 'a' } } as any } ,
778+ { functionCall : { name : 'read_file' , args : { p : 'b' } } as any } ,
779+ { functionCall : { name : 'read_file' , args : { p : 'c' } } as any } ,
780+ ] ,
781+ } ,
782+ {
783+ role : MESSAGE_ROLES . USER ,
784+ parts : [
785+ { functionResponse : { name : 'read_file' , response : { from : 'a' } } as any } ,
786+ { functionResponse : { name : 'read_file' , response : { from : 'b' } } as any } ,
787+ ] ,
788+ } ,
789+ ] ;
790+
791+ const result = GeminiChat . sanitizeRequestContents ( input ) ;
792+ const fcCount = result . flatMap ( c => c . parts || [ ] ) . filter ( ( p : any ) => p . functionCall ) . length ;
793+ const frCount = result . flatMap ( c => c . parts || [ ] ) . filter ( ( p : any ) => p . functionResponse ) . length ;
794+ // 数量必须严格相等(Gemini 400 的判定式)
795+ expect ( fcCount ) . toBe ( frCount ) ;
796+ expect ( fcCount ) . toBe ( 3 ) ;
797+
798+ // 必有恰好 1 个 cancel 占位(补给那个少出来的 fc),另外 2 个是真实 fr
799+ const frs = result . flatMap ( c => c . parts || [ ] ) . filter ( ( p : any ) => p . functionResponse ) ;
800+ const cancels = frs . filter ( ( p : any ) => p . functionResponse . response ?. result === 'user cancel' ) ;
801+ expect ( cancels . length ) . toBe ( 1 ) ;
802+ } ) ;
803+
804+ it ( '单条无 id fc + 单条无 id fr:不触发合成 id 路径,按原 name 模糊匹配(行为零变化)' , ( ) => {
805+ // 这条防御了 Claude 跨模型迁移场景(commit e5f01a81)
806+ const input : Content [ ] = [
807+ {
808+ role : MESSAGE_ROLES . MODEL ,
809+ parts : [ { functionCall : { name : 'glob' , args : { pattern : '**/*' } } as any } ] ,
810+ } ,
811+ {
812+ role : MESSAGE_ROLES . USER ,
813+ parts : [ { functionResponse : { name : 'glob' , response : { ok : true } } as any } ] ,
814+ } ,
815+ ] ;
816+
817+ const result = GeminiChat . sanitizeRequestContents ( input ) ;
818+ const fc = result . flatMap ( c => c . parts || [ ] ) . find ( ( p : any ) => p . functionCall ) ;
819+ const fr = result . flatMap ( c => c . parts || [ ] ) . find ( ( p : any ) => p . functionResponse ) ;
820+ // 单条无 id 不应该被加合成 id —— 维持原始无 id 状态(兼容现有 Claude 测试期望)
821+ expect ( ( fc as any ) . functionCall . id ) . toBeUndefined ( ) ;
822+ expect ( ( fr as any ) . functionResponse . id ) . toBeUndefined ( ) ;
823+ } ) ;
824+
825+ it ( '并行同名 fc 已经带 id:完全不走合成 id 路径,原 id 保留' , ( ) => {
826+ const input : Content [ ] = [
827+ {
828+ role : MESSAGE_ROLES . MODEL ,
829+ parts : [
830+ { functionCall : { name : 'read_file' , id : 'real-1' , args : { } } as any } ,
831+ { functionCall : { name : 'read_file' , id : 'real-2' , args : { } } as any } ,
832+ ] ,
833+ } ,
834+ {
835+ role : MESSAGE_ROLES . USER ,
836+ parts : [
837+ { functionResponse : { name : 'read_file' , id : 'real-1' , response : { } } as any } ,
838+ { functionResponse : { name : 'read_file' , id : 'real-2' , response : { } } as any } ,
839+ ] ,
840+ } ,
841+ ] ;
842+
843+ const result = GeminiChat . sanitizeRequestContents ( input ) ;
844+ const fcs = result . flatMap ( c => c . parts || [ ] ) . filter ( ( p : any ) => p . functionCall ) ;
845+ expect ( ( fcs [ 0 ] as any ) . functionCall . id ) . toBe ( 'real-1' ) ;
846+ expect ( ( fcs [ 1 ] as any ) . functionCall . id ) . toBe ( 'real-2' ) ;
847+ } ) ;
848+
849+ it ( '幂等性:并行同名场景下,连续清洗两次结果完全一致' , ( ) => {
850+ const dirty : Content [ ] = [
851+ {
852+ role : MESSAGE_ROLES . MODEL ,
853+ parts : [
854+ { functionCall : { name : 'read_file' , args : { p : 'a' } } as any } ,
855+ { functionCall : { name : 'read_file' , args : { p : 'b' } } as any } ,
856+ ] ,
857+ } ,
858+ {
859+ role : MESSAGE_ROLES . USER ,
860+ parts : [
861+ { functionResponse : { name : 'read_file' , response : { from : 'a' } } as any } ,
862+ { functionResponse : { name : 'read_file' , response : { from : 'b' } } as any } ,
863+ ] ,
864+ } ,
865+ ] ;
866+
867+ // 注意:sanitize 会 mutate 原对象,所以两次必须用各自的深拷贝
868+ const once = GeminiChat . sanitizeRequestContents ( JSON . parse ( JSON . stringify ( dirty ) ) ) ;
869+ const twice = GeminiChat . sanitizeRequestContents ( JSON . parse ( JSON . stringify ( dirty ) ) ) ;
870+ expect ( twice ) . toEqual ( once ) ;
871+
872+ // 把第一次的产出再次清洗,结果也应稳定
873+ const onceAgain = GeminiChat . sanitizeRequestContents ( JSON . parse ( JSON . stringify ( once ) ) ) ;
874+ expect ( onceAgain ) . toEqual ( once ) ;
875+ } ) ;
876+ } ) ;
0 commit comments