// Tests for reasoning/thinking wiring across both wire protocols (issues // #240-#244): request encoders forwarding ThinkingLevel, the Anthropic // thinking-block echo on multi-turn tool-use messages, OpenAI reasoning_content // stream decoding, the max_tokens fallback, and strict-gateway empty-content // handling. package provider import ( "encoding/json" "strings" "testing" "github.com/smallnest/pigo/internal/agentcore" ) // decodeBody unmarshals an encoded request body into a generic map for asserts. func decodeBody(t *testing.T, b []byte) map[string]any { t.Helper() var m map[string]any if err := json.Unmarshal(b, &m); err != nil { t.Fatalf("unmarshal body: %v", err) } return m } // TestOpenAIReasoningEffortEncoding verifies ThinkingLevel maps to // reasoning_effort, and that off/unset omits the field entirely (#240). func TestOpenAIReasoningEffortEncoding(t *testing.T) { base := CompletionRequest{Model: "o3-mini", Context: LlmContext{}} // off / unset → no reasoning_effort. for _, lvl := range []agentcore.ThinkingLevel{"", agentcore.ThinkingOff} { base.Config.ThinkingLevel = lvl b, err := encodeOpenAIRequest(base) if err != nil { t.Fatalf("encode: %v", err) } if _, ok := decodeBody(t, b)["reasoning_effort"]; ok { t.Errorf("level %q: reasoning_effort should be omitted", lvl) } } cases := map[agentcore.ThinkingLevel]string{ agentcore.ThinkingMinimal: "minimal", agentcore.ThinkingLow: "low", agentcore.ThinkingMedium: "medium", agentcore.ThinkingHigh: "high", agentcore.ThinkingXHigh: "high", } for lvl, want := range cases { base.Config.ThinkingLevel = lvl b, err := encodeOpenAIRequest(base) if err != nil { t.Fatalf("encode: %v", err) } if got := decodeBody(t, b)["reasoning_effort"]; got != want { t.Errorf("level %q: reasoning_effort = %v, want %q", lvl, got, want) } } } // TestAnthropicThinkingEncoding verifies ThinkingLevel enables the thinking // block with a budget, and off/unset omits it (#240). func TestAnthropicThinkingEncoding(t *testing.T) { base := CompletionRequest{Model: "claude-x", Context: LlmContext{}} base.Config.ThinkingLevel = agentcore.ThinkingOff b, _ := encodeAnthropicRequest(base, nil) if _, ok := decodeBody(t, b)["thinking"]; ok { t.Error("off: thinking block should be omitted") } base.Config.ThinkingLevel = agentcore.ThinkingMedium b, _ = encodeAnthropicRequest(base, nil) th, ok := decodeBody(t, b)["thinking"].(map[string]any) if !ok { t.Fatal("medium: thinking block missing") } if th["type"] != "enabled" { t.Errorf("thinking.type = %v, want enabled", th["type"]) } if bt, _ := th["budget_tokens"].(float64); bt <= 0 { t.Errorf("thinking.budget_tokens = %v, want > 0", th["budget_tokens"]) } } // TestAnthropicMaxTokensFallback verifies the fallback prefers the model's // MaxOutputTokens and otherwise uses the coding-friendly 8192 default (#243). func TestAnthropicMaxTokensFallback(t *testing.T) { req := CompletionRequest{Model: "claude-x", Context: LlmContext{}} // No model metadata → 8192 default. b, _ := encodeAnthropicRequest(req, nil) if got := decodeBody(t, b)["max_tokens"].(float64); got != 8192 { t.Errorf("default max_tokens = %v, want 8192", got) } // Model with a declared cap → that cap. models := []Model{{ID: "claude-x", MaxOutputTokens: 12000}} b, _ = encodeAnthropicRequest(req, models) if got := decodeBody(t, b)["max_tokens"].(float64); got != 12000 { t.Errorf("model-cap max_tokens = %v, want 12000", got) } // Explicit Extra hint still wins. req.Config.Extra = map[string]any{"max_tokens": 2000} b, _ = encodeAnthropicRequest(req, models) if got := decodeBody(t, b)["max_tokens"].(float64); got != 2000 { t.Errorf("explicit max_tokens = %v, want 2000", got) } } // TestAnthropicThinkingBlockEcho verifies a multi-turn assistant message with a // thinking block + tool call re-emits the thinking block (with signature) ahead // of the tool_use block (#241). func TestAnthropicThinkingBlockEcho(t *testing.T) { think := agentcore.NewThinkingContent("let me reason") think.ThinkingSignature = "sig-abc" msg := agentcore.AssistantMessage{ Content: agentcore.ContentList{ think, agentcore.NewToolCallContent("call_1", "read", json.RawMessage(`{"path":"x"}`)), }, } entry := encodeAnthropicMessage(msg) blocks, ok := entry["content"].([]map[string]any) if !ok || len(blocks) != 2 { t.Fatalf("want 2 content blocks, got %#v", entry["content"]) } if blocks[0]["type"] != "thinking" { t.Errorf("first block type = %v, want thinking", blocks[0]["type"]) } if blocks[0]["signature"] != "sig-abc" { t.Errorf("thinking signature = %v, want sig-abc", blocks[0]["signature"]) } if blocks[1]["type"] != "tool_use" { t.Errorf("second block type = %v, want tool_use", blocks[1]["type"]) } } // TestAnthropicRedactedThinkingEcho verifies redacted thinking round-trips as a // redacted_thinking block carrying the signature as data (#241). func TestAnthropicRedactedThinkingEcho(t *testing.T) { think := agentcore.ThinkingContent{Type: agentcore.ContentTypeThinking, Redacted: true, ThinkingSignature: "redacted-data"} msg := agentcore.AssistantMessage{Content: agentcore.ContentList{think}} entry := encodeAnthropicMessage(msg) blocks := entry["content"].([]map[string]any) if blocks[0]["type"] != "redacted_thinking" || blocks[0]["data"] != "redacted-data" { t.Errorf("redacted block = %#v", blocks[0]) } } // TestOpenAIAssistantContentNullWithToolCalls verifies a tool-call-only // assistant turn sends content:null (not ""), while a text-only turn keeps its // text (#244). func TestOpenAIAssistantContentNullWithToolCalls(t *testing.T) { toolOnly := agentcore.AssistantMessage{ Content: agentcore.ContentList{ agentcore.NewToolCallContent("call_1", "read", json.RawMessage(`{}`)), }, } entry := encodeOpenAIMessage(toolOnly)[0] if entry["content"] != nil { t.Errorf("tool-only content = %#v, want nil", entry["content"]) } if _, ok := entry["tool_calls"]; !ok { t.Error("tool_calls missing") } textOnly := agentcore.AssistantMessage{ Content: agentcore.ContentList{agentcore.NewTextContent("hi")}, } if got := encodeOpenAIMessage(textOnly)[0]["content"]; got != "hi" { t.Errorf("text content = %#v, want hi", got) } } // TestAnthropicEmptyAssistantNoEmptyText verifies an assistant message with no // usable content does not emit an empty-string text block (#244). func TestAnthropicEmptyAssistantNoEmptyText(t *testing.T) { msg := agentcore.AssistantMessage{Content: agentcore.ContentList{agentcore.NewTextContent("")}} entry := encodeAnthropicMessage(msg) blocks := entry["content"].([]map[string]any) if len(blocks) != 1 { t.Fatalf("want 1 fallback block, got %d", len(blocks)) } if txt, _ := blocks[0]["text"].(string); strings.TrimSpace(txt) == "" && txt == "" { t.Errorf("fallback text block is empty string, want non-empty placeholder") } } // TestAnthropicThinkingRaisesMaxTokens verifies max_tokens is lifted above the // thinking budget: Anthropic requires budget_tokens < max_tokens, so a low cap // must be raised to leave headroom for the visible reply (#243). func TestAnthropicThinkingRaisesMaxTokens(t *testing.T) { req := CompletionRequest{Model: "claude-x", Context: LlmContext{}} req.Config.ThinkingLevel = agentcore.ThinkingXHigh // budget 32768 // Default cap (8192) is below the budget → must be raised above it. b, _ := encodeAnthropicRequest(req, nil) body := decodeBody(t, b) budget := body["thinking"].(map[string]any)["budget_tokens"].(float64) maxTok := body["max_tokens"].(float64) if maxTok <= budget { t.Errorf("max_tokens = %v, want > budget_tokens %v", maxTok, budget) } // A caller cap already above budget+headroom is left untouched. req.Config.Extra = map[string]any{"max_tokens": 100000} b, _ = encodeAnthropicRequest(req, nil) if got := decodeBody(t, b)["max_tokens"].(float64); got != 100000 { t.Errorf("max_tokens = %v, want caller value 100000", got) } } // TestOpenAIReasoningContentDecoding verifies the decoder accumulates // reasoning_content into a ThinkingContent block ahead of text (#242). func TestOpenAIReasoningContentDecoding(t *testing.T) { d := NewOpenAIDecoder() chunks := []string{ `{"id":"c1","choices":[{"delta":{"reasoning_content":"think "}}]}`, `{"choices":[{"delta":{"reasoning_content":"harder"}}]}`, `{"choices":[{"delta":{"content":"answer"}}]}`, `{"choices":[{"finish_reason":"stop"}]}`, } var events []StreamEvent for _, c := range chunks { evs, err := d.Decode([]byte(c)) if err != nil { t.Fatalf("decode: %v", err) } events = append(events, evs...) } done, _ := d.Finish() events = append(events, done...) var final agentcore.AssistantMessage for _, e := range events { if de, ok := e.(StreamDoneEvent); ok { final = de.Message } } if len(final.Content) != 2 { t.Fatalf("want thinking+text, got %d blocks: %#v", len(final.Content), final.Content) } th, ok := final.Content[0].(agentcore.ThinkingContent) if !ok || th.Thinking != "think harder" { t.Errorf("block[0] = %#v, want thinking 'think harder'", final.Content[0]) } txt, ok := final.Content[1].(agentcore.TextContent) if !ok || txt.Text != "answer" { t.Errorf("block[1] = %#v, want text 'answer'", final.Content[1]) } sawThinking := false for _, e := range events { if _, ok := e.(StreamThinkingEvent); ok { sawThinking = true } } if !sawThinking { t.Error("expected at least one StreamThinkingEvent") } }