package loop import ( "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "reflect" "sync" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件是并行工具调用的**端到端**回归:用真适配器(AnthropicChatAdapter) // 打一个按 Anthropic 契约校验请求的假上游,跑完整回合。真机 P0 的原文 // 400 —— // // messages.1: `tool_use` ids were found without `tool_result` blocks // immediately after: call_01_1RBBKboRTJCl2S80t0FJ3155. // Each `tool_use` block must have a corresponding `tool_result` block in the next message. // // 在这里被逐字复刻:上游只放行「assistant 回合里的每个 tool_use 都在紧随其后的 // 那一条消息里被同 id 的 tool_result 应答」的请求。因此修复一旦回退,本用例会 // 以同一个 400 失败 —— 而不是靠对内部结构做断言。 // strictAnthropicUpstream 是一个按 Anthropic 配对契约校验的假上游。 // 它按顺序逐次回放 responses(超出则重复最后一条)。 type strictAnthropicUpstream struct { mu sync.Mutex bodies []map[string]any responses []string srv *httptest.Server } func newStrictAnthropicUpstream(t *testing.T, responses ...string) *strictAnthropicUpstream { t.Helper() up := &strictAnthropicUpstream{responses: responses} up.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) up.mu.Lock() up.bodies = append(up.bodies, body) idx := len(up.bodies) - 1 if idx >= len(up.responses) { idx = len(up.responses) - 1 } payload := up.responses[idx] up.mu.Unlock() if bad := unpairedToolUse(body); bad != "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _, _ = fmt.Fprintf(w, `{"error":{"message":%q,"type":"invalid_request_error"}}`, bad) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = io.WriteString(w, payload) })) t.Cleanup(up.srv.Close) return up } // requests 返回假上游收到的请求体快照数量。 func (up *strictAnthropicUpstream) requests() int { up.mu.Lock() defer up.mu.Unlock() return len(up.bodies) } // bodyAt 返回第 i 个请求体。 func (up *strictAnthropicUpstream) bodyAt(i int) map[string]any { up.mu.Lock() defer up.mu.Unlock() return up.bodies[i] } // unpairedToolUse 复刻上游的校验:对每条 assistant 消息,取出它声明的全部 // tool_use id,要求紧随其后的那一条消息里出现**同 id、同数量、同顺序**的 // tool_result 块;违反时返回与上游同形的错误文案(空串表示通过)。 func unpairedToolUse(body map[string]any) string { raw, _ := body["messages"].([]any) for i, m := range raw { msg, _ := m.(map[string]any) uses := wireBlockIDs(msg, "tool_use") if len(uses) == 0 { continue } if i+1 >= len(raw) { return fmt.Sprintf("messages.%d:`tool_use` ids were found without `tool_result` blocks immediately after: %s", i, joinIDs(uses)) } next, _ := raw[i+1].(map[string]any) results := wireBlockIDs(next, "tool_result") if !reflect.DeepEqual(uses, results) { return fmt.Sprintf("messages.%d:`tool_use` ids were found without `tool_result` blocks immediately after: %s", i, joinIDs(missingIDs(uses, results))) } } return "" } // wireBlockIDs 按出现顺序取出 assistant 的 tool_use id / 结果的 tool_result id。 func wireBlockIDs(msg map[string]any, blockType string) []string { blocks, _ := msg["content"].([]any) ids := make([]string, 0, len(blocks)) for _, b := range blocks { blk, _ := b.(map[string]any) if blk["type"] != blockType { continue } key := "id" if blockType == "tool_result" { key = "tool_use_id" } if s, ok := blk[key].(string); ok { ids = append(ids, s) } } return ids } // joinIDs 渲染 id 列表,供错误文案使用。 func joinIDs(ids []string) string { out := "" for i, id := range ids { if i > 0 { out += ", " } out += id } return out } // missingIDs 返回 uses 里没有在 results 中被一一应答的 id。 func missingIDs(uses, results []string) []string { answered := make(map[string]int, len(results)) for _, r := range results { answered[r]++ } missing := make([]string, 0, len(uses)) for _, u := range uses { if answered[u] > 0 { answered[u]-- continue } missing = append(missing, u) } return missing } // parallelToolUseResponse 是首步模型响应:**同一步并行发出两个** tool_use // (真机那次是两个 knowledge 调用)。id 采用 OpenAI 风格的 // call_01_*,与真机日志一致。 func parallelToolUseResponse() string { return `{ "id":"msg_1","model":"claude-test","role":"assistant", "content":[ {"type":"tool_use","id":"call_01_a","name":"knowledge","input":{"query":"Cyrene"}}, {"type":"tool_use","id":"call_01_b","name":"datetime","input":{"timezone":"Asia/Shanghai"}} ], "stop_reason":"tool_use", "usage":{"input_tokens":10,"output_tokens":8} }` } const parallelFollowUpResponse = `{ "id":"msg_2","model":"claude-test","role":"assistant", "content":[{"type":"text","text":"两条记录都在。"}], "stop_reason":"end_turn", "usage":{"input_tokens":20,"output_tokens":5} }` // TestRun_ParallelToolCalls_SecondStepAccepted 端到端复现真机 P0 的场景: // 同一步并行两个 tool_use,回灌结果后必须能完成第二步模型调用并给出最终答复。 // 修复回退时上游按原样回 400,本用例即以那条错误失败。 func TestRun_ParallelToolCalls_SecondStepAccepted(t *testing.T) { t.Parallel() up := newStrictAnthropicUpstream(t, parallelToolUseResponse(), parallelFollowUpResponse) ad := model.NewAnthropicChat(config.LLMConfig{ AnthropicBaseURL: up.srv.URL, AnthropicModel: "claude-test", }) td := dispatcherWith(t, &mockTool{name: "knowledge", result: map[string]any{"count": 10}}, &mockTool{name: "datetime", result: map[string]any{"now": "2026-10-04 22:17"}}, ) l := New(Options{Agent: newTestAgent(t, ad, td), MaxRounds: 8}) res, err := l.Run(context.Background(), "sess", []model.Message{ model.TextMessage(model.RoleUser, "请调用 knowledge 工具检索关于「Cyrene」的记录"), }) if err != nil { t.Fatalf("Run: %v(旧行为下这里是上游 400: tool_use ids were found without tool_result blocks)", err) } if res.Text != "两条记录都在。" { t.Fatalf("Text = %q, want 两条记录都在。", res.Text) } if res.ToolCallsExecuted != 2 { t.Fatalf("ToolCallsExecuted = %d, want 2(两个并行调用都要执行)", res.ToolCallsExecuted) } if up.requests() != 2 { t.Fatalf("上游请求数 = %d, want 2(第二步必须真的发出去)", up.requests()) } // 第二步请求的配对形态也要成立(不只是「没报错」)。 if bad := unpairedToolUse(up.bodyAt(1)); bad != "" { t.Fatalf("第二步请求仍违反配对契约: %s", bad) } }