package service import ( "errors" "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/windsurf" ) // buildCascadeText always sends full history regardless of account switches. func TestBuildCascadeText_AlwaysFullHistory(t *testing.T) { const perTurnBytes = 50 * 1024 const turns = 3 // keep small so it fits in the 200KB budget 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}) } messages = append(messages, windsurf.ChatMessage{Role: "user", Content: "final question"}) text := buildCascadeText(messages, "claude-sonnet-4") if !strings.Contains(text, "final question") { t.Fatal("text must include the final user message") } if !strings.Contains(text, "sys") { t.Fatal("text must include the system prompt") } } func TestBuildCascadeText_SingleTurn(t *testing.T) { messages := []windsurf.ChatMessage{ {Role: "system", Content: "be helpful"}, {Role: "user", Content: "hello"}, } got := buildCascadeText(messages, "claude-sonnet-4") if !strings.Contains(got, "hello") { t.Fatal("single turn text must contain user message") } if !strings.Contains(got, "be helpful") { t.Fatal("single turn text must contain system prompt") } } 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)) } } }) } } // reuse 路径只送 system + 最后一条 user,不携带历史 func TestBuildCascadeTextForReuse_SystemAndLastUser(t *testing.T) { messages := []windsurf.ChatMessage{ {Role: "system", Content: "be helpful"}, {Role: "user", Content: "first turn"}, {Role: "assistant", Content: "first response"}, {Role: "user", Content: "second turn"}, } got := buildCascadeTextForReuse(messages) if !strings.Contains(got, "second turn") { t.Fatal("reuse text must contain the latest user message") } if !strings.Contains(got, "be helpful") { t.Fatal("reuse text must contain the system prompt") } if strings.Contains(got, "first turn") || strings.Contains(got, "first response") { t.Fatalf("reuse text must NOT carry prior history (Cascade trajectory has it). got=%q", got) } if !strings.Contains(got, "") { t.Fatalf("reuse text must wrap system in tag. got=%q", got) } } func TestBuildCascadeTextForReuse_NoSystem(t *testing.T) { messages := []windsurf.ChatMessage{ {Role: "user", Content: "hello"}, } got := buildCascadeTextForReuse(messages) if got != "hello" { t.Fatalf("expected raw user message when no system; got %q", got) } } func TestBuildCascadeCacheKey_Stable(t *testing.T) { a := buildCascadeCacheKey(1, 2, "claude-sonnet", "http://localhost:42100", "sess-x", "syshash-x") b := buildCascadeCacheKey(1, 2, "claude-sonnet", "http://localhost:42100", "sess-x", "syshash-x") if a != b { t.Fatalf("same inputs must yield same key; %q vs %q", a, b) } if len(a) != 24 { t.Fatalf("cache key length expected 24, got %d (%q)", len(a), a) } } // cacheKey 任一组件变化都必须产生不同的 key(避免错误复用) func TestBuildCascadeCacheKey_DifferentInputsDiffer(t *testing.T) { base := buildCascadeCacheKey(1, 2, "model-a", "ep1", "sess1", "sys1") cases := map[string]string{ "groupID": buildCascadeCacheKey(99, 2, "model-a", "ep1", "sess1", "sys1"), "accountID": buildCascadeCacheKey(1, 99, "model-a", "ep1", "sess1", "sys1"), "modelUID": buildCascadeCacheKey(1, 2, "model-b", "ep1", "sess1", "sys1"), "lsEndpoint": buildCascadeCacheKey(1, 2, "model-a", "ep2", "sess1", "sys1"), "sessionHash": buildCascadeCacheKey(1, 2, "model-a", "ep1", "sess2", "sys1"), "sysHash": buildCascadeCacheKey(1, 2, "model-a", "ep1", "sess1", "sys2"), } for name, k := range cases { if k == base { t.Fatalf("changing %s must produce a different cache key", name) } } } func TestSysPromptHash_DetectsSystemChange(t *testing.T) { a := sysPromptHash([]windsurf.ChatMessage{ {Role: "system", Content: "be helpful"}, {Role: "user", Content: "hi"}, }) b := sysPromptHash([]windsurf.ChatMessage{ {Role: "system", Content: "be helpful"}, {Role: "user", Content: "different user msg — should NOT affect sys hash"}, }) c := sysPromptHash([]windsurf.ChatMessage{ {Role: "system", Content: "be VERY helpful"}, {Role: "user", Content: "hi"}, }) if a != b { t.Fatalf("sysPromptHash must ignore non-system content; %q vs %q", a, b) } if a == c { t.Fatalf("sysPromptHash must reflect system content changes; both are %q", a) } } // 多条 system 拼接顺序敏感(合并成 multipart 时不应让两段交换后产生相同 hash) func TestSysPromptHash_OrderSensitive(t *testing.T) { a := sysPromptHash([]windsurf.ChatMessage{ {Role: "system", Content: "alpha"}, {Role: "system", Content: "beta"}, }) b := sysPromptHash([]windsurf.ChatMessage{ {Role: "system", Content: "beta"}, {Role: "system", Content: "alpha"}, }) if a == b { t.Fatalf("sysPromptHash must be order-sensitive across multiple system messages") } } func TestIsPanelNotFound(t *testing.T) { cases := []struct { name string err error want bool }{ {"nil", nil, false}, {"unrelated error", errors.New("network unreachable"), false}, {"exact phrase", errors.New("Cascade panel state not found"), true}, {"lower case variant", errors.New("panel state not found: id=abc"), true}, {"not_found code", errors.New("rpc error: code=not_found, panel missing"), true}, {"missing one keyword", errors.New("not_found"), false}, } for _, tt := range cases { t.Run(tt.name, func(t *testing.T) { if got := isPanelNotFound(tt.err); got != tt.want { t.Fatalf("isPanelNotFound(%v) = %v, want %v", tt.err, got, tt.want) } }) } } func TestCascadeHistoryBudget(t *testing.T) { tests := []struct { name string modelUID string want int }{ {"normal model", "claude-sonnet-4", cascadeMaxHistoryBytes}, {"1m model", "claude-sonnet-4-1m", cascade1MHistoryBytes}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := cascadeHistoryBudget(tt.modelUID); got != tt.want { t.Errorf("cascadeHistoryBudget(%q) = %d, want %d", tt.modelUID, got, tt.want) } }) } }