package agent import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" ) func TestReasoningStreamSeparatesThinkingFromAnswer(t *testing.T) { for _, field := range []string{"reasoning_content", "reasoning"} { t.Run(field, func(t *testing.T) { stream := fmt.Sprintf("data: {\"choices\":[{\"delta\":{%q:\"先确认设备。\"}}]}\n\ndata: {\"choices\":[{\"delta\":{%q:\"再检查页面。\",\"content\":\"设备在线。\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", field, field) var answer, thought strings.Builder step, err := readToolStream(strings.NewReader(stream), "", func(delta string) { answer.WriteString(delta) }, func(delta string) { thought.WriteString(delta) }) if err != nil || step.Text != "设备在线。" || answer.String() != step.Text || step.Reasoning != "先确认设备。再检查页面。" || thought.String() != step.Reasoning { t.Fatalf("reasoning/answer mixed or lost: %+v %q %q %v", step, answer.String(), thought.String(), err) } }) } } func TestReasoningInterruptedStreamRetainsReceivedThought(t *testing.T) { stream := "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"尚未完成的思考\"}}]}\n\n" var received strings.Builder step, err := readToolStream(strings.NewReader(stream), "", nil, func(delta string) { received.WriteString(delta) }) if err == nil || step.Reasoning != "尚未完成的思考" || received.String() != step.Reasoning || step.Text != "" { t.Fatalf("partial reasoning lost: %+v %v", step, err) } } func TestReasoningEventResultAndConversationRoundTrip(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"先核对预算。\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"预算3500元。\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n") })) defer server.Close() s, secrets := testService(t) if _, err := s.SaveModelConfig(ModelConfigInput{Config: testConfig(server.URL), APIKey: "key"}); err != nil { t.Fatal(err) } session, err := s.CreateConversation() if err != nil { t.Fatal(err) } var thought, answer strings.Builder s.emitDelta = func(delta ChatDelta) { if delta.RequestID != "reasoning-request" { t.Errorf("wrong request ID: %s", delta.RequestID) } thought.WriteString(delta.ReasoningDelta) answer.WriteString(delta.Delta) } result, err := s.ChatStream(context.Background(), "reasoning-request", session.ID, "预算多少?") if err != nil || result["reasoning"] != "先核对预算。" || thought.String() != result["reasoning"] || answer.String() != result["content"] { t.Fatalf("reasoning not delivered: %v %v", result, err) } session.Messages = []ConversationMessage{{ID: "u1", Role: "user", Content: "预算多少?", CreatedAt: 1}, {ID: "a1", Role: "assistant", Content: result["content"], Reasoning: result["reasoning"], CreatedAt: 2, Status: "complete"}} if _, err = s.SaveConversation(session); err != nil { t.Fatal(err) } loaded, err := newService(s.configPath, secrets).GetConversation(session.ID) if err != nil || loaded.Messages[1].Reasoning != result["reasoning"] { t.Fatalf("reasoning lost on reopen: %+v %v", loaded, err) } raw, _ := json.Marshal(s.history[session.ID]) if strings.Contains(string(raw), "先核对预算") { t.Fatal("display reasoning leaked into legacy history") } } func TestNonStreamReasoningFromModelAndToolStep(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"choices":[{"message":{"content":"回答","reasoning_content":"思考"},"finish_reason":"stop"}]}`) })) defer server.Close() var thought string text, err := complete(testConfig(server.URL), "key", nil, func(delta string) { thought += delta }) if err != nil || text != "回答" || thought != "思考" { t.Fatalf("non-stream reasoning missing: %q %q %v", text, thought, err) } step, err := modelToolStep(context.Background(), testConfig(server.URL), "key", nil, nil, nil) if err != nil || step.Reasoning != "思考" || step.Text != "回答" { t.Fatalf("tool step reasoning missing: %+v %v", step, err) } }