From 43de821b08f9e3aa112b08d725d0e4c2407225e6 Mon Sep 17 00:00:00 2001 From: Badgerbees Date: Sat, 21 Mar 2026 18:04:42 +0700 Subject: [PATCH] add unit tests for ollama thinking fallback (streaming and non-streaming) --- pkg/providers/common/common_test.go | 62 ++++++++++++++++ pkg/providers/openai_compat/provider_test.go | 74 ++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go index bb7e7434d..26e56e3fc 100644 --- a/pkg/providers/common/common_test.go +++ b/pkg/providers/common/common_test.go @@ -556,3 +556,65 @@ func TestParseResponse_WithThoughtSignature(t *testing.T) { out.ToolCalls[0].ExtraContent.Google.ThoughtSignature, "sig123") } } + +func TestSerializeMessages_WithReasoningFallbackFields(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + Content: "final answer", + ReasoningContent: "deepseek thinking", + Reasoning: "ollama reasoning", + Thinking: "ollama thinking", + }, + } + result := SerializeMessages(messages) + + data, _ := json.Marshal(result) + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + if msgs[0]["reasoning_content"] != "deepseek thinking" { + t.Errorf("reasoning_content mismatch, got %v", msgs[0]["reasoning_content"]) + } + if msgs[0]["reasoning"] != "ollama reasoning" { + t.Errorf("reasoning mismatch, got %v", msgs[0]["reasoning"]) + } + if msgs[0]["thinking"] != "ollama thinking" { + t.Errorf("thinking mismatch, got %v", msgs[0]["thinking"]) + } +} + +func TestParseResponse_WithOllamaThinkingFallback(t *testing.T) { + // Test thinking fallback + body := `{"choices":[{"message":{"content":"","thinking":"I am thinking..."},"finish_reason":"stop"}]}` + out, err := ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if out.Content != "I am thinking..." { + t.Errorf("Content fallback to thinking failed, got %q", out.Content) + } + if out.Thinking != "I am thinking..." { + t.Errorf("Thinking field not preserved, got %q", out.Thinking) + } + + // Test reasoning fallback + body = `{"choices":[{"message":{"content":"","reasoning":"Ollama reasoning text"},"finish_reason":"stop"}]}` + out, err = ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if out.Content != "Ollama reasoning text" { + t.Errorf("Content fallback to reasoning failed, got %q", out.Content) + } + + // Test priority: content > thinking > reasoning + body = `{"choices":[{"message":{"content":"real content","thinking":"hidden thinking"},"finish_reason":"stop"}]}` + out, err = ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if out.Content != "real content" { + t.Errorf("Content should have priority, got %q", out.Content) + } +} diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index efb03ccb8..3b1b63766 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -1089,3 +1089,77 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) { t.Fatal("system_parts should not appear in serialized output") } } + +func TestParseStreamResponse_ThinkingFallback(t *testing.T) { + // Mock SSE stream with thinking/reasoning chunks + stream := `data: {"choices":[{"delta":{"thinking":"Let me "},"index":0}]} +data: {"choices":[{"delta":{"thinking":"think... "},"index":0}]} +data: {"choices":[{"delta":{"content":"The answer is "},"index":0}]} +data: {"choices":[{"delta":{"content":"42"},"index":0}]} +data: [DONE] +` + + var accumulated string + onChunk := func(acc string) { + accumulated = acc + } + + resp, err := parseStreamResponse(t.Context(), strings.NewReader(stream), onChunk) + if err != nil { + t.Fatalf("parseStreamResponse() error = %v", err) + } + + // Verify Thinking field is populated + if resp.Thinking != "Let me think... " { + t.Errorf("resp.Thinking = %q, want %q", resp.Thinking, "Let me think... ") + } + + // Verify Content contains BOTH thinking (as fallback/prefix) and content + expectedContent := "Let me think... The answer is 42" + if resp.Content != expectedContent { + t.Errorf("resp.Content = %q, want %q", resp.Content, expectedContent) + } + + // Verify onChunk received the intermediate states + if accumulated != expectedContent { + t.Errorf("last onChunk = %q, want %q", accumulated, expectedContent) + } +} + +func TestParseStreamResponse_ReasoningFallback(t *testing.T) { + // Mock SSE stream with reasoning (Ollama style) + stream := `data: {"choices":[{"delta":{"reasoning":"I will calculate "},"index":0}]} +data: {"choices":[{"delta":{"reasoning":"the sum."},"index":0}]} +data: [DONE] +` + + resp, err := parseStreamResponse(t.Context(), strings.NewReader(stream), nil) + if err != nil { + t.Fatalf("parseStreamResponse() error = %v", err) + } + + if resp.Content != "I will calculate the sum." { + t.Errorf("resp.Content = %q, want %q", resp.Content, "I will calculate the sum.") + } +} + +func TestParseStreamResponse_MixedContent(t *testing.T) { + // Verify that if both thinking and content are present, they are both captured correctly. + // Some models might start with thinking and then send content. + stream := `data: {"choices":[{"delta":{"thinking":"Wait, "},"index":0}]} +data: {"choices":[{"delta":{"content":"Hello"},"index":0}]} +data: [DONE] +` + + resp, err := parseStreamResponse(t.Context(), strings.NewReader(stream), nil) + if err != nil { + t.Fatalf("parseStreamResponse() error = %v", err) + } + + if resp.Thinking != "Wait, " { + t.Errorf("Thinking = %q", resp.Thinking) + } + if resp.Content != "Wait, Hello" { + t.Errorf("Content = %q", resp.Content) + } +}