// compact_test.go -- 上下文压缩的单元测试. // // 覆盖场景: // - EstimateTokens token 估算 // - MicroCompact 微压缩 // - estimateTextTokens 文本 token 估算 // - isCJKRune CJK 字符判断 // - isWordChar 单词字符判断 // - extractFilePaths 文件路径提取 // - isLikelyFilePath 文件路径判断 // - StripImages 图像剥离 // - CompactStrategy 多策略 // - CompactWithStrategy Policy 接入 // - 三层压缩降级(CompactTiered) // - 分块切分逻辑(chunkByTokenBudget 轮次边界正确性) // - 断路器 rate limit 不计入 // - 断路器时间重置(超时后恢复) // - isPromptTooLong / isRateLimit 错误检测 package context import ( "context" "encoding/json" "fmt" "strings" "testing" "time" ) // TestEstimateTokens_English 测试英文文本估算 func TestEstimateTokens_English(t *testing.T) { msg := []CompactMessage{ {Role: "user", Content: jsonStr("Hello, how are you doing today?")}, } tokens := EstimateTokens(msg) if tokens < 5 { t.Errorf("token 估算太少: %d", tokens) } } // TestEstimateTokens_CJK 测试中文文本估算 func TestEstimateTokens_CJK(t *testing.T) { msg := []CompactMessage{ {Role: "user", Content: jsonStr("你好世界这是一个测试")}, } tokens := EstimateTokens(msg) if tokens < 5 { t.Errorf("CJK token 估算太少: %d", tokens) } } // TestEstimateTokens_Empty 测试空消息 func TestEstimateTokens_Empty(t *testing.T) { tokens := EstimateTokens(nil) // 空消息列表,但 EstimateTokens 会加消息序列格式开销(3 token) if tokens > 10 { t.Errorf("空消息估算应较小, 实际: %d", tokens) } } // TestEstimateTextTokens 测试文本 token 估算 func TestEstimateTextTokens(t *testing.T) { // 英文 tokens := estimateTextTokens("The quick brown fox jumps over the lazy dog") if tokens < 5 { t.Errorf("英文估算太少: %d", tokens) } // 空文本 if estimateTextTokens("") != 0 { t.Error("空文本应返回 0") } // 纯标点 tokens = estimateTextTokens("!!!???...") if tokens < 1 { t.Errorf("标点估算太少: %d", tokens) } } // TestMicroCompact 测试微压缩 func TestMicroCompact(t *testing.T) { // 创建包含长内容的消息 longContent := strings.Repeat("x", 20000) toolResult := []map[string]any{ { "type": "tool_result", "content": longContent, }, } toolResultJSON, _ := json.Marshal(toolResult) messages := make([]CompactMessage, 20) for i := range messages { if i < 10 { // 旧消息包含长工具结果 messages[i] = CompactMessage{Role: "user", Content: toolResultJSON} } else { // 新消息 messages[i] = CompactMessage{Role: "user", Content: jsonStr("recent message")} } } compacted := MicroCompact(messages) if len(compacted) != 20 { t.Fatalf("消息数量不应变化: %d", len(compacted)) } // 旧消息中的长内容应被截断 var blocks []map[string]any if err := json.Unmarshal(compacted[0].Content, &blocks); err == nil { if len(blocks) > 0 { content, ok := blocks[0]["content"].(string) if ok && len(content) >= 20000 { t.Error("旧消息中的长内容应被截断") } } } } // TestMicroCompact_Empty 测试空消息微压缩 func TestMicroCompact_Empty(t *testing.T) { result := MicroCompact(nil) if len(result) != 0 { t.Error("空消息应返回空") } } // TestMicroCompact_TooFew 测试消息太少不压缩 func TestMicroCompact_TooFew(t *testing.T) { messages := []CompactMessage{ {Role: "user", Content: jsonStr("hello")}, {Role: "assistant", Content: jsonStr("hi")}, } result := MicroCompact(messages) if len(result) != 2 { t.Errorf("消息太少不应变化: %d", len(result)) } } // TestIsCJKRune 测试 CJK 字符判断 func TestIsCJKRune(t *testing.T) { if !isCJKRune('你') { t.Error("中文字符应为 CJK") } if !isCJKRune('の') { t.Error("日文字符应为 CJK") } if isCJKRune('a') { t.Error("英文不应为 CJK") } if isCJKRune('1') { t.Error("数字不应为 CJK") } } // TestIsWordChar 测试单词字符判断 func TestIsWordChar(t *testing.T) { // 字母 if !isWordChar('a') || !isWordChar('Z') { t.Error("字母应为单词字符") } // 数字 if !isWordChar('0') || !isWordChar('9') { t.Error("数字应为单词字符") } // 下划线 if !isWordChar('_') { t.Error("下划线应为单词字符") } // 非单词字符 if isWordChar(' ') || isWordChar('!') || isWordChar('@') { t.Error("特殊字符不应为单词字符") } } // TestExtractFilePaths 测试文件路径提取 func TestExtractFilePaths(t *testing.T) { content := `{"file_path":"/src/main.go"} and {"path":"/pkg/handler.go"}` paths := extractFilePaths(content) if len(paths) != 2 { t.Fatalf("期望 2 个路径, 实际 %d", len(paths)) } } // TestIsLikelyFilePath 测试文件路径判断 func TestIsLikelyFilePath(t *testing.T) { tests := []struct { path string want bool }{ {"/src/main.go", true}, {"./src/main.go", true}, {"main.go", true}, {"file.ts", true}, {"http://example.com", false}, {"a", false}, {"", false}, } for _, tt := range tests { got := isLikelyFilePath(tt.path) if got != tt.want { t.Errorf("isLikelyFilePath(%q) = %v, 期望 %v", tt.path, got, tt.want) } } } // jsonStr 辅助函数:将字符串转为 json.RawMessage func jsonStr(s string) json.RawMessage { data, _ := json.Marshal(s) return data } // --- 三层防御测试 --- // TestBuildFallbackSummary 测试降级摘要构建 func TestBuildFallbackSummary(t *testing.T) { // 空消息 summary := buildFallbackSummary(nil) if summary != "Conversation history was compacted." { t.Errorf("empty fallback = %q", summary) } // 正常消息 messages := []CompactMessage{ {Role: "user", Content: jsonStr("msg 1")}, {Role: "assistant", Content: jsonStr("msg 2")}, {Role: "user", Content: jsonStr("msg 3")}, {Role: "assistant", Content: jsonStr("msg 4")}, {Role: "user", Content: jsonStr("msg 5")}, } summary = buildFallbackSummary(messages) // 应包含最后 3 条消息的内容 if !strings.Contains(summary, "msg 3") { t.Error("fallback summary should contain recent messages") } if !strings.Contains(summary, "msg 5") { t.Error("fallback summary should contain most recent message") } // 超长内容应被截断 longMsg := strings.Repeat("x", 1000) messages = []CompactMessage{ {Role: "user", Content: jsonStr(longMsg)}, } summary = buildFallbackSummary(messages) if len(summary) > 600 { t.Errorf("fallback summary should truncate long content, len=%d", len(summary)) } } // TestBuildFallbackSummary_SingleMessage 测试只有一条消息的情况 func TestBuildFallbackSummary_SingleMessage(t *testing.T) { messages := []CompactMessage{ {Role: "user", Content: jsonStr("only message")}, } summary := buildFallbackSummary(messages) if !strings.Contains(summary, "only message") { t.Error("single message fallback should contain the message") } } // --- 电路断路器测试 --- // TestCompactCircuitBreaker 测试基本流程 func TestCompactCircuitBreaker(t *testing.T) { cb := NewCompactCircuitBreaker(3) // 初始应允许尝试 if !cb.ShouldAttempt() { t.Error("should allow attempt initially") } // 记录失败(非 rate limit) cb.RecordFailure(false) cb.RecordFailure(false) if !cb.ShouldAttempt() { t.Error("should allow attempt after 2 failures (max=3)") } if cb.Failures() != 2 { t.Errorf("failures = %d, want 2", cb.Failures()) } // 第三次失败后应拒绝 cb.RecordFailure(false) if cb.ShouldAttempt() { t.Error("should NOT allow attempt after 3 failures") } // Reset 后恢复 cb.Reset() if !cb.ShouldAttempt() { t.Error("should allow attempt after Reset") } if cb.Failures() != 0 { t.Errorf("failures after Reset = %d, want 0", cb.Failures()) } } // TestCompactCircuitBreaker_DefaultMax 测试默认最大失败次数 func TestCompactCircuitBreaker_DefaultMax(t *testing.T) { cb := NewCompactCircuitBreaker(0) // 0 应使用默认值 3 cb.RecordFailure(false) cb.RecordFailure(false) if !cb.ShouldAttempt() { t.Error("default max should be 3, should allow after 2 failures") } cb.RecordFailure(false) if cb.ShouldAttempt() { t.Error("default max should be 3, should NOT allow after 3 failures") } } // --- 图像剥离测试 --- // TestStripImages_WithImages 测试剥离图像块 func TestStripImages_WithImages(t *testing.T) { blocks := []map[string]any{ {"type": "text", "text": "hello"}, {"type": "image", "source": map[string]any{"type": "base64", "data": "abc123"}}, {"type": "text", "text": "world"}, } blocksJSON, _ := json.Marshal(blocks) msgs := []CompactMessage{ {Role: "user", Content: blocksJSON}, } stripped := StripImages(msgs) if len(stripped) != 1 { t.Fatalf("消息数量不应变化: %d", len(stripped)) } var resultBlocks []map[string]any if err := json.Unmarshal(stripped[0].Content, &resultBlocks); err != nil { t.Fatalf("解析结果失败: %v", err) } // 应有 3 个块:text + placeholder + text if len(resultBlocks) != 3 { t.Fatalf("期望 3 个块, 实际 %d", len(resultBlocks)) } // 第二个块应该是占位文本 if text, _ := resultBlocks[1]["text"].(string); !strings.Contains(text, "removed") { t.Errorf("图像占位文本应包含 'removed', 实际 %q", text) } } // TestStripImages_NoImages 测试无图像时不变 func TestStripImages_NoImages(t *testing.T) { msgs := []CompactMessage{ {Role: "user", Content: jsonStr("hello")}, {Role: "assistant", Content: jsonStr("hi")}, } stripped := StripImages(msgs) if len(stripped) != 2 { t.Fatalf("消息数量不应变化: %d", len(stripped)) } } // TestStripImages_EmbeddedImage 测试嵌套图像剥离 func TestStripImages_EmbeddedImage(t *testing.T) { blocks := []map[string]any{ { "type": "document", "source": map[string]any{ "type": "base64", "media_type": "image/png", "data": "abc123", }, }, } blocksJSON, _ := json.Marshal(blocks) msgs := []CompactMessage{ {Role: "user", Content: blocksJSON}, } stripped := StripImages(msgs) var resultBlocks []map[string]any if err := json.Unmarshal(stripped[0].Content, &resultBlocks); err != nil { t.Fatalf("解析结果失败: %v", err) } if len(resultBlocks) != 1 { t.Fatalf("期望 1 个块, 实际 %d", len(resultBlocks)) } if text, _ := resultBlocks[0]["text"].(string); !strings.Contains(text, "removed") { t.Errorf("嵌套图像占位文本应包含 'removed', 实际 %q", text) } } // --- 多策略测试 --- // TestCompactStrategy_Constants 测试策略常量 func TestCompactStrategy_Constants(t *testing.T) { if StrategyFull != "full" { t.Errorf("StrategyFull = %q", StrategyFull) } if StrategyPartial != "partial" { t.Errorf("StrategyPartial = %q", StrategyPartial) } if StrategyReactive != "reactive" { t.Errorf("StrategyReactive = %q", StrategyReactive) } } // TestCompactResult_Fields 测试 CompactResult 字段 func TestCompactResult_Fields(t *testing.T) { result := &CompactResult{ Summary: "test summary", Strategy: StrategyFull, TokensBefore: 100000, TokensAfter: 5000, DurationMs: 150, } if result.Strategy != StrategyFull { t.Errorf("Strategy = %q", result.Strategy) } if result.TokensBefore != 100000 { t.Errorf("TokensBefore = %d", result.TokensBefore) } } // TestCompressor_SetPolicy 测试策略设置 func TestCompressor_SetPolicy(t *testing.T) { c := NewCompressor(0, nil) p := &DefaultCodePolicy{} c.SetPolicy(p) // 不 panic 即可 } // TestCompressor_SetRestoreManager 测试恢复管理器设置 func TestCompressor_SetRestoreManager(t *testing.T) { c := NewCompressor(0, nil) rm := NewRestoreManager(0) c.SetRestoreManager(rm) // 不 panic 即可 } // TestCompactReactive 测试反应式压缩 func TestCompactReactive(t *testing.T) { c := NewCompressor(0, nil) // 构建足够多的消息 msgs := make([]CompactMessage, 20) for i := 0; i < 20; i++ { if i%2 == 0 { msgs[i] = CompactMessage{Role: "user", Content: jsonStr("user msg")} } else { msgs[i] = CompactMessage{Role: "assistant", Content: jsonStr("assistant msg")} } } result, err := c.CompactWithStrategy(context.Background(), msgs, StrategyReactive, nil) if err != nil { t.Fatalf("CompactWithStrategy 错误: %v", err) } if result.Strategy != StrategyReactive { t.Errorf("Strategy = %q, 期望 reactive", result.Strategy) } // 反应式压缩应减少消息数量 if len(result.Messages) >= 20 { t.Errorf("反应式压缩后消息应减少, 实际 %d", len(result.Messages)) } } // TestFormatRestoredItems 测试恢复项格式化 func TestFormatRestoredItems(t *testing.T) { // 空 if formatRestoredItems(nil) != "" { t.Error("空恢复项应返回空字符串") } items := []RestoreItem{ {Type: "file", Name: "/a.go", Content: "[Recently accessed file]: /a.go"}, {Type: "state", Name: "mcp", Content: "[Active MCP servers]: server-a"}, } text := formatRestoredItems(items) if !strings.Contains(text, "Post-compact context restoration") { t.Error("应包含恢复标题") } if !strings.Contains(text, "/a.go") { t.Error("应包含文件路径") } if !strings.Contains(text, "server-a") { t.Error("应包含 MCP 服务器") } } // --- 三层压缩降级测试 --- // TestCompactTiered_Layer1_SingleCompactSuccess 测试第 1 层单次压缩成功 func TestCompactTiered_Layer1_SingleCompactSuccess(t *testing.T) { // 构建一个 token 量较少的会话(不会触发 PTL) // 注意:CompactTiered 最终调用 compactFull → generateSummary, // 而 generateSummary 会做 HTTP 请求.我们测试的是流程逻辑, // 所以这里只验证 token 估算和路由逻辑. messages := make([]CompactMessage, 20) for i := 0; i < 20; i++ { if i%2 == 0 { messages[i] = CompactMessage{Role: "user", Content: jsonStr("user message")} } else { messages[i] = CompactMessage{Role: "assistant", Content: jsonStr("assistant response")} } } // 验证 token 估算在窗口内 tokens := EstimateTokens(messages) if tokens >= DefaultContextWindow*85/100 { t.Skipf("消息 token 数 %d 超预期,跳过本测试", tokens) } // 验证 getContextWindow 返回合理值 c := NewCompressor(0, nil) window := c.getContextWindow() if window <= 0 { t.Errorf("getContextWindow 返回 %d,应 > 0", window) } // 验证断路器初始状态 if !c.circuitBreaker.ShouldAttempt() { t.Error("断路器初始应允许尝试") } } // TestCompactTiered_CircuitBreakerBlock 测试断路器打开时拒绝压缩 func TestCompactTiered_CircuitBreakerBlock(t *testing.T) { c := NewCompressor(0, nil) // 触发断路器 c.circuitBreaker.RecordFailure(false) c.circuitBreaker.RecordFailure(false) c.circuitBreaker.RecordFailure(false) messages := []CompactMessage{ {Role: "user", Content: jsonStr("hello")}, } _, err := c.CompactTiered(context.Background(), messages, nil) if err == nil { t.Error("断路器打开时应返回错误") } if !strings.Contains(err.Error(), "circuit breaker open") { t.Errorf("错误消息应包含 'circuit breaker open',实际: %s", err.Error()) } } // TestChunkByTokenBudget_Basic 测试基本分块逻辑 func TestChunkByTokenBudget_Basic(t *testing.T) { groups := []MessageGroup{ {Index: 0, Tokens: 10000, Messages: []CompactMessage{{Role: "user", Content: jsonStr("g0")}}}, {Index: 1, Tokens: 15000, Messages: []CompactMessage{{Role: "assistant", Content: jsonStr("g1")}}}, {Index: 2, Tokens: 20000, Messages: []CompactMessage{{Role: "user", Content: jsonStr("g2")}}}, {Index: 3, Tokens: 10000, Messages: []CompactMessage{{Role: "assistant", Content: jsonStr("g3")}}}, {Index: 4, Tokens: 25000, Messages: []CompactMessage{{Role: "user", Content: jsonStr("g4")}}}, } // 预算 30000:第 1 块 [g0, g1](25000),第 2 块 [g2](20000),第 3 块 [g3, g4](35000 > 30000 → g3 单独,g4 单独?不对) // 实际:g0(10k)+g1(15k)=25k < 30k → 继续; +g2(20k)=45k > 30k → 切块 // 所以第 1 块 [g0,g1], g2(20k) < 30k → 继续; +g3(10k)=30k → 继续; +g4(25k)=55k > 30k → 切块 // 第 2 块 [g2,g3], 第 3 块 [g4] chunks := chunkByTokenBudget(groups, 30000) if len(chunks) != 3 { t.Fatalf("期望 3 个分块, 实际 %d", len(chunks)) } // 第 1 块: [g0, g1] if len(chunks[0]) != 2 { t.Errorf("第 1 块组数: %d, 期望 2", len(chunks[0])) } // 第 2 块: [g2, g3] if len(chunks[1]) != 2 { t.Errorf("第 2 块组数: %d, 期望 2", len(chunks[1])) } // 第 3 块: [g4] if len(chunks[2]) != 1 { t.Errorf("第 3 块组数: %d, 期望 1", len(chunks[2])) } } // TestChunkByTokenBudget_EmptyGroups 测试空组列表 func TestChunkByTokenBudget_EmptyGroups(t *testing.T) { chunks := chunkByTokenBudget(nil, 50000) if chunks != nil { t.Errorf("空组列表应返回 nil, 实际 %d 块", len(chunks)) } } // TestChunkByTokenBudget_SingleLargeGroup 测试单个超大组 func TestChunkByTokenBudget_SingleLargeGroup(t *testing.T) { groups := []MessageGroup{ {Index: 0, Tokens: 100000, Messages: []CompactMessage{{Role: "user", Content: jsonStr("huge")}}}, } chunks := chunkByTokenBudget(groups, 50000) if len(chunks) != 1 { t.Fatalf("单个超大组应形成 1 块, 实际 %d", len(chunks)) } if len(chunks[0]) != 1 { t.Errorf("块内应有 1 组, 实际 %d", len(chunks[0])) } } // TestChunkByTokenBudget_RoundBoundaryIntegrity 测试轮次边界完整性 func TestChunkByTokenBudget_RoundBoundaryIntegrity(t *testing.T) { // 模拟完整的 API 往返组 groups := []MessageGroup{ {Index: 0, Tokens: 5000, Messages: []CompactMessage{ {Role: "user", Content: jsonStr("system context")}, }}, {Index: 1, Tokens: 8000, Messages: []CompactMessage{ {Role: "assistant", Content: jsonStr("response 1")}, {Role: "user", Content: jsonStr("tool result 1")}, }}, {Index: 2, Tokens: 12000, Messages: []CompactMessage{ {Role: "assistant", Content: jsonStr("response 2")}, {Role: "user", Content: jsonStr("tool result 2")}, }}, } chunks := chunkByTokenBudget(groups, 15000) // 验证每个块内的消息组是完整的(没有拆开 assistant+tool_result) for ci, chunk := range chunks { for gi, g := range chunk { if len(g.Messages) == 0 { t.Errorf("块 %d 组 %d 消息为空", ci, gi) } } } } // TestCircuitBreaker_RateLimitNotCounted 测试 rate limit 不计入失败次数 func TestCircuitBreaker_RateLimitNotCounted(t *testing.T) { cb := NewCompactCircuitBreaker(3) // rate limit 不应计入 cb.RecordFailure(true) // isRateLimit = true cb.RecordFailure(true) cb.RecordFailure(true) cb.RecordFailure(true) cb.RecordFailure(true) if !cb.ShouldAttempt() { t.Error("rate limit 不应计入失败次数,断路器不应打开") } if cb.Failures() != 0 { t.Errorf("rate limit 后失败次数应为 0, 实际 %d", cb.Failures()) } // 混合:2 次真实失败 + 5 次 rate limit cb.RecordFailure(false) cb.RecordFailure(true) cb.RecordFailure(false) cb.RecordFailure(true) if cb.Failures() != 2 { t.Errorf("2 次真实失败后 Failures 应为 2, 实际 %d", cb.Failures()) } if !cb.ShouldAttempt() { t.Error("2 次失败(max=3)应允许尝试") } // 第 3 次真实失败 → 断路器打开 cb.RecordFailure(false) if cb.ShouldAttempt() { t.Error("3 次真实失败后断路器应打开") } } // TestCircuitBreaker_TimeReset 测试断路器时间重置 func TestCircuitBreaker_TimeReset(t *testing.T) { cb := NewCompactCircuitBreaker(2) // 设置很短的 resetAfter 以便测试 cb.resetAfter = 50 * time.Millisecond // 触发断路器 cb.RecordFailure(false) cb.RecordFailure(false) if cb.ShouldAttempt() { t.Error("断路器应已打开") } // 等待时间重置 time.Sleep(100 * time.Millisecond) // 时间重置后应恢复 if !cb.ShouldAttempt() { t.Error("时间重置后断路器应恢复") } // 恢复后失败计数应已清零 if cb.Failures() != 0 { t.Errorf("时间重置后失败计数应为 0, 实际 %d", cb.Failures()) } } // TestCircuitBreaker_TimeResetNotTriggeredEarly 测试时间未到时不重置 func TestCircuitBreaker_TimeResetNotTriggeredEarly(t *testing.T) { cb := NewCompactCircuitBreaker(2) cb.resetAfter = 1 * time.Hour // 很长的重置时间 cb.RecordFailure(false) cb.RecordFailure(false) if cb.ShouldAttempt() { t.Error("时间未到,断路器不应重置") } } // TestIsPromptTooLong 测试 PTL 错误检测 func TestIsPromptTooLong(t *testing.T) { tests := []struct { err error want bool }{ {nil, false}, {fmt.Errorf("something else"), false}, {fmt.Errorf("prompt is too long"), true}, {fmt.Errorf("Prompt Too Long for this model"), true}, {fmt.Errorf("too many tokens in the request"), true}, {fmt.Errorf("context length exceeded"), true}, {fmt.Errorf("maximum context length"), true}, {fmt.Errorf("HTTP 413: payload too large"), true}, } for _, tt := range tests { got := isPromptTooLong(tt.err) if got != tt.want { errStr := "" if tt.err != nil { errStr = tt.err.Error() } t.Errorf("isPromptTooLong(%q) = %v, 期望 %v", errStr, got, tt.want) } } } // TestIsRateLimit 测试 rate limit 错误检测 func TestIsRateLimit(t *testing.T) { tests := []struct { err error want bool }{ {nil, false}, {fmt.Errorf("something else"), false}, {fmt.Errorf("HTTP 429: Too Many Requests"), true}, {fmt.Errorf("HTTP 529: Overloaded"), true}, {fmt.Errorf("rate limit exceeded"), true}, {fmt.Errorf("server is overloaded"), true}, } for _, tt := range tests { got := isRateLimit(tt.err) if got != tt.want { errStr := "" if tt.err != nil { errStr = tt.err.Error() } t.Errorf("isRateLimit(%q) = %v, 期望 %v", errStr, got, tt.want) } } } // TestTruncateAndRetryCompact_InsufficientGroups 测试组太少时的行为 func TestTruncateAndRetryCompact_InsufficientGroups(t *testing.T) { c := NewCompressor(0, nil) // 只有 2 个组(不够砍) messages := []CompactMessage{ {Role: "user", Content: jsonStr("hello")}, {Role: "assistant", Content: jsonStr("hi")}, {Role: "user", Content: jsonStr("bye")}, } groups := GroupByAPIRound(messages) _, err := c.truncateAndRetryCompact(context.Background(), messages, groups, nil, 0) if err == nil { t.Error("组太少时应返回错误") } } // TestChunkedCompact_TooFewGroups 测试组太少时 chunkedCompact 的行为 // 当 keepRounds >= len(groups) 时,splitGroupIdx 被 clamp 到 1, // 所以至少 preamble 会被压缩.如果没有真实 API,压缩会失败. // 真正的 "组太少返回原始消息" 场景是只有 1 个组的情况. func TestChunkedCompact_TooFewGroups(t *testing.T) { c := NewCompressor(0, nil) // 只有 1 条 user 消息 → 1 个组 → splitGroupIdx=1 >= len(groups)=1 → 返回原始消息 messages := []CompactMessage{ {Role: "user", Content: jsonStr("hello")}, } groups := GroupByAPIRound(messages) result, err := c.chunkedCompact(context.Background(), messages, groups, nil) if err != nil { t.Fatalf("组太少时不应返回错误: %v", err) } if result == nil { t.Fatal("结果不应为 nil") } // 应返回原始消息 if len(result.Messages) != len(messages) { t.Errorf("组太少时应返回原始消息, 消息数 %d, 期望 %d", len(result.Messages), len(messages)) } } // TestCompressorCircuitBreakerAccessor 测试 CircuitBreaker 访问器 func TestCompressorCircuitBreakerAccessor(t *testing.T) { c := NewCompressor(0, nil) cb := c.CircuitBreaker() if cb == nil { t.Fatal("CircuitBreaker 不应为 nil") } if !cb.ShouldAttempt() { t.Error("新建的断路器应允许尝试") } } // TestGetContextWindow 测试压缩模型上下文窗口获取 func TestGetContextWindow(t *testing.T) { c := NewCompressor(0, nil) window := c.getContextWindow() if window <= 0 { t.Errorf("上下文窗口应 > 0, 实际 %d", window) } // 默认模型是 claude-haiku-3-5,窗口应为 200000 if window != 200000 { t.Errorf("默认模型窗口应为 200000, 实际 %d", window) } } // TestChunkByTokenBudget_AllFitInOneChunk 测试所有组能放入一块 func TestChunkByTokenBudget_AllFitInOneChunk(t *testing.T) { groups := []MessageGroup{ {Index: 0, Tokens: 1000}, {Index: 1, Tokens: 2000}, {Index: 2, Tokens: 3000}, } chunks := chunkByTokenBudget(groups, 100000) if len(chunks) != 1 { t.Errorf("所有组在预算内应形成 1 块, 实际 %d", len(chunks)) } if len(chunks[0]) != 3 { t.Errorf("块内应有 3 组, 实际 %d", len(chunks[0])) } } // TestChunkByTokenBudget_EachGroupOneChunk 测试每组一块(极端场景) func TestChunkByTokenBudget_EachGroupOneChunk(t *testing.T) { groups := []MessageGroup{ {Index: 0, Tokens: 10000}, {Index: 1, Tokens: 10000}, {Index: 2, Tokens: 10000}, } // 预算 10000:每组刚好一块 chunks := chunkByTokenBudget(groups, 10000) if len(chunks) != 3 { t.Errorf("每组应独立一块, 期望 3 块, 实际 %d", len(chunks)) } } // TestCircuitBreaker_ResetClearsLastFailure 测试 Reset 清除 lastFailure func TestCircuitBreaker_ResetClearsLastFailure(t *testing.T) { cb := NewCompactCircuitBreaker(2) cb.resetAfter = 50 * time.Millisecond // 记录失败 cb.RecordFailure(false) cb.RecordFailure(false) // 手动 Reset cb.Reset() // Reset 后再次失败和检查 cb.RecordFailure(false) if !cb.ShouldAttempt() { t.Error("Reset 后 1 次失败(max=2)应允许尝试") } } // --- 6.5 压缩结果为空兜底测试 --- // TestBuildFallbackSummary_Empty 测试空消息列表时返回固定字符串 func TestBuildFallbackSummary_Empty(t *testing.T) { result := buildFallbackSummary(nil) if result == "" { t.Error("空消息列表 fallback 应返回非空字符串") } if !strings.Contains(result, "compacted") { t.Errorf("空消息列表 fallback 应包含 'compacted',实际: %q", result) } } // TestBuildFallbackSummary_FewMessages 测试消息少于 compactFallbackMessages 时全部包含 func TestBuildFallbackSummary_FewMessages(t *testing.T) { msgs := []CompactMessage{ {Role: "user", Content: jsonStr("hello world")}, {Role: "assistant", Content: jsonStr("hi there")}, } result := buildFallbackSummary(msgs) if result == "" { t.Error("fallback 摘要不应为空") } // 所有消息都应包含在摘要中 if !strings.Contains(result, "hello world") { t.Errorf("fallback 应包含 user 消息,实际: %q", result) } if !strings.Contains(result, "hi there") { t.Errorf("fallback 应包含 assistant 消息,实际: %q", result) } } // TestBuildFallbackSummary_ManyMessages 测试超过 compactFallbackMessages 时只取最近 N 条 func TestBuildFallbackSummary_ManyMessages(t *testing.T) { // 构建 10 条消息,只有最后 compactFallbackMessages 条应出现在 fallback 里 msgs := make([]CompactMessage, 10) for i := 0; i < 10; i++ { role := "user" if i%2 != 0 { role = "assistant" } msgs[i] = CompactMessage{Role: role, Content: jsonStr(fmt.Sprintf("message-%d", i))} } result := buildFallbackSummary(msgs) if result == "" { t.Error("fallback 摘要不应为空") } // 最后 compactFallbackMessages(5) 条消息应出现 for i := 10 - compactFallbackMessages; i < 10; i++ { expected := fmt.Sprintf("message-%d", i) if !strings.Contains(result, expected) { t.Errorf("fallback 应包含最近消息 %q,实际: %q", expected, result) } } // 早期消息不应出现 for i := 0; i < 10-compactFallbackMessages; i++ { notExpected := fmt.Sprintf("message-%d", i) if strings.Contains(result, notExpected) { t.Errorf("fallback 不应包含早期消息 %q", notExpected) } } } // TestBuildFallbackSummary_LongContent 测试内容超过 500 字符时被截断 func TestBuildFallbackSummary_LongContent(t *testing.T) { longContent := strings.Repeat("x", 600) msgs := []CompactMessage{ {Role: "user", Content: jsonStr(longContent)}, } result := buildFallbackSummary(msgs) // 截断后内容长度应 <= 500 + len("...") + 角色前缀长度 if len(result) > 600 { t.Errorf("fallback 应截断长内容,实际长度: %d", len(result)) } if !strings.Contains(result, "...") { t.Errorf("截断的内容应包含 '...',实际: %q", result) } } // TestCompactFallbackMessages_Constant 测试常量值 func TestCompactFallbackMessages_Constant(t *testing.T) { // compactFallbackMessages 必须等于 5(设计文档约定) if compactFallbackMessages != 5 { t.Errorf("compactFallbackMessages 应为 5,实际: %d", compactFallbackMessages) } } // TestBuildFallbackSummary_AllEmptyContent 测试所有消息内容为空时返回固定字符串 func TestBuildFallbackSummary_AllEmptyContent(t *testing.T) { msgs := []CompactMessage{ {Role: "user", Content: jsonStr("")}, {Role: "assistant", Content: jsonStr("")}, } result := buildFallbackSummary(msgs) // 所有内容为空时,parts 为空,应回落到固定字符串 if result == "" { t.Error("所有内容为空时 fallback 应返回非空字符串") } }