package model import ( "context" "net/http" "reflect" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // 本文件锁定一个真机抓到过的 P0:同一步里模型**并行**发出 ≥2 个 tool_use 时, // 上游对该请求判 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. // // Anthropic 协议要求:assistant 回合里**每一个** tool_use 的应答块,都必须出现在 // **紧接其后的那一条**消息里。所以一批并行调用的全部 tool_result 必须落在同一条 // user 消息中;分散成多条 user 消息时,校验器只在下一条里看到其中一个 id, // 其余 id 一律被判「没有 tool_result」,整个请求失败(单调用回合因此永远看不出 // 问题——回填侧恰好只产出一条 user 回合)。下面三组用例把这条契约钉死在 // **线格式**上(回填侧 loop/tool_history.go 的形态即 parallelToolTurnMessages)。 // parallelToolTurnMessages 复刻 loop 在一个「同一步并行发出 ≥2 个 tool_use」的 // 回合里回填的内部消息形态(loop/tool_history.go 的 appendToolCallHistory): // 一条 assistant 声明全部调用,其后**每个调用一条** RoleTool 消息,顺序与调用 // 顺序一致。这正是真机那次请求编码前的输入。 func parallelToolTurnMessages(ids []string) []Message { calls := make([]ToolCall, 0, len(ids)) for _, id := range ids { calls = append(calls, ToolCall{ ID: id, Name: "knowledge", Args: map[string]any{"query": "Cyrene"}, }) } msgs := []Message{ TextMessage(RoleUser, "请调用 knowledge 工具检索关于「Cyrene」的记录"), AssistantToolCallMessage(calls), } for _, id := range ids { msgs = append(msgs, ToolResultMessage(id, `{"count":10}`, false)) } return msgs } // wireMessages 从抓到的请求体里取出 messages 数组。 func wireMessages(t *testing.T, body map[string]any) []map[string]any { t.Helper() raw, ok := body["messages"].([]any) if !ok { t.Fatalf("body.messages = %#v, want an array", body["messages"]) } out := make([]map[string]any, 0, len(raw)) for _, m := range raw { msg, ok := m.(map[string]any) if !ok { t.Fatalf("message = %#v, want an object", m) } out = append(out, msg) } return out } // wireBlocks 取出一条线格式消息的内容块。 func wireBlocks(t *testing.T, msg map[string]any) []map[string]any { t.Helper() raw, ok := msg["content"].([]any) if !ok { t.Fatalf("message content = %#v, want an array of blocks", msg["content"]) } out := make([]map[string]any, 0, len(raw)) for _, b := range raw { blk, ok := b.(map[string]any) if !ok { t.Fatalf("content block = %#v, want an object", b) } out = append(out, blk) } return out } // blockIDs 按出现顺序取出某类块的 id:tool_use 取 "id", // tool_result 取 "tool_use_id"(它应答的那个 tool_use id)。 func blockIDs(blocks []map[string]any, blockType string) []string { ids := make([]string, 0, len(blocks)) for _, b := range blocks { if b["type"] != blockType { continue } key := "id" if blockType == "tool_result" { key = "tool_use_id" } s, _ := b[key].(string) ids = append(ids, s) } return ids } // assertToolResultPairing 断言线格式满足 Anthropic 的配对契约:assistant // 回合里的每个 tool_use id,都要在下一条消息里被一条同 id 的 tool_result // 应答,且**数量与顺序完全一致**。这正是上游 400 // 「ids were found without `tool_result` blocks immediately after」所判定的东西。 func assertToolResultPairing(t *testing.T, body map[string]any) { t.Helper() msgs := wireMessages(t, body) for i, m := range msgs { uses := blockIDs(wireBlocks(t, m), "tool_use") if len(uses) == 0 { continue } if i+1 >= len(msgs) { t.Fatalf("messages.%d 声明了 tool_use %v,但之后没有任何消息应答它", i, uses) } results := blockIDs(wireBlocks(t, msgs[i+1]), "tool_result") if !reflect.DeepEqual(uses, results) { t.Fatalf("messages.%d 的 tool_use %v 在 messages.%d 里没有一一对应(实际 tool_result %v);"+ "上游会判 400(每个 tool_use 都必须在下一条消息里有同 id 的 tool_result)", i, uses, i+1, results) } } } // TestAnthropicBuild_ParallelToolResults_OneUserTurn 是三组用例里最直接的一组: // 直接对编码器断言「串行回填的多条 RoleTool 消息被合并成**一条** user 回合, // 块顺序与调用顺序一致」。旧行为(每条 RoleTool 各自一条 user 消息)在这里必红。 func TestAnthropicBuild_ParallelToolResults_OneUserTurn(t *testing.T) { t.Parallel() ids := []string{"call_01_a", "call_01_b", "call_01_c"} _, out, err := buildAnthropicMessages(parallelToolTurnMessages(ids)) if err != nil { t.Fatalf("buildAnthropicMessages error: %v", err) } wantRoles := []string{"user", "assistant", "user"} if len(out) != len(wantRoles) { t.Fatalf("messages = %d, want %d (并行工具结果必须合并为同一条 user 回合): roles=%v", len(out), len(wantRoles), anthropicRolesOf(out)) } for i, want := range wantRoles { if out[i].Role != want { t.Fatalf("messages.%d role = %q, want %q (roles=%v)", i, out[i].Role, want, anthropicRolesOf(out)) } } blocks, ok := out[2].Content.([]any) if !ok { t.Fatalf("messages.2 content = %#v, want a block array", out[2].Content) } if len(blocks) != len(ids) { t.Fatalf("messages.2 blocks = %d, want %d(一个 tool_use 一个应答块)", len(blocks), len(ids)) } got := make([]string, 0, len(blocks)) for _, b := range blocks { blk, ok := b.(map[string]any) if !ok { t.Fatalf("block = %#v, want an object", b) } if blk["type"] != "tool_result" { t.Fatalf("messages.2 里出现非 tool_result 块: %#v", blk) } s, _ := blk["tool_use_id"].(string) got = append(got, s) } if !reflect.DeepEqual(got, ids) { t.Fatalf("messages.2 的 tool_use_id 顺序 = %v, want %v(顺序必须与调用顺序一致)", got, ids) } } // TestAnthropicBuild_ToolResults_SeparateTurnsNotMerged 守住合并的边界: // 只有**相邻**的工具结果才合并;两个不同 assistant 回合的结果之间隔着别的 // 回合时绝不能并进同一条 user 消息(否则会把 A 批的结果塞到 B 批的 // assistant 回合之后,反而制造出更坏的配对错乱)。 func TestAnthropicBuild_ToolResults_SeparateTurnsNotMerged(t *testing.T) { t.Parallel() msgs := []Message{ TextMessage(RoleUser, "first"), AssistantToolCallMessage([]ToolCall{{ID: "call_a", Name: "knowledge", Args: map[string]any{}}}), ToolResultMessage("call_a", `{"count":1}`, false), TextMessage(RoleUser, "second"), AssistantToolCallMessage([]ToolCall{{ID: "call_b", Name: "knowledge", Args: map[string]any{}}}), ToolResultMessage("call_b", `{"count":2}`, false), } _, out, err := buildAnthropicMessages(msgs) if err != nil { t.Fatalf("buildAnthropicMessages error: %v", err) } if len(out) != 6 { t.Fatalf("messages = %d, want 6 (两个回合各自一条结果回合): roles=%v", len(out), anthropicRolesOf(out)) } wantRoles := []string{"user", "assistant", "user", "user", "assistant", "user"} for i, want := range wantRoles { if out[i].Role != want { t.Fatalf("messages.%d role = %q, want %q (roles=%v)", i, out[i].Role, want, anthropicRolesOf(out)) } } } // anthropicRolesOf 只在断言失败时渲染角色序列,便于一眼看出错误形态。 func anthropicRolesOf(msgs []anthropicMessage) []string { roles := make([]string, 0, len(msgs)) for _, m := range msgs { roles = append(roles, m.Role) } return roles } // TestAnthropicChat_ParallelToolResults_WirePairing 走真机同一条路(非流式 // Chat → 抓请求体),断言并行回合在**线格式**上被一一配对。 func TestAnthropicChat_ParallelToolResults_WirePairing(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch) ad := NewAnthropicChat(config.LLMConfig{ AnthropicBaseURL: srv.URL, AnthropicAPIKey: "ak-test", AnthropicModel: "claude-test", }) if _, err := ad.Chat(context.Background(), Request{ Messages: parallelToolTurnMessages([]string{"call_01_a", "call_01_b"}), Model: "claude-test", MaxTokens: 64, }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } got := <-ch assertToolResultPairing(t, got.body) msgs := wireMessages(t, got.body) if len(msgs) != 3 { t.Fatalf("messages = %d, want 3 (user / assistant / 合并后的 user)", len(msgs)) } if msgs[1]["role"] != "assistant" || msgs[2]["role"] != "user" { t.Fatalf("roles = %v, want [user assistant user]", []any{msgs[0]["role"], msgs[1]["role"], msgs[2]["role"]}) } uses := blockIDs(wireBlocks(t, msgs[1]), "tool_use") if !reflect.DeepEqual(uses, []string{"call_01_a", "call_01_b"}) { t.Fatalf("messages.1 tool_use ids = %v, want [call_01_a call_01_b]", uses) } } // TestAnthropicChat_ParallelToolResults_StreamWirePairing 覆盖真机那次 // 实际走的路:Web 聊天是流式,流式与非流式共用 buildRequest,因此也必须 // 满足同一条契约。 func TestAnthropicChat_ParallelToolResults_StreamWirePairing(t *testing.T) { t.Parallel() sse := strings.Join([]string{ `data: {"type":"message_start","message":{"model":"claude-test","usage":{"input_tokens":5}}}`, ``, `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, ``, `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`, ``, `data: {"type":"content_block_stop","index":0}`, ``, `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`, ``, `data: {"type":"message_stop"}`, ``, }, "\n") ch := make(chan anthropicCaptured, 1) srv := newAnthropicServer(t, http.StatusOK, "text/event-stream", sse, ch) ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"}) if _, err := ad.ChatStream(context.Background(), Request{ Messages: parallelToolTurnMessages([]string{"call_01_a", "call_01_b"}), Model: "claude-test", Purpose: "chat", }, func(StreamEvent) {}); err != nil { t.Fatalf("ChatStream error: %v", err) } captured := <-ch assertToolResultPairing(t, captured.body) } // TestAnthropicChat_SingleToolResult_StillPaired 守住单调用形态:它本来就 // 正常,合并逻辑不得把它改坏(既不能多出回合,也不能丢块)。 func TestAnthropicChat_SingleToolResult_StillPaired(t *testing.T) { t.Parallel() ch := make(chan anthropicCaptured, 1) srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch) ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"}) if _, err := ad.Chat(context.Background(), Request{ Messages: parallelToolTurnMessages([]string{"call_01_only"}), Model: "claude-test", MaxTokens: 64, }, Options{}); err != nil { t.Fatalf("Chat error: %v", err) } got := <-ch assertToolResultPairing(t, got.body) msgs := wireMessages(t, got.body) if len(msgs) != 3 { t.Fatalf("messages = %d, want 3 (user / assistant / user)", len(msgs)) } results := blockIDs(wireBlocks(t, msgs[2]), "tool_result") if !reflect.DeepEqual(results, []string{"call_01_only"}) { t.Fatalf("messages.2 tool_result ids = %v, want [call_01_only]", results) } }