sub2api/backend/internal/service/windsurf_chat_service_test.go

168 lines
5.9 KiB
Go

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)
}
})
}
}