@@ -354,7 +354,10 @@ describe('mergeStreamContent + finalize end-to-end (streamed local_time call)',
354354} ) ;
355355
356356describe ( 'DeepVServerAdapter.cleanContents' , ( ) => {
357- const cleanContents = ( contents : any [ ] ) => proto . cleanContents . call ( { } , contents ) ;
357+ // cleanContents 内部会调用 this.removeOrphanedToolResponses,
358+ // 白盒测试需提供一个挂载了该方法的 this 上下文。
359+ const ctx = { removeOrphanedToolResponses : proto . removeOrphanedToolResponses } ;
360+ const cleanContents = ( contents : any [ ] ) => proto . cleanContents . call ( ctx , contents ) ;
358361
359362 it ( 'filters out empty or whitespace-only text parts within a message' , ( ) => {
360363 const input = [
@@ -402,3 +405,201 @@ describe('DeepVServerAdapter.cleanContents', () => {
402405 expect ( result [ 0 ] . parts [ 0 ] . text ) . toBe ( 'Keep this' ) ;
403406 } ) ;
404407} ) ;
408+
409+ /**
410+ * 单元测试:DeepVServerAdapter.removeOrphanedToolResponses
411+ *
412+ * 守护生产 bug 修复(用户实拍 easyrouter 400):
413+ * "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'"
414+ *
415+ * 根因:上下文压缩/历史截断把 functionCall 切掉、却保留了它的 functionResponse,
416+ * 形成"孤儿 functionResponse"。该孤儿被客户端 sanitizeRequestContents 的
417+ * "全局 name 兜底"误判为有配对而存活,发往云端转 OpenAI 格式后变成无前驱
418+ * tool_calls 的 role:'tool' 消息 → 严格网关 (easyrouter) 报 400。
419+ *
420+ * 修复策略(对 Gemini / Claude / OpenAI 三家均无害):在发往云端的出口处,
421+ * 按"真实配对关系 + 计数消耗"删除真孤儿——
422+ * - 优先 id 精确配对(消耗,防两个 fr 抢同一个 fc)
423+ * - 回退 name 计数配对(N 个同名 fc 最多配 N 个 fr,多出的才删)
424+ * 合法会话里 fr 数 ≤ 同名 fc 数,计数永远够,不会删任何合法配对。
425+ */
426+ describe ( 'DeepVServerAdapter.removeOrphanedToolResponses' , ( ) => {
427+ const removeOrphans = ( contents : any [ ] ) =>
428+ proto . removeOrphanedToolResponses . call ( { } , contents ) ;
429+
430+ it ( 'removes a truly orphaned functionResponse (its functionCall was dropped)' , ( ) => {
431+ // 压缩后场景:只剩下一个 functionResponse,对应的 functionCall 已被截断丢弃
432+ const input = [
433+ { role : 'user' , parts : [ { text : 'do something' } ] } ,
434+ {
435+ role : 'user' ,
436+ parts : [
437+ { functionResponse : { id : 'read_file-123-abc' , name : 'read_file' , response : { ok : true } } } ,
438+ ] ,
439+ } ,
440+ ] ;
441+
442+ const result = removeOrphans ( input ) ;
443+
444+ // 孤儿 fr 被删除 → 该 user 消息只剩 0 个有效 part → 整条消息移除
445+ expect ( result ) . toHaveLength ( 1 ) ;
446+ expect ( result [ 0 ] . parts [ 0 ] . text ) . toBe ( 'do something' ) ;
447+ } ) ;
448+
449+ it ( 'keeps a functionResponse that has a matching functionCall by id' , ( ) => {
450+ const input = [
451+ {
452+ role : 'model' ,
453+ parts : [ { functionCall : { id : 'call_1' , name : 'read_file' , args : { path : 'a.ts' } } } ] ,
454+ } ,
455+ {
456+ role : 'user' ,
457+ parts : [ { functionResponse : { id : 'call_1' , name : 'read_file' , response : { ok : true } } } ] ,
458+ } ,
459+ ] ;
460+
461+ const result = removeOrphans ( input ) ;
462+
463+ expect ( result ) . toHaveLength ( 2 ) ;
464+ expect ( result [ 1 ] . parts [ 0 ] . functionResponse . id ) . toBe ( 'call_1' ) ;
465+ } ) ;
466+
467+ it ( 'keeps a functionResponse matched by name when ids are absent (Gemini native)' , ( ) => {
468+ // Gemini 原生:functionCall 常无 id,functionResponse 靠 name 配对
469+ const input = [
470+ {
471+ role : 'model' ,
472+ parts : [ { functionCall : { name : 'get_weather' , args : { city : 'SH' } } } ] ,
473+ } ,
474+ {
475+ role : 'user' ,
476+ parts : [ { functionResponse : { name : 'get_weather' , response : { temp : 20 } } } ] ,
477+ } ,
478+ ] ;
479+
480+ const result = removeOrphans ( input ) ;
481+
482+ expect ( result ) . toHaveLength ( 2 ) ;
483+ expect ( result [ 1 ] . parts [ 0 ] . functionResponse . name ) . toBe ( 'get_weather' ) ;
484+ } ) ;
485+
486+ it ( 'does NOT mis-delete parallel same-name calls (N functionCalls ↔ N functionResponses)' , ( ) => {
487+ // 并行同名工具调用:3 个 read_file 全无 id,3 个 fr 也全无 id(或仅 name)
488+ const input = [
489+ {
490+ role : 'model' ,
491+ parts : [
492+ { functionCall : { name : 'read_file' , args : { path : 'a.ts' } } } ,
493+ { functionCall : { name : 'read_file' , args : { path : 'b.ts' } } } ,
494+ { functionCall : { name : 'read_file' , args : { path : 'c.ts' } } } ,
495+ ] ,
496+ } ,
497+ {
498+ role : 'user' ,
499+ parts : [
500+ { functionResponse : { name : 'read_file' , response : { c : 'A' } } } ,
501+ { functionResponse : { name : 'read_file' , response : { c : 'B' } } } ,
502+ { functionResponse : { name : 'read_file' , response : { c : 'C' } } } ,
503+ ] ,
504+ } ,
505+ ] ;
506+
507+ const result = removeOrphans ( input ) ;
508+
509+ // 3 个 fr 全部保留(计数 3 配 3)
510+ expect ( result ) . toHaveLength ( 2 ) ;
511+ expect ( result [ 1 ] . parts ) . toHaveLength ( 3 ) ;
512+ expect ( result [ 1 ] . parts . every ( ( p : any ) => p . functionResponse ?. name === 'read_file' ) ) . toBe ( true ) ;
513+ } ) ;
514+
515+ it ( 'removes only the EXCESS orphan when fr count exceeds same-name fc count' , ( ) => {
516+ // 2 个 fc 但 3 个 fr:保留 2 个、删除 1 个多余孤儿
517+ const input = [
518+ {
519+ role : 'model' ,
520+ parts : [
521+ { functionCall : { name : 'read_file' , args : { path : 'a.ts' } } } ,
522+ { functionCall : { name : 'read_file' , args : { path : 'b.ts' } } } ,
523+ ] ,
524+ } ,
525+ {
526+ role : 'user' ,
527+ parts : [
528+ { functionResponse : { name : 'read_file' , response : { c : 'A' } } } ,
529+ { functionResponse : { name : 'read_file' , response : { c : 'B' } } } ,
530+ { functionResponse : { name : 'read_file' , response : { c : 'C' } } } ,
531+ ] ,
532+ } ,
533+ ] ;
534+
535+ const result = removeOrphans ( input ) ;
536+
537+ expect ( result ) . toHaveLength ( 2 ) ;
538+ expect ( result [ 1 ] . parts ) . toHaveLength ( 2 ) ;
539+ } ) ;
540+
541+ it ( 'does not consume one functionCall for two functionResponses (id pool exhaustion)' , ( ) => {
542+ // 1 个 fc (id=call_1) 但 2 个 fr 都声称 id=call_1:只保留 1 个,另一个判为孤儿
543+ const input = [
544+ {
545+ role : 'model' ,
546+ parts : [ { functionCall : { id : 'call_1' , name : 'read_file' , args : { } } } ] ,
547+ } ,
548+ {
549+ role : 'user' ,
550+ parts : [
551+ { functionResponse : { id : 'call_1' , name : 'read_file' , response : { c : 'A' } } } ,
552+ { functionResponse : { id : 'call_1' , name : 'read_file' , response : { c : 'B' } } } ,
553+ ] ,
554+ } ,
555+ ] ;
556+
557+ const result = removeOrphans ( input ) ;
558+
559+ expect ( result ) . toHaveLength ( 2 ) ;
560+ expect ( result [ 1 ] . parts ) . toHaveLength ( 1 ) ;
561+ } ) ;
562+
563+ it ( 'leaves contents unchanged when there are no functionResponses' , ( ) => {
564+ const input = [
565+ { role : 'user' , parts : [ { text : 'hello' } ] } ,
566+ { role : 'model' , parts : [ { text : 'hi there' } ] } ,
567+ ] ;
568+
569+ const result = removeOrphans ( input ) ;
570+
571+ expect ( result ) . toEqual ( input ) ;
572+ } ) ;
573+
574+ it ( 'leaves a fully paired history untouched (idempotent on healthy data)' , ( ) => {
575+ const input = [
576+ { role : 'user' , parts : [ { text : 'q' } ] } ,
577+ { role : 'model' , parts : [ { functionCall : { id : 'c1' , name : 'read_file' , args : { } } } ] } ,
578+ { role : 'user' , parts : [ { functionResponse : { id : 'c1' , name : 'read_file' , response : { } } } ] } ,
579+ { role : 'model' , parts : [ { text : 'answer' } ] } ,
580+ ] ;
581+
582+ const result = removeOrphans ( input ) ;
583+
584+ expect ( result ) . toEqual ( input ) ;
585+ } ) ;
586+
587+ it ( 'preserves non-functionResponse parts in the same message when removing an orphan' , ( ) => {
588+ // 同一条 user 消息里既有孤儿 fr 又有正常文本:只删 fr,保留文本
589+ const input = [
590+ {
591+ role : 'user' ,
592+ parts : [
593+ { functionResponse : { id : 'ghost-999' , name : 'ghost_tool' , response : { } } } ,
594+ { text : 'user follow-up text' } ,
595+ ] ,
596+ } ,
597+ ] ;
598+
599+ const result = removeOrphans ( input ) ;
600+
601+ expect ( result ) . toHaveLength ( 1 ) ;
602+ expect ( result [ 0 ] . parts ) . toHaveLength ( 1 ) ;
603+ expect ( result [ 0 ] . parts [ 0 ] . text ) . toBe ( 'user follow-up text' ) ;
604+ } ) ;
605+ } ) ;
0 commit comments