@@ -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