- 真 bug:文档注入预算(1M 窗口 ⇒ 349525 字符)> 输入护栏硬编码上限(262144)⇒ 带 744KB 附件的 POST /chat 直接 input_length_exceeded/guardrail_input,整个回合作废(主会话活服务实测)。 - 修法:guardrailMaxInputChars → guardrailMinInputChars(256Ki 下限) + 纯函数 deriveGuardrailInputLimit(log, override, budgetTokens, docInjectChars):派生值 = max(下限, 推导预算 tokens)(1M ⇒ 699050);新增 env GUARDRAIL_MAX_INPUT_CHARS(未设/0=派生、>0=覆盖、<0=WARN 回落);预算只在 wire_context 推导一次并经 buildGuardrailPipeline(ctxBudget) 传入(避免「窗口未声明」WARN 记两遍);输出上限 65536 刻意不参与派生(有显式回归钉住)。 - 关系不变量:文档注入预算 ≤ 输入上限,人为破坏 ⇒ 一条 WARN(不静默)。 - 第 5 条 flake(exec/TestTerminal_Timeout_Killed):仅改测试、terminal.go 保持 pristine;根因不是「杀进程失效」而是旧断言的 ~200ms 精度把宿主负载下的进程创建耗时算了进去(重压下 cmd.Start 会直接返回 deadline exceeded,红行却报 process not killed)⇒ 改为「控制组证明同命令不设短超时正常退出」的确定性证明 + 把墙钟断言降级为 10s 语义判别界(子进程自然存活期拉到 30s)。 - 主会话独立验收:隔离 worktree(G2 的 10 个文件,排除 M1 在途的 wire_channels.go)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、Guardrail* 全 PASS、exec -count=20 绿;变异独立复现(把派生值退回硬编码 ⇒ 4 条红,含 budget=699050 → 262144)。 - 残留缺口(已排 G2b):护栏量的是整段装配请求、注入预算是单份 ⇒ ≥2 份各撞注入预算的大附件仍可能被拦。
427 lines
17 KiB
Go
427 lines
17 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"log/slog"
|
||
"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"
|
||
)
|
||
|
||
// 本文件锁定上下文预算的取值链(design/04 §1.3 + 附录 A.3):
|
||
//
|
||
// - **窗口声明**(models.json 组的 contextWindow / LLM_CONTEXT_WINDOW)与推导
|
||
// budget = min(窗口 × 比例, 窗口 − 输出保留);
|
||
// - CONTEXT_MAX_TOKENS 的三态(未设置 ⇒ 推导 / >0 ⇒ 覆盖 / 0 或负 ⇒ 关闭);
|
||
// - 窗口未声明时**恰好一条** WARN;
|
||
// - 单份附件的注入预算随推导预算走,且真的让「清单游标 → document-read」这条
|
||
// 续读链路跑通(S4 实测的断裂,切片 T1 §7.2)。
|
||
|
||
// 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 取自推导结论,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()
|
||
}
|
||
|
||
// capturingLog 返回输出 JSON 记录的 logger 与缓冲:这里要按**条数**断言 WARN
|
||
// (「窗口未声明恰好一条」),文本子串数不出来。
|
||
func capturingLog() (*slog.Logger, *bytes.Buffer) {
|
||
buf := &bytes.Buffer{}
|
||
return slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
|
||
}
|
||
|
||
// logRecords 把缓冲里的 JSON 记录逐条解出来。
|
||
func logRecords(t *testing.T, buf *bytes.Buffer) []map[string]any {
|
||
t.Helper()
|
||
out := []map[string]any{}
|
||
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
|
||
if strings.TrimSpace(line) == "" {
|
||
continue
|
||
}
|
||
rec := map[string]any{}
|
||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||
t.Fatalf("日志行不是合法 JSON: %v(%q)", err, line)
|
||
}
|
||
out = append(out, rec)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// countRecords 数出消息里包含 substr 的记录条数(只看 msg 与 level)。
|
||
func countRecords(records []map[string]any, level, substr string) int {
|
||
n := 0
|
||
for _, rec := range records {
|
||
msg, _ := rec["msg"].(string)
|
||
if level != "" && rec["level"] != level {
|
||
continue
|
||
}
|
||
if strings.Contains(msg, substr) {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// envWindowStack 造一个「生效组声明了窗口」的模型栈:models.json 不存在时
|
||
// LoadModels 会回落到 .env 合成的组,而 LLM_CONTEXT_WINDOW 正是该组的窗口来源。
|
||
func envWindowStack(t *testing.T, window, maxTokens int) *model.RuntimeLLM {
|
||
t.Helper()
|
||
return model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{
|
||
Provider: "openai",
|
||
BaseURL: "https://api.example/v1",
|
||
Model: "test-model",
|
||
ContextWindow: window,
|
||
}, testLog())
|
||
}
|
||
|
||
// modelsWindowStack 造一个「生效组是 models.json 里那一组」的模型栈:组级
|
||
// contextWindow / maxTokens 只在 models.json 里有出处,因此这里真写一份文件。
|
||
func modelsWindowStack(t *testing.T, window, maxTokens int) *model.RuntimeLLM {
|
||
t.Helper()
|
||
path := filepath.Join(t.TempDir(), "models.json")
|
||
raw, err := json.Marshal([]map[string]any{{
|
||
"id": "deepseek", "provider": "openai", "baseUrl": "https://api.example/v1",
|
||
"apiKey": "sk-test", "model": "test-model",
|
||
"contextWindow": window, "maxTokens": maxTokens,
|
||
}})
|
||
if err != nil {
|
||
t.Fatalf("marshal models.json: %v", err)
|
||
}
|
||
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||
t.Fatalf("write models.json: %v", err)
|
||
}
|
||
return model.NewRuntimeLLM(path, config.LLMConfig{}, testLog())
|
||
}
|
||
|
||
// TestContextBudget_DerivedFromTheDeclaredWindow 钉住推导链的接线:窗口取自
|
||
// **生效模型组**,预算按附录 A.3 的公式算,比例来自 CONTEXT_BUDGET_RATIO。
|
||
func TestContextBudget_DerivedFromTheDeclaredWindow(t *testing.T) {
|
||
t.Parallel()
|
||
envPath := filepath.Join(t.TempDir(), "absent.env")
|
||
log, buf := capturingLog()
|
||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||
|
||
got := a.contextBudget(envPath)
|
||
if got.Budget.Tokens != 699050 || got.Budget.Mode != config.ContextBudgetDerived {
|
||
t.Fatalf("1M 窗口的预算 = %+v, want 699050/derived", got.Budget)
|
||
}
|
||
// 单份附件注入预算随推导预算走:1M 窗口下远大于旧的 64K 卡死值。
|
||
if got.DocumentInjectChars <= 64*1024 {
|
||
t.Fatalf("1M 窗口下的注入预算 = %d, want > 64K", got.DocumentInjectChars)
|
||
}
|
||
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
|
||
t.Fatalf("窗口已声明却记了 %d 条「未声明」WARN", n)
|
||
}
|
||
|
||
// 组级 maxTokens=384K(思考模式上限)⇒ 输出保留吃满 ⇒ min(699050, 1M−384K)。
|
||
a.llm = modelsWindowStack(t, 1048576, 384*1024)
|
||
got = a.contextBudget(envPath)
|
||
if got.Budget.Tokens != 655360 || got.Budget.OutputReserve != 384*1024 {
|
||
t.Fatalf("带输出保留的预算 = %+v, want 655360 / reserve 393216", got.Budget)
|
||
}
|
||
}
|
||
|
||
// TestContextBudget_UndeclaredWindowWarnsExactlyOnce 钉住回落与「恰好一条 WARN」:
|
||
// 没声明窗口的部署照常工作,但必须被告知「要吃到 1M 得声明窗口」,且只被告知一次。
|
||
func TestContextBudget_UndeclaredWindowWarnsExactlyOnce(t *testing.T) {
|
||
t.Parallel()
|
||
envPath := filepath.Join(t.TempDir(), "absent.env")
|
||
log, buf := capturingLog()
|
||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 0, 0)}
|
||
|
||
got := a.contextBudget(envPath)
|
||
if got.Budget.Window != config.DefaultModelContextWindow || got.Budget.WindowDeclared {
|
||
t.Fatalf("未声明窗口 = %+v, want 回落 %d 且 WindowDeclared=false",
|
||
got.Budget, config.DefaultModelContextWindow)
|
||
}
|
||
if got.Budget.Tokens != 87381 {
|
||
t.Fatalf("回落窗口下的预算 = %d, want 87381(131072 × 2/3 截尾)", got.Budget.Tokens)
|
||
}
|
||
records := logRecords(t, buf)
|
||
if n := countRecords(records, "WARN", "not declared"); n != 1 {
|
||
t.Fatalf("「窗口未声明」WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
|
||
}
|
||
// WARN 必须点出两个声明入口,否则主人不知道该改哪儿。
|
||
warn := ""
|
||
for _, rec := range records {
|
||
if msg, _ := rec["msg"].(string); strings.Contains(msg, "not declared") {
|
||
warn = msg
|
||
if rec["env"] != "LLM_CONTEXT_WINDOW" || rec["json_key"] != "contextWindow" {
|
||
t.Fatalf("WARN 没点出声明入口: %v", rec)
|
||
}
|
||
}
|
||
}
|
||
if warn == "" {
|
||
t.Fatal("没有找到窗口未声明的 WARN")
|
||
}
|
||
}
|
||
|
||
// TestContextBudget_OverrideAndOff 钉住 CONTEXT_MAX_TOKENS 的三态:>0 覆盖、
|
||
// 显式 0 关闭、负值关闭 —— 且关闭时**不推导**(窗口未声明也不该冒出 WARN)。
|
||
func TestContextBudget_OverrideAndOff(t *testing.T) {
|
||
t.Parallel()
|
||
envPath := filepath.Join(t.TempDir(), "absent.env")
|
||
|
||
log, buf := capturingLog()
|
||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||
a.cfg.Context.MaxTokens = 600
|
||
if got := a.contextBudget(envPath); got.Budget.Tokens != 600 || got.Budget.Mode != config.ContextBudgetOverride {
|
||
t.Fatalf("显式覆盖 = %+v, want 600/override", got.Budget)
|
||
}
|
||
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
|
||
t.Fatalf("显式覆盖与窗口无关,却记了 %d 条「未声明」WARN", n)
|
||
}
|
||
|
||
for _, override := range []int{0, -1} {
|
||
a.cfg.Context.MaxTokens = override
|
||
a.cfg.Context.MaxTokensSet = true // 显式 0 = 关闭(0 与「未设置」必须分开)
|
||
got := a.contextBudget(envPath)
|
||
if got.Budget.Tokens != override || got.Budget.Mode != config.ContextBudgetOff {
|
||
t.Fatalf("override=%d ⇒ %+v, want off", override, got.Budget)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestContextBudget_InvalidRatioWarnsAndFallsBack 钉住非法比例的处理:记一条带
|
||
// 原始取值的 WARN 并回落 2/3,绝不静默生效(AGENTS §10)。
|
||
func TestContextBudget_InvalidRatioWarnsAndFallsBack(t *testing.T) {
|
||
dir := t.TempDir()
|
||
envPath := filepath.Join(dir, "test.env")
|
||
if err := os.WriteFile(envPath, []byte("CONTEXT_BUDGET_RATIO=四分之三\n"), 0o600); err != nil {
|
||
t.Fatalf("写 .env: %v", err)
|
||
}
|
||
log, buf := capturingLog()
|
||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||
|
||
got := a.contextBudget(envPath)
|
||
if got.Budget.Ratio != config.DefaultContextBudgetRatio || got.Budget.Tokens != 699050 {
|
||
t.Fatalf("非法比例下的预算 = %+v, want 回落 2/3 ⇒ 699050", got.Budget)
|
||
}
|
||
records := logRecords(t, buf)
|
||
if n := countRecords(records, "WARN", "ratio invalid"); n != 1 {
|
||
t.Fatalf("非法比例的 WARN 条数 = %d, want 1(记录:%v)", n, records)
|
||
}
|
||
|
||
// 合法比例真的生效:1/2 × 1M = 524288。
|
||
if err := os.WriteFile(envPath, []byte("CONTEXT_BUDGET_RATIO=1/2\n"), 0o600); err != nil {
|
||
t.Fatalf("写 .env: %v", err)
|
||
}
|
||
if got := a.contextBudget(envPath); got.Budget.Tokens != 524288 {
|
||
t.Fatalf("1/2 比例下的预算 = %d, want 524288", got.Budget.Tokens)
|
||
}
|
||
}
|
||
|
||
// TestContextBudget_DocumentInjectCharsOverride 钉住注入预算的显式覆盖优先:
|
||
// DOCUMENT_MAX_INJECT_CHARS 是「至少可配」的落点(任务书 §2 第 7 条)。
|
||
func TestContextBudget_DocumentInjectCharsOverride(t *testing.T) {
|
||
t.Parallel()
|
||
a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||
a.cfg.Context.DocumentMaxInjectChars = 4096
|
||
got := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
|
||
if got.DocumentInjectChars != 4096 {
|
||
t.Fatalf("显式注入预算 = %d, want 4096", got.DocumentInjectChars)
|
||
}
|
||
if got.Budget.Tokens != 699050 {
|
||
t.Fatalf("注入预算的覆盖不该影响压缩预算:%d", got.Budget.Tokens)
|
||
}
|
||
}
|
||
|
||
// TestContextBudget_DefaultsToNoiseFreeLoopDefault 锁定取值链的兜底:显式覆盖与
|
||
// 推导都不适用时(这里用显式覆盖举最小的例子),组合根读的就是配置值本身。
|
||
func TestContextBudget_DefaultsToNoiseFreeLoopDefault(t *testing.T) {
|
||
t.Parallel()
|
||
log, buf := capturingLog()
|
||
a := &app{log: log, cfg: &config.Config{}}
|
||
a.cfg.Context.MaxTokens = config.MinContextOutputReserve
|
||
a.cfg.Context.MaxTokensSet = true
|
||
|
||
if got := a.contextBudget(filepath.Join(t.TempDir(), "absent.env")); got.Budget.Tokens != config.MinContextOutputReserve {
|
||
t.Fatalf("contextBudget = %+v, want %d", got.Budget, config.MinContextOutputReserve)
|
||
}
|
||
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
|
||
t.Fatalf("覆盖态下不该有窗口 WARN(记了 %d 条)", n)
|
||
}
|
||
}
|
||
|
||
// 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(filepath.Join(t.TempDir(), "absent.env")).Budget.Tokens, 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{}}
|
||
a.cfg.Context.MaxTokens = 0
|
||
a.cfg.Context.MaxTokensSet = true // 显式 0 = 关闭(未设置会走推导)
|
||
msgs := longConversation()
|
||
|
||
req := runWithBudget(t, a.contextBudget(filepath.Join(t.TempDir(), "absent.env")).Budget.Tokens, 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)无法在单元测试里实例化:它要开库、迁移、
|
||
// 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此「组合根确实走了这些缝」
|
||
// 只能在源码层钉住:wire_runtime.go 必须把预算取自 a.contextBudget(envPath),
|
||
// 在这份预算上建护栏管线(buildGuardrailPipeline(ctxBudget)),并把两项预算都交给 loop。
|
||
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(ctxBudget)",
|
||
"a.contextBudget(envPath)",
|
||
"MaxContextTokens: ctxBudget.Budget.Tokens",
|
||
"DocumentMaxInjectChars: ctxBudget.DocumentInjectChars",
|
||
} {
|
||
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)
|
||
}
|
||
}
|