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
263 lines
8.5 KiB
Go
263 lines
8.5 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|