Skip to content

Commit 93054ef

Browse files
committed
fix(core): pair synthetic ids for parallel same-name tool calls in sanitize to fix Gemini 400
Root cause: Status bar [Gemini] gemini-3.5-flash, no model switch involved. Gemini native upstream returned 400: 'Please ensure that the number of function response parts is equal to the number of function call parts of the function call turn.' Gemini's native protocol does not require functionCall / functionResponse to carry an id. When Gemini parallel-invokes the same tool N times in one turn (e.g. read_file on multiple files, replace at multiple sites), the N functionResponses come back with the same name and no id. The dedup stage in sanitizeRequestContents used key = id || name:NAME, so all N fr collided into the same bucket and only one survived -> the request reached Gemini with N functionCall parts but only 1 functionResponse part -> 400. The unmatchedCalls branch's isToolMatch falls back to name-only when both sides lack id, so the N fc all "matched" the lone fr and no cancel placeholders were synthesized either. None of the previous adapter fixes covered this path: - 421d070 / e5f01a8 only sanitized cross-model (custom adapter) flows. - 4343cb6 paired synthetic ids inside AnthropicConverter only; the Gemini native path never reaches that converter. Fix: At the very top of GeminiChat.sanitizeRequestContents (the single shared cleaner used by all providers and by client.setHistory / resumeChat), run a FIFO synthetic-id pre-pairing pass, but only when at least 2 same-name no-id functionCalls are observed in the entire history. For each such name, mint deterministic synthetic ids (gem_synth_<name>_<counter>) for the no-id fc in source order and queue them by name; then mint the same ids back into the no-id fr in the same order. After this, every parallel same-name pair has a unique key in the dedup map, so the N functionResponses are preserved. Invariants preserved (zero regression): - Single no-id fc + single no-id fr (Claude cross-model migration scenarios from e5f01a8 / 4343cb6): trigger condition not met, sanitize behaves identically. - Either side already has an id: completely untouched. - Synthetic counter is deterministic: idempotent under repeated sanitize. - Independent from AnthropicConverter's own id pairing in 4343cb6. - All five protocol invariants (adjacent-role merge, user-tail prefill guard, no orphan fr, fc/fr count parity, idempotency) still hold. Tests: - 7 new regression cases in sanitizeRequestContents.test.ts (parallel N=2/N=5/mixed names, fr-fewer-than-fc cancel completion, single-noid not triggering, all-id untouched, idempotency). - 235/235 tests pass across sanitize / fixRequestContents / customModelAdapter (sanitize + anthropicIdPairing + gemini3Downgrade + main) / client.setHistory / client.compressionFallback / geminiChat suites. - npm run build green across all 3 workspaces (core / cli / vscode-ui-plugin), TypeScript noEmit clean.
1 parent 4343cb6 commit 93054ef

2 files changed

Lines changed: 298 additions & 0 deletions

File tree

packages/core/src/core/geminiChat.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,94 @@ export class GeminiChat {
437437
* 函数体保持不变;如需更新清洗规则,只在这里修改即可。
438438
*/
439439
static sanitizeRequestContents(requestContents: Content[]): Content[] {
440+
// 🆕 [并行同名工具调用兜底] —— 在任何 dedup 之前给「同名 ≥2 且都无 id」的 fc/fr 配合成 id
441+
//
442+
// 触发场景(用户实拍 400,状态栏全程 [Gemini] gemini-3.5-flash,没切模型):
443+
// Gemini 原生上游报:
444+
// "Please ensure that the number of function response parts
445+
// is equal to the number of function call parts of the function call turn."
446+
//
447+
// 根因:Gemini 协议下 functionCall / functionResponse 的 `id` 字段不强制必填。
448+
// 当 Gemini 在一个 turn 里**并行调用 N 次同名工具**(比如同时 read_file
449+
// 多个文件、并行 replace 多处),写回的 N 个 fr 全是同 name 无 id。
450+
// 而下面去重阶段使用的 key = `id || name:${name}` 会让这 N 个 fr 全部 collide
451+
// 到同一个桶,只保留 1 个 → API 收到 N 个 fc 但只有 1 个 fr → 数量不等 → 400。
452+
//
453+
// `unmatchedCalls` 那一步用的 isToolMatch 在双方都无 id 时回退到「只看 name」,
454+
// N 个 fc 都"匹配"到唯一 1 个 fr,也不会触发 cancel 补全。
455+
//
456+
// 修复策略:进入任何 dedup 之前,专门针对「同名 ≥2 个无 id functionCall」这个
457+
// 触发条件启动「合成 id 配对」—— 给这 N 个 fc 各自分配确定性合成 id,按
458+
// 出现顺序入 FIFO 队列;下游 N 个无 id 同名 fr 按 FIFO 顺序 dequeue 同一个
459+
// 合成 id 回填给自己。这样后续所有 dedup/匹配逻辑就能按 id 区分桶。
460+
//
461+
// 关键约束(保护现有行为,零回归):
462+
// - 仅当**同名无 id fc 出现 ≥2 次**才启动 —— 这是 bug 的唯一触发条件。
463+
// 单个无 id fc 的场景(Claude 跨模型迁移)继续走原 name 模糊匹配路径。
464+
// - 任一侧已经带 id 的 fc/fr 完全不动。
465+
// - 合成 id 用稳定 counter,不依赖 Date.now()/Math.random() —— 幂等性保留。
466+
// - 没匹配上的多余无 id fr 会自然落入下面"移除孤立 functionResponse"分支。
467+
// - 与 4343cb67 在 AnthropicConverter 内的合成 id 配对独立互不干扰。
468+
{
469+
// 第 0 步:扫描所有 functionCall,统计每个 name 下「无 id」实例的数量
470+
const noIdCallsByName = new Map<string, number>();
471+
for (const content of requestContents) {
472+
if (content.role !== MESSAGE_ROLES.MODEL || !content.parts) continue;
473+
for (const part of content.parts) {
474+
const fc = (part as any)?.functionCall;
475+
if (!fc || typeof fc !== 'object') continue;
476+
if (typeof fc.id === 'string' && fc.id.length > 0) continue;
477+
const name = typeof fc.name === 'string' ? fc.name : 'unknown';
478+
noIdCallsByName.set(name, (noIdCallsByName.get(name) ?? 0) + 1);
479+
}
480+
}
481+
482+
// 仅对「同名 ≥2 个无 id fc」的 name 启动 FIFO 合成 id 配对
483+
const namesNeedingSynth = new Set<string>();
484+
for (const [name, count] of noIdCallsByName) {
485+
if (count >= 2) namesNeedingSynth.add(name);
486+
}
487+
488+
if (namesNeedingSynth.size > 0) {
489+
let synthCounter = 0;
490+
const queueByName = new Map<string, string[]>();
491+
const mintId = (name: string) => `gem_synth_${name}_${++synthCounter}`;
492+
493+
// 第 1 遍:给目标 name 的所有无 id functionCall 分配合成 id 并入队
494+
for (const content of requestContents) {
495+
if (content.role !== MESSAGE_ROLES.MODEL || !content.parts) continue;
496+
for (const part of content.parts) {
497+
const fc = (part as any)?.functionCall;
498+
if (!fc || typeof fc !== 'object') continue;
499+
if (typeof fc.id === 'string' && fc.id.length > 0) continue;
500+
const name = typeof fc.name === 'string' ? fc.name : 'unknown';
501+
if (!namesNeedingSynth.has(name)) continue;
502+
const synth = mintId(name);
503+
fc.id = synth;
504+
if (!queueByName.has(name)) queueByName.set(name, []);
505+
queueByName.get(name)!.push(synth);
506+
}
507+
}
508+
509+
// 第 2 遍:目标 name 的无 id functionResponse 按 FIFO 配对回填合成 id
510+
for (const content of requestContents) {
511+
if (content.role !== MESSAGE_ROLES.USER || !content.parts) continue;
512+
for (const part of content.parts) {
513+
const fr = (part as any)?.functionResponse;
514+
if (!fr || typeof fr !== 'object') continue;
515+
if (typeof fr.id === 'string' && fr.id.length > 0) continue;
516+
const name = typeof fr.name === 'string' ? fr.name : 'unknown';
517+
if (!namesNeedingSynth.has(name)) continue;
518+
const queue = queueByName.get(name);
519+
if (queue && queue.length > 0) {
520+
fr.id = queue.shift()!;
521+
}
522+
// 若队列耗尽:fr 找不到对应 fc,留给下面「移除孤立 functionResponse」处理
523+
}
524+
}
525+
}
526+
}
527+
440528
const fixedContents: Content[] = [];
441529

442530
// 🔍 辅助函数:判断 functionCall 和 functionResponse 是否匹配

packages/core/src/core/sanitizeRequestContents.test.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)