package main import ( "context" "os" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件锁定 P0-11:上下文溢出压缩在生产里**真的启用**,且阈值来自 // CONTEXT_MAX_TOKENS。断言落在「模型实际看到的请求消息」上: // 预算小时长对话被压成「一条前导摘要 + 近期尾部」,摘要消息确实进了 prompt; // 预算为 0 时行为与接线前逐条一致(一条消息都不少)。 // loopCompactSummaryMarker 是 loop 压缩摘要消息的开头(internal/runtime/loop/ // compactor.go 的 summaryMarker)。测试按这个可观测契约判定「摘要进了 prompt」。 const loopCompactSummaryMarker = "[前文摘要 · history summary]" // compactProbeAdapter 记下每次流式请求,使测试能读回模型实际看到的上下文。 type compactProbeAdapter struct{ reqs []model.Request } func (a *compactProbeAdapter) Name() string { return "compact-probe" } func (a *compactProbeAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) { return model.Response{Content: "好"}, nil } func (a *compactProbeAdapter) ChatStream(_ context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) { a.reqs = append(a.reqs, req) if on != nil { on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "好"}) } return model.Response{Content: "好"}, nil } // longConversation 造一段「比小预算长得多、单看每条都很正常」的对话: // 12 轮问答、每条约 220 个汉字(离线估算约 114 token/条,共约 2700 token)。 func longConversation() []model.Message { nums := []string{"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"} filler := strings.Repeat("今天也要好好吃饭早点休息哦", 17) msgs := make([]model.Message, 0, 2*len(nums)) for _, n := range nums { msgs = append(msgs, model.TextMessage(model.RoleUser, "第"+n+"件事:"+filler), model.TextMessage(model.RoleAssistant, "好呀,人家记下了♪"+filler), ) } return msgs } // runWithBudget 用给定预算驱动一次真实循环(装配形状与组合根一致: // MaxContextTokens 取自 a.contextBudget(),Compactor 不注入 —— 由 loop 选用 // 内置的结构化 compactor),返回模型实际看到的请求。 func runWithBudget(t *testing.T, budget int, msgs []model.Message) model.Request { t.Helper() adapter := &compactProbeAdapter{} ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil)) if err != nil { t.Fatalf("agent.New: %v", err) } l := loop.New(loop.Options{ Agent: ag, MaxContextTokens: budget, OnStream: func(model.StreamEvent) {}, }) env := loop.SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true, PersonaID: "cyrene"} ctx := loop.WithSessionEnv(context.Background(), env) if _, err := l.Run(ctx, "web", msgs); err != nil { t.Fatalf("Run: %v", err) } if len(adapter.reqs) == 0 { t.Fatal("没有捕获到任何模型请求") } return adapter.reqs[len(adapter.reqs)-1] } // summaryMessages 返回请求里所有压缩摘要消息的下标。 func summaryMessages(req model.Request) []int { var idx []int for i, m := range req.Messages { if strings.HasPrefix(loopText(m), loopCompactSummaryMarker) { idx = append(idx, i) } } return idx } // loopText 取一条消息的文本部件拼接(与 loop 的 messageText 同义)。 func loopText(m model.Message) string { var b strings.Builder for _, c := range m.Content { if c.Type == model.ContentTypeText { b.WriteString(c.Text) } } return b.String() } // TestContextBudget_ComesFromConfigAndDefaultsToTheLoopDefault 锁定取值链: // 配置层默认 == loop.DefaultMaxContextTokens(两个常量不许漂移), // 组合根读的就是配置值(含「0 = 关闭」这条语义)。 func TestContextBudget_ComesFromConfigAndDefaultsToTheLoopDefault(t *testing.T) { // 环境里若已有 CONTEXT_MAX_TOKENS,配置层会优先用它,默认值断言就不成立; // 用 t.Setenv 钉空串也不行(空串会被解析成 0),因此这里显式清掉再还原。 if prev, had := os.LookupEnv("CONTEXT_MAX_TOKENS"); had { _ = os.Unsetenv("CONTEXT_MAX_TOKENS") t.Cleanup(func() { _ = os.Setenv("CONTEXT_MAX_TOKENS", prev) }) } t.Setenv("JWT_SECRET", strings.Repeat("k", 40)) cfg, err := config.Load(filepath.Join(t.TempDir(), "absent.env")) if err != nil { t.Fatalf("config.Load: %v", err) } if cfg.Context.MaxTokens != config.DefaultContextMaxTokens { t.Fatalf("CONTEXT_MAX_TOKENS 默认值 = %d,want %d", cfg.Context.MaxTokens, config.DefaultContextMaxTokens) } if config.DefaultContextMaxTokens != loop.DefaultMaxContextTokens { t.Fatalf("默认值与 loop 的常量漂移:%d vs %d", config.DefaultContextMaxTokens, loop.DefaultMaxContextTokens) } if got := (&app{cfg: cfg}).contextBudget(); got != loop.DefaultMaxContextTokens { t.Fatalf("contextBudget() = %d,want %d", got, loop.DefaultMaxContextTokens) } // 显式取值原样透传;0 = 关闭。 cfg.Context.MaxTokens = 4096 if got := (&app{cfg: cfg}).contextBudget(); got != 4096 { t.Fatalf("contextBudget() = %d,want 4096", got) } cfg.Context.MaxTokens = 0 if got := (&app{cfg: cfg}).contextBudget(); got != 0 { t.Fatalf("contextBudget() = %d,want 0(关闭)", got) } } // TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt 是真断言①: // 预算为正且对话超出预算时,会话**真的被压缩**,且摘要消息进了模型请求。 func TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt(t *testing.T) { t.Parallel() a := &app{log: testLog(), cfg: &config.Config{}} a.cfg.Context.MaxTokens = 600 // 远小于 longConversation 的体量 msgs := longConversation() req := runWithBudget(t, a.contextBudget(), msgs) summaries := summaryMessages(req) if len(summaries) != 1 { t.Fatalf("请求里的摘要消息数 = %d(共 %d 条消息),want 1", len(summaries), len(req.Messages)) } if summaries[0] != 1 { t.Fatalf("摘要消息的位置 = %d,want 1(紧跟指令消息的近期窗口之前)", summaries[0]) } if len(req.Messages) >= len(msgs)+1 { t.Fatalf("请求消息数 = %d(喂入 %d 条),压缩没有生效", len(req.Messages), len(msgs)) } // 近期尾部保留(压缩不是把历史全丢掉):最后一条用户消息仍在。 all := "" for _, m := range req.Messages { all += loopText(m) } if !strings.Contains(all, "第十二件事") { t.Fatalf("最近一轮已被压掉:请求里找不到最后一轮用户消息") } } // TestContextCompaction_ZeroBudgetKeepsTodaysBehavior 是真断言②:预算为 0 // (关闭)时,行为与接线前完全一致 —— 喂进去的消息一条不少地进了请求, // 且没有任何摘要消息。 func TestContextCompaction_ZeroBudgetKeepsTodaysBehavior(t *testing.T) { t.Parallel() a := &app{log: testLog(), cfg: &config.Config{}} // Context.MaxTokens 零值 = 关闭 msgs := longConversation() req := runWithBudget(t, a.contextBudget(), msgs) if len(req.Messages) != len(msgs)+1 { t.Fatalf("请求消息数 = %d,want %d(指令 + 全部喂入消息)", len(req.Messages), len(msgs)+1) } if summaries := summaryMessages(req); len(summaries) != 0 { t.Fatalf("关闭压缩却出现了摘要消息:%v", summaries) } } // TestWireRuntime_UsesTheCompositionRootSeams 是一道「接线存在性」防回归检查。 // // 组合根本身(wireRuntime → newApp)无法在单元测试里实例化:它要开库、迁移、 // 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此 P0-5 / P0-11 的 // 「组合根确实走了这两条缝」只能在源码层钉住:wire_runtime.go 必须调用 // a.buildGuardrailPipeline(),并且把 MaxContextTokens 取自 a.contextBudget()。 // 有人把这两行改回空管线 / 不设预算时,这里立刻红。 func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) { t.Parallel() src, err := os.ReadFile("wire_runtime.go") if err != nil { t.Fatalf("read wire_runtime.go: %v", err) } for _, want := range []string{"a.buildGuardrailPipeline()", "a.contextBudget()"} { if !strings.Contains(string(src), want) { t.Fatalf("wire_runtime.go 里找不到 %q —— 组合根没有走这条缝", want) } } } // TestContextCompaction_HugeBudgetIsAlsoInert 是变异反证:同一段对话只把预算 // 放大到不可能超出的值,压缩同样不发生 —— 证明上一条的压缩确实由「预算」驱动, // 而不是别的什么副作用。 func TestContextCompaction_HugeBudgetIsAlsoInert(t *testing.T) { t.Parallel() msgs := longConversation() req := runWithBudget(t, 1<<30, msgs) if len(req.Messages) != len(msgs)+1 { t.Fatalf("巨大预算下请求消息数 = %d,want %d", len(req.Messages), len(msgs)+1) } if summaries := summaryMessages(req); len(summaries) != 0 { t.Fatalf("巨大预算下不该出现摘要消息:%v", summaries) } }