sub2api/backend/internal/service/windsurf_chat_service_test.go
win 0a3666ef24
Some checks failed
Security Scan / backend-security (push) Failing after 1m31s
Security Scan / frontend-security (push) Failing after 7s
CI / test (push) Failing after 6s
CI / frontend (push) Failing after 4s
CI / golangci-lint (push) Failing after 4s
CI / windsurf-platform (macos-latest) (push) Has been cancelled
CI / windsurf-platform (windows-latest) (push) Has been cancelled
x
2026-04-29 10:32:36 +08:00

263 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, "<system_instructions>") {
t.Fatalf("reuse text must wrap system in <system_instructions> 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)
}
})
}
}