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 TestLastUserIsToolResponse(t *testing.T) {
messages := []windsurf.ChatMessage{
{Role: "user", Content: "please inspect git diff"},
{Role: "assistant", Content: `{"name":"bash","arguments":{"cmd":"git diff"}}`},
{Role: "user", Content: `diff --git a/file b/file`},
}
if !lastUserIsToolResponse(messages) {
t.Fatal("tool_response user turn should disable last-turn-only cascade reuse")
}
messages = append(messages, windsurf.ChatMessage{Role: "user", Content: "now summarize it"})
if lastUserIsToolResponse(messages) {
t.Fatal("normal user turn after tool_response should be reusable")
}
}
func TestLastUserIsToolContinuationDetectsTranscriptStyleOutput(t *testing.T) {
messages := []windsurf.ChatMessage{
{Role: "user", Content: "分析一下这个项目 我感觉 计费逻辑出问题了"},
{Role: "assistant", Content: "我来分析一下这个项目的计费逻辑。先定位计费相关代码。"},
{Role: "user", Content: "Searched for 2 patterns, listed 1 directory\n\nBash(find backend -type f -name '*.go')\n ⎿ backend/internal/service/billing_service.go"},
}
if !lastUserIsToolContinuation(messages) {
t.Fatal("transcript-style tool output should disable last-turn-only cascade reuse")
}
}
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)
}
})
}
}