package loop import ( "context" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // anthropicToolRoundSSE 是一个原生 Anthropic 流:它先思考,然后发出 // 一个带空文本部件的 tool_use 块 —— 正是拖垮旧循环(它只在 // Content 中寻找 JSON 信封)的那种形态。 const anthropicToolRoundSSE = `data: {"type":"message_start","message":{"model":"m","usage":{"input_tokens":10}}} data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}} data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"checking the clock"}} data: {"type":"content_block_stop","index":0} data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"datetime","input":{}}} data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"timezone\":\"Asia/Shanghai\"}"}} data: {"type":"content_block_stop","index":1} data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":20}} data: {"type":"message_stop"} ` // anthropicTextRoundSSE 是后续流:工具结果回灌后的一段 // 纯文本答案。 const anthropicTextRoundSSE = `data: {"type":"message_start","message":{"model":"m","usage":{"input_tokens":30}}} data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"现在是 10:34。"}} data: {"type":"content_block_stop","index":0} data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":8}} data: {"type":"message_stop"} ` // TestRun_NativeToolCall_AnthropicAdapter_HTTP 端到端演练真实循环路径: // 经由真实的 AnthropicChatAdapter 打向一个桩 /v1/messages // 服务器:第 1 轮流式输出不带文本的 thinking + tool_use 块, // 循环必须执行它并重新提问,第 2 轮必须携带 assistant tool_use 轮 // 以及关联的 tool_result,并返回模型的文本(而非空回复)。 func TestRun_NativeToolCall_AnthropicAdapter_HTTP(t *testing.T) { t.Parallel() var mu sync.Mutex round := 0 var secondBody string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) mu.Lock() round++ n := round if n == 2 { secondBody = string(body) } mu.Unlock() w.Header().Set("Content-Type", "text/event-stream") if n == 1 { _, _ = io.WriteString(w, anthropicToolRoundSSE) return } _, _ = io.WriteString(w, anthropicTextRoundSSE) })) t.Cleanup(srv.Close) ad := model.NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "m"}) td := dispatcherWith(t, &mockTool{name: "datetime", result: map[string]any{"now": "2026-09-08 22:34:00"}}) ag, err := agent.New("你是昔涟。", td, nil, agent.WithModel(ad, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } l := New(Options{Agent: ag, MaxRounds: 8, OnStream: func(model.StreamEvent) {}}) res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "瞅瞅当前系统时间是多少")}) if err != nil { t.Fatalf("Run: %v", err) } if res.Text != "现在是 10:34。" { t.Fatalf("Text = %q, want the follow-up answer (empty means the native tool call was dropped)", res.Text) } if res.ToolCallsExecuted != 1 { t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted) } if secondBody == "" { t.Fatal("adapter never made a second request (tool result was not fed back)") } for _, want := range []string{`"type":"tool_use"`, `"id":"toolu_1"`, `"type":"tool_result"`, `"tool_use_id":"toolu_1"`} { if !strings.Contains(secondBody, want) { t.Fatalf("second request missing %s:\n%s", want, secondBody) } } }