package service import ( "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/windsurf" ) // Test that the switchover flag expands the history budget to ~3.5MB and preserves // all turns for a large multi-turn conversation that would otherwise be trimmed // under the normal 200KB budget. This guards the core fix: after a Windsurf // account switch, the new account must receive the full chat history. func TestBuildCascadeText_SwitchoverKeepsFullHistory(t *testing.T) { // Build a ~1.5MB multi-turn history: 30 turns of ~50KB each (alternating // user/assistant). Exceeds the normal 200KB cap; well within the 3.5MB cap. const perTurnBytes = 50 * 1024 const turns = 30 bulk := strings.Repeat("x", perTurnBytes) var messages []windsurf.ChatMessage messages = append(messages, windsurf.ChatMessage{Role: "system", Content: "sys"}) for i := 0; i < turns; i++ { role := "user" if i%2 == 1 { role = "assistant" } messages = append(messages, windsurf.ChatMessage{Role: role, Content: bulk}) } // Latest user message (the one actually being answered). messages = append(messages, windsurf.ChatMessage{Role: "user", Content: "final question"}) normalText := buildCascadeText(messages, "claude-sonnet-4", false, false) switchoverText := buildCascadeText(messages, "claude-sonnet-4", false, true) if len(normalText) >= len(switchoverText) { t.Fatalf("switchover text (%d bytes) must be larger than normal (%d bytes)", len(switchoverText), len(normalText)) } if len(normalText) > cascadeMaxHistoryBytes+perTurnBytes { t.Fatalf("normal text (%d bytes) must fit near %d budget", len(normalText), cascadeMaxHistoryBytes) } if len(switchoverText) < perTurnBytes*turns { t.Fatalf("switchover text (%d bytes) dropped turns; expected >= %d (all %d turns kept)", len(switchoverText), perTurnBytes*turns, turns) } if len(switchoverText) > cascadeSwitchoverHistoryBytes+perTurnBytes { t.Fatalf("switchover text (%d bytes) exceeded budget %d", len(switchoverText), cascadeSwitchoverHistoryBytes) } // Final user message must always be preserved (it's the question being asked). if !strings.Contains(switchoverText, "final question") { t.Fatal("switchover text must include the final user message") } if !strings.Contains(normalText, "final question") { t.Fatal("normal text must include the final user message") } } // Resume mode ignores switchover — only the last user message is sent because // Cascade server already has the history for the reused cascade_id. func TestBuildCascadeText_ResumeIgnoresSwitchover(t *testing.T) { messages := []windsurf.ChatMessage{ {Role: "user", Content: "first"}, {Role: "assistant", Content: "reply"}, {Role: "user", Content: "second question"}, } got := buildCascadeText(messages, "claude-sonnet-4", true, true) if got != "second question" { t.Fatalf("resume=true must return only last user message, got %q", got) } } func TestInjectModelIdentity(t *testing.T) { tests := []struct { name string messages []windsurf.ChatMessage meta *windsurf.ModelMeta modelKey string wantInjected bool }{ { name: "anthropic model without system", messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}}, meta: &windsurf.ModelMeta{Name: "claude-sonnet-4.6", Provider: "anthropic"}, modelKey: "claude-sonnet-4.6", wantInjected: true, }, { name: "client already has system — skip injection", messages: []windsurf.ChatMessage{ {Role: "system", Content: "You are a helpful assistant"}, {Role: "user", Content: "hi"}, }, meta: &windsurf.ModelMeta{Name: "claude-sonnet-4.6", Provider: "anthropic"}, modelKey: "claude-sonnet-4.6", wantInjected: false, }, { name: "nil meta — skip injection", messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}}, meta: nil, modelKey: "unknown", wantInjected: false, }, { name: "unknown provider — skip injection", messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}}, meta: &windsurf.ModelMeta{Name: "some-model", Provider: "unknownvendor"}, modelKey: "some-model", wantInjected: false, }, { name: "openai model without system", messages: []windsurf.ChatMessage{{Role: "user", Content: "hi"}}, meta: &windsurf.ModelMeta{Name: "gpt-4o", Provider: "openai"}, modelKey: "gpt-4o", wantInjected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := injectModelIdentity(tt.messages, tt.meta, tt.modelKey) if tt.wantInjected { if len(result) != len(tt.messages)+1 { t.Fatalf("expected injection (len %d → %d), got len %d", len(tt.messages), len(tt.messages)+1, len(result)) } if result[0].Role != "system" { t.Fatalf("injected message role = %q, want system", result[0].Role) } displayName := tt.meta.Name if !strings.Contains(result[0].Content, displayName) { t.Fatalf("injected content should contain model name %q, got %q", displayName, result[0].Content) } } else { if len(result) != len(tt.messages) { t.Fatalf("expected no injection (len %d), got len %d", len(tt.messages), len(result)) } } }) } } func TestCascadeHistoryBudget(t *testing.T) { tests := []struct { name string modelUID string switchover bool want int }{ {"normal model normal budget", "claude-sonnet-4", false, cascadeMaxHistoryBytes}, {"1m model normal budget", "claude-sonnet-4-1m", false, cascade1MHistoryBytes}, {"normal model switchover", "claude-sonnet-4", true, cascadeSwitchoverHistoryBytes}, {"1m model switchover", "claude-sonnet-4-1m", true, cascadeSwitchoverHistoryBytes}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := cascadeHistoryBudget(tt.modelUID, tt.switchover); got != tt.want { t.Errorf("cascadeHistoryBudget(%q, %v) = %d, want %d", tt.modelUID, tt.switchover, got, tt.want) } }) } }