Skip to content

Commit ccc0b93

Browse files
committed
fix(custom-model): reconcile tool_use/tool_result ids on cross-model switch to Anthropic
1 parent 93054ef commit ccc0b93

4 files changed

Lines changed: 464 additions & 107 deletions

File tree

packages/core/src/core/customModelAdapter.anthropicIdPairing.test.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -374,10 +374,90 @@ describe('Anthropic tool_use/tool_result id pairing (cross-model migration)', ()
374374
});
375375

376376
// ───────────────────────────────────────────────────────────────────────
377-
// 兜底场景:彻底孤立的 fr(没有任何 fc 配对,理论上 sanitize 已过滤)
377+
// Case 7:二次事故复现 — fc 无 id,fr 带「真实 CLI callId」(2026-06-04)
378+
//
379+
// 现场:[Gemini] 期间并行 read_file×2(fc 无 id),coreToolScheduler 给两个
380+
// fr 各写入 `read_file-<ts>-<rand>` 真实 callId;切到 [Anthropic] 后 400:
381+
// unexpected `tool_use_id` found in `tool_result` blocks:
382+
// read_file-1780549486950-5f6pb6trd.
383+
//
384+
// 旧实现给 fc 造合成 id、跳过「已有 id」的 fr → tool_use.id ≠ tool_result.id。
385+
// 修复后:fc 必须借用 fr 的真实 id,双方严格一致。
378386
// ───────────────────────────────────────────────────────────────────────
379-
it('sanity: 彻底孤立的 fr 仍然走旧 fallback,不抛异常', async () => {
387+
it('case 7: 并行 read_file×2,fc 无 id + fr 带真实 callId → fc 借用 fr 的真实 id 配对', async () => {
380388
const getBody = makeFetchSpy();
389+
await callAnthropicModel(claudeConfig as any, {
390+
contents: [
391+
{ role: MESSAGE_ROLES.USER, parts: [{ text: '同时读两个文件' }] },
392+
{
393+
role: MESSAGE_ROLES.MODEL,
394+
parts: [
395+
{ functionCall: { name: 'read_file', args: { absolute_path: '/a' } } },
396+
{ functionCall: { name: 'read_file', args: { absolute_path: '/b' } } },
397+
],
398+
},
399+
{
400+
role: MESSAGE_ROLES.USER,
401+
parts: [
402+
{ functionResponse: { name: 'read_file', id: 'read_file-1780549486950-5f6pb6trd', response: { output: 'AAA' } } },
403+
{ functionResponse: { name: 'read_file', id: 'read_file-1780549486951-qq11ww22e', response: { output: 'BBB' } } },
404+
],
405+
},
406+
{ role: MESSAGE_ROLES.USER, parts: [{ text: '继续' }] },
407+
],
408+
});
409+
410+
const body = getBody();
411+
const { toolUses, toolResults } = collectToolPairs(body.messages);
412+
expect(toolUses.length).toBe(2);
413+
expect(toolResults.length).toBe(2);
414+
415+
// 每个 tool_use.id 必须等于真实 CLI callId(绝不是合成前缀)
416+
const useIds = toolUses.map(u => u.id).sort();
417+
expect(useIds).toEqual([
418+
'read_file-1780549486950-5f6pb6trd',
419+
'read_file-1780549486951-qq11ww22e',
420+
]);
421+
expect(useIds.some(id => id.startsWith('toolu_synth_'))).toBe(false);
422+
423+
// 报错的字面量绝不能再出现在 wire body 里(无匹配 tool_use)
424+
const flat = JSON.stringify(body.messages);
425+
expect(flat).toContain('read_file-1780549486950-5f6pb6trd');
426+
assertEveryResultHasMatchingUse(body.messages);
427+
});
428+
429+
// ───────────────────────────────────────────────────────────────────────
430+
// Case 8:单次 read_file,fc 无 id + fr 带真实 callId(最常见的单工具场景)
431+
// ───────────────────────────────────────────────────────────────────────
432+
it('case 8: 单次 fc 无 id + fr 带真实 callId → tool_use 借用该真实 id', async () => {
433+
const getBody = makeFetchSpy();
434+
await callAnthropicModel(claudeConfig as any, {
435+
contents: [
436+
{ role: MESSAGE_ROLES.USER, parts: [{ text: '读文件' }] },
437+
{
438+
role: MESSAGE_ROLES.MODEL,
439+
parts: [{ functionCall: { name: 'read_file', args: { absolute_path: '/x' } } }],
440+
},
441+
{
442+
role: MESSAGE_ROLES.USER,
443+
parts: [{ functionResponse: { name: 'read_file', id: 'read_file-999-abc', response: { output: 'X' } } }],
444+
},
445+
{ role: MESSAGE_ROLES.USER, parts: [{ text: '继续' }] },
446+
],
447+
});
448+
const body = getBody();
449+
const { toolUses, toolResults } = collectToolPairs(body.messages);
450+
expect(toolUses.length).toBe(1);
451+
expect(toolResults.length).toBe(1);
452+
expect(toolUses[0].id).toBe('read_file-999-abc');
453+
expect(toolResults[0].tool_use_id).toBe('read_file-999-abc');
454+
assertEveryResultHasMatchingUse(body.messages);
455+
});
456+
457+
// ───────────────────────────────────────────────────────────────────────
458+
// 兜底场景:彻底孤立的 fr(没有任何 fc 配对,理论上 sanitize 已过滤)
459+
// ───────────────────────────────────────────────────────────────────────
460+
it('sanity: 彻底孤立的 fr 仍然走旧 fallback,不抛异常', async () => { const getBody = makeFetchSpy();
381461
await callAnthropicModel(claudeConfig as any, {
382462
contents: [
383463
{ role: MESSAGE_ROLES.USER, parts: [{ text: 'orphan' }] },

0 commit comments

Comments
 (0)