package engine // compact_event_test.go — validates the full-compact path of // engine.maybeCompact emits a *flyto.CompactEvent with Kind="full", // non-empty Summary, and TokensBefore > TokensAfter. // // Background: commit 7172f04 plugged five silent compaction paths so // every path emits *flyto.CompactEvent. The micro path was already // observed in r31 production (turn 2→3 input 16K→8K). This test pins // the rarer but more critical full path (the one that calls the LLM to // generate Summary), keeping it from regressing into silent again. // // Strategy (Option A — same-package whitebox test): // 1. Build an *Engine struct directly with the minimum fields // maybeCompact touches: cfg, observer, sectionRegistry. // 2. Construct a real *agentctx.Compressor with a low threshold (1000 // tokens) and a context-window override that keeps singleCompact // on the first tier (no truncate-and-retry). // 3. Inject a scriptedSummaryProvider that emits one *TextDeltaEvent // ("FAKE_SUMMARY ..."). The compressor's generateSummaryViaProvider // consumes TextDeltaEvent + closes the stream as success. // 4. Build text-only user/assistant messages each ~2KB so: // - EstimateTokens > threshold → ShouldCompact=true // - MicroCompact only trims tool_result blocks, leaves text-only // messages untouched → ShouldCompact still true after micro // → falls through to full DoCompact. // 5. Buffered ch (capacity 4) so maybeCompact can send the event // synchronously without a reader goroutine. // 6. Assert exactly one *flyto.CompactEvent with Kind="full", // non-empty Summary, TokensBefore > TokensAfter, TokensBefore > // threshold. // // compact_event_test.go — 验证 engine.maybeCompact 的完整压缩路径 // emit *flyto.CompactEvent{Kind:"full", Summary:非空, // TokensBefore > TokensAfter}. // // 背景: commit 7172f04 修了 5 条 silent 压缩路径让每条路径都 emit // *flyto.CompactEvent. 微压缩路径已在 r31 实测观察到 (turn 2→3 // input 16K→8K). 本测试钉住更罕见但更关键的完整压缩路径 (走 LLM // 生成 Summary 那条), 防止它再次悄悄退化为 silent. // // 策略 (Option A — 同包白盒测试): // 1. 直接构造 *Engine struct, 只填 maybeCompact 需要的最少字段: // cfg / observer / sectionRegistry. // 2. 构造真 *agentctx.Compressor, threshold 低 (1000 tokens), 注入 // context-window override 让 singleCompact 留在第一层 // (不进 truncate-and-retry). // 3. 注入 scriptedSummaryProvider, 发一个 *TextDeltaEvent // ("FAKE_SUMMARY ...") 后关 channel. compressor 的 // generateSummaryViaProvider 消费 TextDeltaEvent + close 算成功. // 4. 造 text-only user/assistant message 每条 ~2KB, 让: // - EstimateTokens > threshold → ShouldCompact=true // - MicroCompact 只裁 tool_result 块, text-only 不动 // → 微压缩后 ShouldCompact 仍 true → 走完整 DoCompact. // 5. ch buffered cap 4, maybeCompact 不需消费方 goroutine. // 6. 断言收到恰好 1 个 *flyto.CompactEvent, Kind="full", // Summary 非空, TokensBefore > TokensAfter, TokensBefore > 阈值. import ( "context" "encoding/json" "strings" "testing" agentctx "git.flytoex.net/yuanwei/flyto-agent/pkg/context" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" "git.flytoex.net/yuanwei/flyto-agent/pkg/query" ) // scriptedSummaryProvider emits a single TextDeltaEvent "FAKE_SUMMARY" // then closes. Mimics the minimum shape generateSummaryViaProvider // needs: range over events, accumulate TextDeltaEvent.Text, treat close // as success. // // scriptedSummaryProvider 发一个 TextDeltaEvent "FAKE_SUMMARY" 然后 // 关 channel. 模拟 generateSummaryViaProvider 所需的最小形状: range // events, 累积 TextDeltaEvent.Text, channel 关 = 成功. type scriptedSummaryProvider struct { summary string } func (p *scriptedSummaryProvider) Name() string { return "scripted-summary" } func (p *scriptedSummaryProvider) Stream(ctx context.Context, _ *flyto.Request) (<-chan flyto.Event, error) { ch := make(chan flyto.Event, 2) go func() { defer close(ch) select { case <-ctx.Done(): return case ch <- &flyto.TextDeltaEvent{Text: p.summary}: } }() return ch, nil } func (p *scriptedSummaryProvider) Models(_ context.Context) ([]flyto.ModelInfo, error) { return nil, nil } // makeTextMessage constructs a query.Message with one text block of // `body` whose serialized form ends up large enough to push token // estimates over the threshold. text-only on purpose — MicroCompact // only trims tool_result blocks, so micro is a no-op here and the path // must fall through to full DoCompact. // // makeTextMessage 造一个 query.Message 单 text 块, body 足够长把 // token 估算推过阈值. 故意 text-only — MicroCompact 只裁 tool_result // 块, 所以微压缩在这里是 no-op, 路径必走完整 DoCompact. func makeTextMessage(role query.Role, body string) query.Message { return query.Message{ Role: role, Content: []query.Content{{Type: query.ContentText, Text: body}}, } } // TestMaybeCompact_EmitsFullCompactEvent pins commit 7172f04's claim // that the full-compact success path emits *flyto.CompactEvent with // Kind="full". // // TestMaybeCompact_EmitsFullCompactEvent 钉住 commit 7172f04 的承诺: // 完整压缩成功路径 emit *flyto.CompactEvent{Kind:"full"}. func TestMaybeCompact_EmitsFullCompactEvent(t *testing.T) { const threshold = 1000 // Build text-only conversation. Six rounds of ~2KB body each lands // at roughly 12KB raw text, which EstimateTokens scores well above // the 1000-token threshold but well below singleCompact's 85% of // the 32K context window override (so we stay on tier 1). // // 6 轮 ~2KB 文本 (~12KB 总), EstimateTokens 远超 1000-token 阈值 // 但远低于 singleCompact 的 85% × 32K 窗口 (留在第 1 层). body := strings.Repeat("hello world this is a long conversation message that pushes us past the threshold ", 25) messages := []query.Message{ makeTextMessage(query.RoleUser, "round 1 user: "+body), makeTextMessage(query.RoleAssistant, "round 1 assistant: "+body), makeTextMessage(query.RoleUser, "round 2 user: "+body), makeTextMessage(query.RoleAssistant, "round 2 assistant: "+body), makeTextMessage(query.RoleUser, "round 3 user: "+body), makeTextMessage(query.RoleAssistant, "round 3 assistant: "+body), } // Sanity check: confirm the fixture actually triggers the full path. // EstimateTokens on the converted CompactMessages must be > threshold, // and MicroCompact must be a no-op (text-only). // // 自检: 确认 fixture 真触发完整路径. 转 CompactMessage 后 EstimateTokens // 必 > 阈值, 且 MicroCompact 必为 no-op (text-only). compactMsgs := queryMessagesToCompactMessages(messages) rawTokens := agentctx.EstimateTokens(compactMsgs) if rawTokens <= threshold { t.Fatalf("fixture too small: EstimateTokens=%d, want > %d", rawTokens, threshold) } microTokens := agentctx.EstimateTokens(agentctx.MicroCompact(compactMsgs)) if microTokens <= threshold { t.Fatalf("MicroCompact unexpectedly reduced text-only fixture below threshold: "+ "before=%d after=%d threshold=%d (micro path will short-circuit, full path won't run)", rawTokens, microTokens, threshold) } // Build compressor with low threshold + small context window override // so tier-1 singleCompact path is taken (no truncate/chunked). // // 低阈值 + 小窗口让走第 1 层 singleCompact (不进砍头/分块). provider := &scriptedSummaryProvider{summary: "FAKE_SUMMARY: round 1-3 conversation about hello world"} compressor := agentctx.NewCompressor(threshold, provider) compressor.SetContextWindowFn(func(_ string) int { return 32_000 }) compressor.SetCompactModelFn(func() string { return "fake-fast-model" }) // Minimum Engine fields maybeCompact reads: // - cfg: untouched in maybeCompact body, but checked nowhere; set // to a safe testConfig() value anyway. // - observer: NoopObserver to absorb Event/Error/Metric calls. // - sectionRegistry: full success path calls .Reset() at end. // // maybeCompact 读到的最少 Engine 字段: cfg / observer (吸 Event/Error/Metric) / // sectionRegistry (完整成功路径末尾调 Reset). eng := &Engine{ cfg: testConfig(), observer: &NoopObserver{}, sectionRegistry: agentctx.NewSectionRegistry(), } // Buffered channel so maybeCompact's `ch <- &CompactEvent{...}` in // the success path doesn't deadlock without a reader goroutine. // Capacity 4 covers the single full event plus any future micro // emissions that might co-fire (defensive). // // Buffered ch 让 maybeCompact 成功路径的 send 不需要并发消费方. // cap=4 留余量给未来可能并行 emit 的 micro 事件. ch := make(chan Event, 4) resultMessages := eng.maybeCompact(messages, compressor, ch) close(ch) // Drain channel and find the CompactEvent. // 排 ch 找 CompactEvent. var compactEvents []*flyto.CompactEvent for ev := range ch { if ce, ok := ev.(*flyto.CompactEvent); ok { compactEvents = append(compactEvents, ce) } } if len(compactEvents) != 1 { t.Fatalf("expected exactly 1 *flyto.CompactEvent, got %d", len(compactEvents)) } ev := compactEvents[0] t.Logf("CompactEvent: Kind=%q SummaryLen=%d TokensBefore=%d TokensAfter=%d", ev.Kind, len(ev.Summary), ev.TokensBefore, ev.TokensAfter) if ev.Kind != "full" { t.Errorf("Kind = %q, want %q", ev.Kind, "full") } if ev.Summary == "" { t.Errorf("Summary is empty, want non-empty (provider sent %q)", provider.summary) } if !strings.Contains(ev.Summary, "FAKE_SUMMARY") { // Not strict: compressor may add a "[Previous conversation summary]" // prefix. We only require the core marker to survive. // 不严格: compressor 可能加前缀, 只要核心 marker 保留即可. t.Errorf("Summary = %q, want to contain %q", ev.Summary, "FAKE_SUMMARY") } if ev.TokensBefore <= ev.TokensAfter { t.Errorf("TokensBefore (%d) must be > TokensAfter (%d) for full compact", ev.TokensBefore, ev.TokensAfter) } if ev.TokensBefore <= threshold { t.Errorf("TokensBefore (%d) must be > threshold (%d) — fixture didn't actually trip ShouldCompact", ev.TokensBefore, threshold) } // Belt-and-braces: maybeCompact's full path returns the summary // message + recent kept messages. The first returned message // should embed the FAKE_SUMMARY marker (compactFull prepends // "[Previous conversation summary]\n" + summary as a user role). // // 双保险: maybeCompact 完整路径返回 summary message + recent kept. // 首条应嵌 FAKE_SUMMARY marker (compactFull 前置 // "[Previous conversation summary]\n" + summary 当 user role). if len(resultMessages) == 0 { t.Fatalf("resultMessages empty after full compact") } first := resultMessages[0] var firstText string for _, c := range first.Content { if c.Type == query.ContentText { firstText += c.Text } } // Some paths may marshal/unmarshal through json.RawMessage, so the // raw bytes can also be the JSON-encoded summary string. Try both. // 部分路径可能经 json.RawMessage 往返, raw bytes 可能是 JSON 编码 // 后的 summary 字符串. 两种都试. if !strings.Contains(firstText, "FAKE_SUMMARY") { // Try parsing first.Content[0].Text as JSON in case of escaping. // 试着把 first.Content[0].Text 当 JSON 解, 看是否被转义包了一层. var unescaped string if err := json.Unmarshal([]byte(firstText), &unescaped); err == nil { firstText = unescaped } } if !strings.Contains(firstText, "FAKE_SUMMARY") { t.Errorf("first compacted message text = %q, want to contain FAKE_SUMMARY", firstText) } }