288 lines
20 KiB
Go
288 lines
20 KiB
Go
package thinking
|
|
|
|
import (
|
|
"bytes"
|
|
"testing"
|
|
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestExtractSummaryConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
format string
|
|
body string
|
|
wantMode SummaryMode
|
|
wantDetail string
|
|
}{
|
|
{name: "chat effort enables", format: "openai", body: `{"reasoning_effort":"high"}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "chat none disables", format: "openai", body: `{"reasoning_effort":"none"}`, wantMode: SummaryDisabled},
|
|
{name: "chat missing unspecified", format: "openai", body: `{}`, wantMode: SummaryUnspecified},
|
|
{name: "chat null effort unspecified", format: "openai", body: `{"reasoning_effort":null}`, wantMode: SummaryUnspecified},
|
|
{name: "chat non-string effort unspecified", format: "openai", body: `{"reasoning_effort":17}`, wantMode: SummaryUnspecified},
|
|
{name: "chat google extension false overrides effort", format: "openai", body: `{"reasoning_effort":"high","extra_body":{"google":{"thinking_config":{"include_thoughts":false}}}}`, wantMode: SummaryDisabled},
|
|
{name: "chat google extension true", format: "openai", body: `{"extra_body":{"google":{"thinking_config":{"include_thoughts":true}}}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "chat exclude disables", format: "openai", body: `{"reasoning_effort":"high","reasoning":{"exclude":true}}`, wantMode: SummaryDisabled},
|
|
{name: "chat exclude false enables", format: "openai", body: `{"reasoning":{"effort":"high","exclude":false}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "chat legacy include_reasoning false disables", format: "openai", body: `{"reasoning_effort":"high","include_reasoning":false}`, wantMode: SummaryDisabled},
|
|
{name: "chat legacy include_reasoning true enables", format: "openai", body: `{"include_reasoning":true}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "chat reasoning enabled false disables", format: "openai", body: `{"reasoning":{"enabled":false}}`, wantMode: SummaryDisabled},
|
|
{name: "chat reasoning enabled true enables", format: "openai", body: `{"reasoning":{"enabled":true}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "chat exclude wins over include_reasoning", format: "openai", body: `{"reasoning":{"exclude":true},"include_reasoning":true}`, wantMode: SummaryDisabled},
|
|
{name: "chat non-boolean include_reasoning unspecified", format: "openai", body: `{"include_reasoning":"false"}`, wantMode: SummaryUnspecified},
|
|
{name: "responses effort alone unspecified", format: "openai-response", body: `{"reasoning":{"effort":"high"}}`, wantMode: SummaryUnspecified},
|
|
{name: "responses summary auto", format: "openai-response", body: `{"reasoning":{"effort":"high","summary":"auto"}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "responses summary concise", format: "openai-response", body: `{"reasoning":{"summary":"concise"}}`, wantMode: SummaryEnabled, wantDetail: "concise"},
|
|
{name: "responses summary null", format: "openai-response", body: `{"reasoning":{"summary":null}}`, wantMode: SummaryDisabled},
|
|
{name: "responses boolean summary invalid", format: "openai-response", body: `{"reasoning":{"summary":true}}`, wantMode: SummaryUnspecified},
|
|
{name: "responses deprecated generate summary", format: "openai-response", body: `{"reasoning":{"generate_summary":"detailed"}}`, wantMode: SummaryEnabled, wantDetail: "detailed"},
|
|
{name: "claude summarized", format: "claude", body: `{"thinking":{"type":"adaptive","display":"summarized"}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "claude omitted", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":2048,"display":"omitted"}}`, wantMode: SummaryDisabled},
|
|
{name: "claude display without type is invalid", format: "claude", body: `{"thinking":{"display":"summarized"}}`, wantMode: SummaryUnspecified},
|
|
{name: "claude display with auto type is invalid", format: "claude", body: `{"thinking":{"type":"auto","display":"summarized"}}`, wantMode: SummaryUnspecified},
|
|
// ApplySummaryConfig runs before ApplyThinking fills budget_tokens, so an
|
|
// absent budget must not be read as inactive thinking.
|
|
{name: "claude enabled display without budget is valid", format: "claude", body: `{"thinking":{"type":"enabled","display":"summarized"}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "claude enabled display with zero budget is invalid", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":0,"display":"summarized"}}`, wantMode: SummaryUnspecified},
|
|
{name: "claude auto compatibility budget summarized", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":-1,"display":"summarized"}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "claude auto compatibility budget omitted", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":-1,"display":"omitted"}}`, wantMode: SummaryDisabled},
|
|
{name: "gemini include true", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "gemini include false", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":false}}}`, wantMode: SummaryDisabled},
|
|
{name: "antigravity include true", format: "antigravity", body: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "interactions auto", format: "interactions", body: `{"generation_config":{"thinking_summaries":"auto"}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "interactions none", format: "interactions", body: `{"generation_config":{"thinking_summaries":"none"}}`, wantMode: SummaryDisabled},
|
|
{name: "interactions nested snake include false", format: "interactions", body: `{"generation_config":{"thinking_config":{"include_thoughts":false}}}`, wantMode: SummaryDisabled},
|
|
{name: "interactions nested camel include true", format: "interactions", body: `{"generation_config":{"thinking_config":{"includeThoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "interactions camel config snake include true", format: "interactions", body: `{"generation_config":{"thinkingConfig":{"include_thoughts":true}}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "interactions camel config camel include false", format: "interactions", body: `{"generation_config":{"thinkingConfig":{"includeThoughts":false}}}`, wantMode: SummaryDisabled},
|
|
{name: "interactions enum wins over compatibility reasoning", format: "interactions", body: `{"generation_config":{"thinking_summaries":"none"},"reasoning":{"summary":"auto"}}`, wantMode: SummaryDisabled},
|
|
{name: "interactions compatibility reasoning auto", format: "interactions", body: `{"reasoning":{"summary":"auto"}}`, wantMode: SummaryEnabled, wantDetail: "auto"},
|
|
{name: "interactions compatibility reasoning none", format: "interactions", body: `{"reasoning":{"summary":"none"}}`, wantMode: SummaryDisabled},
|
|
{name: "interactions enum wins over include alias", format: "interactions", body: `{"generation_config":{"thinking_summaries":"none","thinking_config":{"include_thoughts":true}}}`, wantMode: SummaryDisabled},
|
|
{name: "interactions string include alias is invalid", format: "interactions", body: `{"generation_config":{"thinking_config":{"include_thoughts":"false"}}}`, wantMode: SummaryUnspecified},
|
|
{name: "interactions detailed is invalid", format: "interactions", body: `{"generation_config":{"thinking_summaries":"detailed"}}`, wantMode: SummaryUnspecified},
|
|
{name: "interactions boolean is invalid", format: "interactions", body: `{"generation_config":{"thinking_summaries":true}}`, wantMode: SummaryUnspecified},
|
|
{name: "gemini string bool is invalid", format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"includeThoughts":"true"}}}`, wantMode: SummaryUnspecified},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got := ExtractSummaryConfig([]byte(test.body), test.format)
|
|
if got.Mode != test.wantMode || got.Detail != test.wantDetail {
|
|
t.Fatalf("ExtractSummaryConfig() = %+v, want mode=%v detail=%q", got, test.wantMode, test.wantDetail)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractExplicitSummaryConfigDoesNotUseChatEffort(t *testing.T) {
|
|
body := []byte(`{"reasoning_effort":"high"}`)
|
|
if got := ExtractExplicitSummaryConfig(body, "openai"); got.Mode != SummaryUnspecified {
|
|
t.Fatalf("ExtractExplicitSummaryConfig() = %+v, want unspecified", got)
|
|
}
|
|
|
|
body = []byte(`{"reasoning_effort":"high","reasoning":{"exclude":true}}`)
|
|
if got := ExtractExplicitSummaryConfig(body, "openai"); got.Mode != SummaryDisabled {
|
|
t.Fatalf("ExtractExplicitSummaryConfig() = %+v, want disabled", got)
|
|
}
|
|
}
|
|
|
|
func TestApplySummaryConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
format string
|
|
body string
|
|
config SummaryConfig
|
|
path string
|
|
want string
|
|
}{
|
|
{name: "chat enabled invents no effort", format: "openai", config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: ""},
|
|
{name: "chat enabled preserves active effort", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: "high"},
|
|
{name: "chat enabled preserves disabled effort", format: "openai", body: `{"reasoning_effort":"none"}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning_effort", want: "none"},
|
|
// Chat cannot express "reason but hide", so disabling must not fall back to
|
|
// reasoning_effort:"none", which would disable reasoning altogether.
|
|
{name: "chat disabled preserves requested effort", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "reasoning_effort", want: "high"},
|
|
{name: "chat disabled sets openrouter exclude when present", format: "openai", body: `{"reasoning":{"effort":"high","exclude":false}}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "reasoning.exclude", want: "true"},
|
|
{name: "chat enabled clears openrouter exclude when present", format: "openai", body: `{"reasoning":{"effort":"high","exclude":true}}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "reasoning.exclude", want: "false"},
|
|
{name: "chat disabled updates legacy include_reasoning when present", format: "openai", body: `{"reasoning_effort":"high","include_reasoning":true}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "include_reasoning", want: "false"},
|
|
{name: "chat disabled invents no openrouter field", format: "openai", body: `{"reasoning_effort":"high"}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "reasoning", want: ""},
|
|
{name: "claude enabled", format: "claude", body: `{"thinking":{"type":"adaptive"}}`, config: SummaryConfig{Mode: SummaryEnabled}, path: "thinking.display", want: "summarized"},
|
|
{name: "claude disabled", format: "claude", body: `{"thinking":{"type":"enabled","budget_tokens":2048}}`, config: SummaryConfig{Mode: SummaryDisabled}, path: "thinking.display", want: "omitted"},
|
|
{name: "gemini enabled", format: "gemini", config: SummaryConfig{Mode: SummaryEnabled}, path: "generationConfig.thinkingConfig.includeThoughts", want: "true"},
|
|
{name: "gemini disabled", format: "gemini", config: SummaryConfig{Mode: SummaryDisabled}, path: "generationConfig.thinkingConfig.includeThoughts", want: "false"},
|
|
{name: "antigravity enabled", format: "antigravity", config: SummaryConfig{Mode: SummaryEnabled}, path: "request.generationConfig.thinkingConfig.includeThoughts", want: "true"},
|
|
{name: "interactions detail collapses to auto", format: "interactions", config: SummaryConfig{Mode: SummaryEnabled, Detail: "detailed"}, path: "generation_config.thinking_summaries", want: "auto"},
|
|
{name: "interactions disabled", format: "interactions", config: SummaryConfig{Mode: SummaryDisabled}, path: "generation_config.thinking_summaries", want: "none"},
|
|
{name: "responses concise", format: "openai-response", config: SummaryConfig{Mode: SummaryEnabled, Detail: "concise"}, path: "reasoning.summary", want: "concise"},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
body := test.body
|
|
if body == "" {
|
|
body = `{}`
|
|
}
|
|
out := ApplySummaryConfig([]byte(body), test.format, test.config)
|
|
if got := gjson.GetBytes(out, test.path).String(); got != test.want {
|
|
t.Fatalf("%s = %q, want %q; body=%s", test.path, got, test.want, out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplySummaryConfig_OpenAIChatProviderDialects(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
provider string
|
|
body string
|
|
mode SummaryMode
|
|
wantExclude string
|
|
wantExisting bool
|
|
wantEffort string
|
|
}{
|
|
{name: "OpenAI does not invent visibility", provider: "openai", body: `{}`, mode: SummaryEnabled},
|
|
{name: "OpenRouter enables visibility", provider: "openrouter", body: `{}`, mode: SummaryEnabled, wantExclude: "false", wantExisting: true},
|
|
{name: "OpenRouter disables visibility", provider: "prod-openrouter", body: `{}`, mode: SummaryDisabled, wantExclude: "true", wantExisting: true},
|
|
{name: "DeepSeek preserves documented effort", provider: "deepseek", body: `{"reasoning_effort":"high"}`, mode: SummaryDisabled, wantEffort: "high"},
|
|
{name: "Kimi preserves documented K3 effort", provider: "kimi", body: `{"reasoning_effort":"max"}`, mode: SummaryEnabled, wantEffort: "max"},
|
|
{name: "Moonshot does not invent visibility", provider: "moonshot", body: `{"thinking":{"type":"enabled"}}`, mode: SummaryEnabled},
|
|
{name: "generic provider updates existing OpenRouter field", provider: "openai-compatibility", body: `{"reasoning":{"exclude":false}}`, mode: SummaryDisabled, wantExclude: "true", wantExisting: true},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
out := applySummaryConfigForProvider([]byte(test.body), "openai", "model", test.provider, nil, SummaryConfig{Mode: test.mode})
|
|
exclude := gjson.GetBytes(out, "reasoning.exclude")
|
|
if exclude.Exists() != test.wantExisting {
|
|
t.Fatalf("reasoning.exclude exists = %v, want %v; body=%s", exclude.Exists(), test.wantExisting, out)
|
|
}
|
|
if test.wantExisting && exclude.String() != test.wantExclude {
|
|
t.Fatalf("reasoning.exclude = %q, want %q; body=%s", exclude.String(), test.wantExclude, out)
|
|
}
|
|
effort := gjson.GetBytes(out, "reasoning_effort")
|
|
if test.wantEffort == "" {
|
|
if effort.Exists() {
|
|
t.Fatalf("summary visibility invented reasoning_effort: %s", out)
|
|
}
|
|
} else if effort.String() != test.wantEffort {
|
|
t.Fatalf("reasoning_effort = %q, want %q; body=%s", effort.String(), test.wantEffort, out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplySummaryConfigNormalizesTargetAliases(t *testing.T) {
|
|
tests := []struct {
|
|
format string
|
|
body string
|
|
canonical string
|
|
alias string
|
|
}{
|
|
{format: "gemini", body: `{"generationConfig":{"thinkingConfig":{"include_thoughts":true}}}`, canonical: "generationConfig.thinkingConfig.includeThoughts", alias: "generationConfig.thinkingConfig.include_thoughts"},
|
|
{format: "antigravity", body: `{"request":{"generationConfig":{"thinkingConfig":{"include_thoughts":true}}}}`, canonical: "request.generationConfig.thinkingConfig.includeThoughts", alias: "request.generationConfig.thinkingConfig.include_thoughts"},
|
|
{format: "interactions", body: `{"generation_config":{"thinkingSummaries":"auto"}}`, canonical: "generation_config.thinking_summaries", alias: "generation_config.thinkingSummaries"},
|
|
}
|
|
for _, test := range tests {
|
|
out := ApplySummaryConfig([]byte(test.body), test.format, SummaryConfig{Mode: SummaryEnabled})
|
|
if !gjson.GetBytes(out, test.canonical).Exists() {
|
|
t.Fatalf("%s missing canonical field: %s", test.format, out)
|
|
}
|
|
if gjson.GetBytes(out, test.alias).Exists() {
|
|
t.Fatalf("%s retained alias %s: %s", test.format, test.alias, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Anthropic requires thinking.type, and rejects display on a disabled block, so
|
|
// display must never be written unless thinking is already active.
|
|
func TestApplySummaryConfig_ClaudeDisplayRequiresActiveThinking(t *testing.T) {
|
|
bodies := []string{
|
|
`{}`,
|
|
`{"messages":[{"role":"user","content":"hi"}]}`,
|
|
`{"thinking":{"type":"disabled"}}`,
|
|
}
|
|
for _, mode := range []SummaryMode{SummaryEnabled, SummaryDisabled} {
|
|
for _, body := range bodies {
|
|
out := ApplySummaryConfig([]byte(body), "claude", SummaryConfig{Mode: mode})
|
|
if gjson.GetBytes(out, "thinking.display").Exists() {
|
|
t.Fatalf("mode %v wrote display without active thinking: %s", mode, out)
|
|
}
|
|
if !bytes.Equal(out, []byte(body)) {
|
|
t.Fatalf("mode %v changed body: got %s, want %s", mode, out, body)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplySummaryConfigForModel_ClaudeEnabledSummaryUsesValidThinkingMode(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
model string
|
|
body string
|
|
wantType string
|
|
wantBudget int64
|
|
}{
|
|
{name: "adaptive model", model: "claude-opus-5", body: `{"model":"claude-opus-5","max_tokens":32000}`, wantType: "adaptive"},
|
|
{name: "manual model", model: "claude-haiku-4-5-20251001", body: `{"model":"claude-haiku-4-5-20251001","max_tokens":32000}`, wantType: "enabled", wantBudget: 1024},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
out := ApplySummaryConfigForModel([]byte(test.body), "claude", test.model, SummaryConfig{Mode: SummaryEnabled})
|
|
if got := gjson.GetBytes(out, "thinking.type").String(); got != test.wantType {
|
|
t.Fatalf("thinking.type = %q, want %q; body=%s", got, test.wantType, out)
|
|
}
|
|
if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" {
|
|
t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out)
|
|
}
|
|
if test.wantBudget > 0 && gjson.GetBytes(out, "thinking.budget_tokens").Int() != test.wantBudget {
|
|
t.Fatalf("thinking.budget_tokens = %d, want %d; body=%s", gjson.GetBytes(out, "thinking.budget_tokens").Int(), test.wantBudget, out)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Disabling summaries must not make CPA add a Claude thinking block. Absence
|
|
// preserves the per-model default: newer models may still think by default,
|
|
// while older models remain off.
|
|
func TestApplySummaryConfigForModel_ClaudeDisabledSummaryDoesNotEnableThinking(t *testing.T) {
|
|
for _, model := range []string{"claude-opus-5", "claude-haiku-4-5-20251001"} {
|
|
body := []byte(`{"model":"` + model + `","max_tokens":32000}`)
|
|
out := ApplySummaryConfigForModel(body, "claude", model, SummaryConfig{Mode: SummaryDisabled})
|
|
if gjson.GetBytes(out, "thinking").Exists() {
|
|
t.Fatalf("model %s gained thinking for a disabled summary: %s", model, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplySummaryConfig_ResponsesNormalizesDeprecatedGenerateSummary(t *testing.T) {
|
|
out := ApplySummaryConfig([]byte(`{"reasoning":{"generate_summary":"detailed"}}`), "openai-response", SummaryConfig{Mode: SummaryEnabled, Detail: "detailed"})
|
|
if got := gjson.GetBytes(out, "reasoning.summary").String(); got != "detailed" {
|
|
t.Fatalf("reasoning.summary = %q, want detailed; body=%s", got, out)
|
|
}
|
|
if gjson.GetBytes(out, "reasoning.generate_summary").Exists() {
|
|
t.Fatalf("deprecated reasoning.generate_summary remained: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestApplySummaryConfig_ResponsesDisabledOmitsSummary(t *testing.T) {
|
|
out := ApplySummaryConfig([]byte(`{"reasoning":{"effort":"high","summary":"auto"}}`), "openai-response", SummaryConfig{Mode: SummaryDisabled})
|
|
if result := gjson.GetBytes(out, "reasoning.summary"); result.Exists() {
|
|
t.Fatalf("reasoning.summary = %s, want absent; body=%s", result.Raw, out)
|
|
}
|
|
if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "high" {
|
|
t.Fatalf("reasoning.effort = %q, want high; body=%s", got, out)
|
|
}
|
|
}
|
|
|
|
func TestApplySummaryConfig_ResponsesDisabledDropsEmptyReasoning(t *testing.T) {
|
|
out := ApplySummaryConfig([]byte(`{"model":"gpt-5.4","reasoning":{"summary":"auto"}}`), "openai-response", SummaryConfig{Mode: SummaryDisabled})
|
|
if gjson.GetBytes(out, "reasoning").Exists() {
|
|
t.Fatalf("empty reasoning object left behind: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestApplySummaryConfig_UnspecifiedLeavesBodyUnchanged(t *testing.T) {
|
|
body := []byte(`{"thinking":{"type":"adaptive"}}`)
|
|
if got := ApplySummaryConfig(body, "claude", SummaryConfig{}); !bytes.Equal(got, body) {
|
|
t.Fatalf("unspecified summary changed body: got %s, want %s", got, body)
|
|
}
|
|
}
|