package loop import ( "reflect" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件钉死工具回填侧的形态契约(appendToolCallHistory):一条 assistant // 回合声明**本步的全部** tool_use,其后**每个调用一条** RoleTool 消息, // 顺序与调用顺序一致。真机 P0(并行 tool_use 的原生回填让上游判 400 // 「tool_use ids were found without tool_result blocks immediately after」) // 的修复落在 Anthropic 编码器(model/anthropic_encode.go:相邻的 RoleTool // 回合合并成同一条 user 消息);这里守住的是编码器的输入 —— 回填侧既不能 // 漏掉某个调用的结果,也不能打乱顺序或丢失 id 配对,否则编码器无从补救。 // nativeCalls 构造一批带 provider id 的调用(原生路径)。 func nativeCalls(ids ...string) []agent.ToolCall { calls := make([]agent.ToolCall, 0, len(ids)) for _, id := range ids { calls = append(calls, agent.ToolCall{ID: id, Name: "knowledge", Args: map[string]any{"query": "Cyrene"}}) } return calls } // toolResultIDs 按消息顺序取出 RoleTool 回合应答的 provider id。 func toolResultIDs(msgs []model.Message) []string { ids := make([]string, 0, len(msgs)) for _, m := range msgs { if m.Role != model.RoleTool { continue } ids = append(ids, m.ToolCallID) } return ids } // TestAppendToolCallHistory_ParallelBatch_PairsEveryCall 覆盖同一步里 // **≥2 个并行调用**:assistant 回合必须声明全部调用,且每个 id 都要在紧随其后的 // 结果里出现恰好一次、顺序一致。 func TestAppendToolCallHistory_ParallelBatch_PairsEveryCall(t *testing.T) { t.Parallel() ids := []string{"call_01_a", "call_01_b", "call_01_c"} outcomes := make([]ToolOutcome, len(ids)) for i := range outcomes { outcomes[i] = ToolOutcome{Result: map[string]any{"count": 10}} } msgs := appendToolCallHistory(nil, nativeCalls(ids...), outcomes) if len(msgs) != len(ids)+1 { t.Fatalf("messages = %d, want %d (1 条 assistant + 每调用 1 条结果)", len(msgs), len(ids)+1) } if msgs[0].Role != model.RoleAssistant { t.Fatalf("messages[0].role = %q, want assistant(结果必须先有调用可关联)", msgs[0].Role) } if got := toolCallIDs(msgs[0].Content); !reflect.DeepEqual(got, ids) { t.Fatalf("assistant tool_use id = %v, want %v", got, ids) } if got := toolResultIDs(msgs[1:]); !reflect.DeepEqual(got, ids) { t.Fatalf("tool_result id = %v, want %v(顺序与调用一致、不得去重或丢失)", got, ids) } } // TestAppendToolCallHistory_SingleCall_Pairs 覆盖单调用形态,防止并行修复 // 把单调用路径改坏。 func TestAppendToolCallHistory_SingleCall_Pairs(t *testing.T) { t.Parallel() msgs := appendToolCallHistory(nil, nativeCalls("call_01_only"), []ToolOutcome{{Result: "ok"}}) if len(msgs) != 2 { t.Fatalf("messages = %d, want 2", len(msgs)) } if got := toolCallIDs(msgs[0].Content); !reflect.DeepEqual(got, []string{"call_01_only"}) { t.Fatalf("assistant tool_use id = %v, want [call_01_only]", got) } if got := toolResultIDs(msgs[1:]); !reflect.DeepEqual(got, []string{"call_01_only"}) { t.Fatalf("tool_result id = %v, want [call_01_only]", got) } } // TestAppendToolCallHistory_LegacyEnvelope_NoNativeTurn 守住旧式 JSON 信封 // 路径(调用不带 provider id):它没有可关联的原生 tool_use,因此保持纯 RoleTool // 文本回灌,绝不凭空造一条 assistant 回合。 func TestAppendToolCallHistory_LegacyEnvelope_NoNativeTurn(t *testing.T) { t.Parallel() calls := []agent.ToolCall{{Name: "echo", Args: map[string]any{"q": "x"}}, {Name: "echo2", Args: map[string]any{"q": "y"}}} msgs := appendToolCallHistory(nil, calls, []ToolOutcome{{Result: "a"}, {Result: "b"}}) if len(msgs) != 2 { t.Fatalf("messages = %d, want 2(每个调用一条纯文本回灌)", len(msgs)) } for i, m := range msgs { if m.Role != model.RoleTool { t.Fatalf("messages[%d].role = %q, want tool", i, m.Role) } } } // toolCallIDs 按内容块顺序取出 assistant 回合声明的 tool_use id。 func toolCallIDs(parts []model.Content) []string { ids := make([]string, 0, len(parts)) for _, p := range parts { if p.Type == model.ContentTypeToolCall { ids = append(ids, p.ToolCallID) } } return ids }