package context import ( "context" "strings" "testing" ) // --------------------------------------------------------------------------- // SectionRegistry 测试 // --------------------------------------------------------------------------- func TestSectionRegistry_CachesResult(t *testing.T) { reg := NewSectionRegistry() callCount := 0 s := DynamicSection("counter", func(_ context.Context) string { callCount++ return "computed" }) ctx := context.Background() v1 := reg.Compute(ctx, s) v2 := reg.Compute(ctx, s) if v1 != "computed" || v2 != "computed" { t.Errorf("expected 'computed', got %q / %q", v1, v2) } // 精妙之处(CLEVER): 缓存命中时,Compute 函数只应被调用一次-- // 第二次读取应直接从 cache map 返回,零额外调用. if callCount != 1 { t.Errorf("expected Compute called once (cache hit), got %d times", callCount) } } func TestSectionRegistry_Reset_ClearsCache(t *testing.T) { reg := NewSectionRegistry() callCount := 0 s := DynamicSection("c", func(_ context.Context) string { callCount++ return "v" }) ctx := context.Background() reg.Compute(ctx, s) reg.Reset() reg.Compute(ctx, s) if callCount != 2 { t.Errorf("after Reset, Compute should be called again; got %d calls", callCount) } } func TestSectionRegistry_Invalidate_SpecificSection(t *testing.T) { reg := NewSectionRegistry() callCount := 0 s := DynamicSection("specific", func(_ context.Context) string { callCount++ return "v" }) other := DynamicSection("other", func(_ context.Context) string { return "o" }) ctx := context.Background() // 先计算两个 section reg.Compute(ctx, s) reg.Compute(ctx, other) // 只使特定 section 失效 reg.Invalidate("specific") reg.Compute(ctx, s) if callCount != 2 { t.Errorf("Invalidate should cause recompute for 'specific'; got %d calls", callCount) } // other 仍然命中缓存(没有被 invalidate) otherCalls := 0 s2 := DynamicSection("other", func(_ context.Context) string { otherCalls++ return "o" }) reg.Compute(ctx, s2) // 应命中缓存 if otherCalls != 0 { t.Errorf("'other' should still be cached after Invalidate('specific'), got %d calls", otherCalls) } } func TestSectionRegistry_CacheBreak_NeverCached(t *testing.T) { reg := NewSectionRegistry() callCount := 0 s := VolatileSection("v", func(_ context.Context) string { callCount++ return "volatile" }, "test reason") ctx := context.Background() reg.Compute(ctx, s) reg.Compute(ctx, s) reg.Compute(ctx, s) // 精妙之处(CLEVER): volatile section 每次调用都触发 Compute-- // 三次调用应有三次 callCount,不经过 cache map. if callCount != 3 { t.Errorf("volatile section should compute every time; got %d calls", callCount) } } func TestSectionRegistry_StaticSection_CachesText(t *testing.T) { reg := NewSectionRegistry() s := StaticSection("static_s", "static text") ctx := context.Background() v1 := reg.Compute(ctx, s) v2 := reg.Compute(ctx, s) if v1 != "static text" || v2 != "static text" { t.Errorf("static section should return Text; got %q", v1) } } func TestSectionRegistry_EmptyResult_NotCached(t *testing.T) { // 空字符串结果应被缓存(cacheBreak=false),避免每次重新计算 reg := NewSectionRegistry() callCount := 0 s := DynamicSection("empty", func(_ context.Context) string { callCount++ return "" // empty - section disabled }) ctx := context.Background() reg.Compute(ctx, s) reg.Compute(ctx, s) if callCount != 1 { t.Errorf("empty result should still be cached; got %d calls", callCount) } } // --------------------------------------------------------------------------- // BundleRegistry 测试 // --------------------------------------------------------------------------- func TestBundleRegistry_Register_Resolve(t *testing.T) { reg := NewBundleRegistry() bundle := &testBundle{name: "custom"} key := BundleKey{ModelFamily: "gpt", Scenario: "warehouse"} reg.Register(key, bundle) resolved := reg.Resolve(key) if resolved == nil { t.Fatal("expected resolved bundle, got nil") } if resolved.(*testBundle).name != "custom" { t.Errorf("expected 'custom', got %q", resolved.(*testBundle).name) } } func TestBundleRegistry_Resolve_FallsBackToDefault(t *testing.T) { reg := NewBundleRegistry() defaultBundle := &testBundle{name: "default"} reg.Register(DefaultBundleKey, defaultBundle) // 查找未注册的 key,应回退到 default resolved := reg.Resolve(BundleKey{ModelFamily: "gpt", Scenario: "warehouse"}) if resolved == nil { t.Fatal("expected fallback to default bundle, got nil") } if resolved.(*testBundle).name != "default" { t.Errorf("expected 'default' fallback, got %q", resolved.(*testBundle).name) } } func TestBundleRegistry_Resolve_NilWhenEmpty(t *testing.T) { reg := NewBundleRegistry() // 没有注册任何 bundle,连默认 key 也没有 resolved := reg.Resolve(DefaultBundleKey) if resolved != nil { t.Error("empty registry should return nil") } } func TestBundleRegistry_SetDefault_ChangesDefaultKey(t *testing.T) { reg := NewBundleRegistry() customDefault := &testBundle{name: "warehouse-default"} warehouseKey := BundleKey{ModelFamily: "claude", Scenario: "warehouse"} reg.Register(warehouseKey, customDefault) reg.SetDefault(warehouseKey) // 未注册的 key 回退到新 default resolved := reg.Resolve(BundleKey{ModelFamily: "gpt", Scenario: "unknown"}) if resolved == nil { t.Fatal("expected fallback to warehouse default, got nil") } if resolved.(*testBundle).name != "warehouse-default" { t.Errorf("expected 'warehouse-default', got %q", resolved.(*testBundle).name) } } func TestBundleRegistry_Overwrite_ExistingKey(t *testing.T) { reg := NewBundleRegistry() key := DefaultBundleKey reg.Register(key, &testBundle{name: "v1"}) reg.Register(key, &testBundle{name: "v2"}) // 覆盖 resolved := reg.Resolve(key) if resolved.(*testBundle).name != "v2" { t.Errorf("expected 'v2' (overwrite), got %q", resolved.(*testBundle).name) } } // --------------------------------------------------------------------------- // BuildPromptBlocks 测试 // --------------------------------------------------------------------------- func TestBuildPromptBlocks_NilBundle_ReturnsNil(t *testing.T) { reg := NewSectionRegistry() blocks := BuildPromptBlocks(context.Background(), nil, reg, true) if blocks != nil { t.Errorf("expected nil for nil bundle, got %v", blocks) } } func TestBuildPromptBlocks_NoCaching_SingleBlock(t *testing.T) { bundle := &testBundle{ staticSecs: []*Section{StaticSection("s1", "static content")}, dynamicSecs: []*Section{DynamicSection("d1", func(_ context.Context) string { return "dynamic content" })}, } reg := NewSectionRegistry() blocks := BuildPromptBlocks(context.Background(), bundle, reg, false) if len(blocks) != 1 { t.Fatalf("enableCaching=false: expected 1 block, got %d", len(blocks)) } if blocks[0].CacheScope != "" { t.Errorf("no-cache block should have empty CacheScope, got %q", blocks[0].CacheScope) } if !strings.Contains(blocks[0].Text, "static content") { t.Error("single block should contain static content") } if !strings.Contains(blocks[0].Text, "dynamic content") { t.Error("single block should contain dynamic content") } } func TestBuildPromptBlocks_WithCaching_TwoBlocks(t *testing.T) { bundle := &testBundle{ staticSecs: []*Section{StaticSection("s1", "static")}, dynamicSecs: []*Section{DynamicSection("d1", func(_ context.Context) string { return "dynamic" })}, } reg := NewSectionRegistry() blocks := BuildPromptBlocks(context.Background(), bundle, reg, true) // 静态块 + 动态块 = 2 blocks if len(blocks) != 2 { t.Fatalf("enableCaching=true: expected 2 blocks, got %d", len(blocks)) } if blocks[0].CacheScope != "ephemeral" { t.Errorf("static block CacheScope: want 'ephemeral', got %q", blocks[0].CacheScope) } if blocks[1].CacheScope != "ephemeral" { t.Errorf("dynamic block CacheScope: want 'ephemeral', got %q", blocks[1].CacheScope) } } func TestBuildPromptBlocks_VolatileSection_NoCacheBlock(t *testing.T) { bundle := &testBundle{ staticSecs: []*Section{StaticSection("s", "static")}, dynamicSecs: []*Section{ DynamicSection("d", func(_ context.Context) string { return "cached-dynamic" }), VolatileSection("v", func(_ context.Context) string { return "volatile" }, "test"), }, } reg := NewSectionRegistry() blocks := BuildPromptBlocks(context.Background(), bundle, reg, true) // static(ephemeral) + dynamic(ephemeral) + volatile("") = 3 blocks if len(blocks) != 3 { t.Fatalf("expected 3 blocks (static+dynamic+volatile), got %d", len(blocks)) } if blocks[2].CacheScope != "" { t.Errorf("volatile block should have no cache scope, got %q", blocks[2].CacheScope) } if blocks[2].Text != "volatile" { t.Errorf("volatile block text: got %q", blocks[2].Text) } } func TestBuildPromptBlocks_EmptySectionsSkipped(t *testing.T) { bundle := &testBundle{ staticSecs: []*Section{ StaticSection("s1", "has content"), StaticSection("s2", ""), // empty - skip }, dynamicSecs: []*Section{ DynamicSection("d1", func(_ context.Context) string { return "" }), // empty - skip }, } reg := NewSectionRegistry() blocks := BuildPromptBlocks(context.Background(), bundle, reg, true) // 只有一个非空静态块,动态块为空被跳过 if len(blocks) != 1 { t.Fatalf("expected 1 block (empty sections skipped), got %d", len(blocks)) } if blocks[0].Text != "has content" { t.Errorf("expected only 'has content', got %q", blocks[0].Text) } } func TestBuildPromptBlocks_MultipleStaticSectionsMerged(t *testing.T) { bundle := &testBundle{ staticSecs: []*Section{ StaticSection("s1", "part1"), StaticSection("s2", "part2"), StaticSection("s3", "part3"), }, } reg := NewSectionRegistry() blocks := BuildPromptBlocks(context.Background(), bundle, reg, true) // 3 静态 sections 应合并为 1 块,节约 cache breakpoint 预算 if len(blocks) != 1 { t.Fatalf("multiple static sections should merge to 1 block, got %d", len(blocks)) } if !strings.Contains(blocks[0].Text, "part1") || !strings.Contains(blocks[0].Text, "part3") { t.Error("merged static block should contain all parts") } } // --------------------------------------------------------------------------- // BlocksToString 测试 // --------------------------------------------------------------------------- func TestBlocksToString_JoinsBlocks(t *testing.T) { blocks := []SystemPromptBlock{ {Text: "first"}, {Text: "second"}, {Text: "third"}, } result := BlocksToString(blocks) if !strings.Contains(result, "first") || !strings.Contains(result, "third") { t.Errorf("BlocksToString should join all blocks, got %q", result) } } func TestBlocksToString_SkipsEmptyBlocks(t *testing.T) { blocks := []SystemPromptBlock{ {Text: "a"}, {Text: ""}, {Text: "b"}, } result := BlocksToString(blocks) if strings.Contains(result, "\n\n\n\n") { t.Error("empty blocks should be skipped, not leave double gaps") } if !strings.Contains(result, "a") || !strings.Contains(result, "b") { t.Error("non-empty blocks should be present") } } func TestBlocksToString_NilBlocks_EmptyString(t *testing.T) { result := BlocksToString(nil) if result != "" { t.Errorf("nil blocks should return empty string, got %q", result) } } // --------------------------------------------------------------------------- // Context value helpers 测试 // --------------------------------------------------------------------------- func TestContextHelpers_RoundTrip(t *testing.T) { ctx := context.Background() ctx = WithCwd(ctx, "/my/cwd") ctx = WithModelID(ctx, "claude-sonnet-4-6") ctx = WithToolDescriptions(ctx, []ToolDescription{{Name: "Bash", Description: "run"}}) ctx = WithEvolveFragment(ctx, "evolve frag") ctx = WithAppendPrompt(ctx, "append text") if got := CwdFromCtx(ctx); got != "/my/cwd" { t.Errorf("CwdFromCtx: got %q", got) } if got := ModelIDFromCtx(ctx); got != "claude-sonnet-4-6" { t.Errorf("ModelIDFromCtx: got %q", got) } descs := ToolDescriptionsFromCtx(ctx) if len(descs) != 1 || descs[0].Name != "Bash" { t.Errorf("ToolDescriptionsFromCtx: got %v", descs) } if got := EvolveFragmentFromCtx(ctx); got != "evolve frag" { t.Errorf("EvolveFragmentFromCtx: got %q", got) } if got := AppendPromptFromCtx(ctx); got != "append text" { t.Errorf("AppendPromptFromCtx: got %q", got) } } func TestContextHelpers_MissingValues_ReturnZero(t *testing.T) { ctx := context.Background() if got := CwdFromCtx(ctx); got != "" { t.Errorf("missing cwd should return empty string, got %q", got) } if descs := ToolDescriptionsFromCtx(ctx); descs != nil { t.Errorf("missing tool descs should return nil, got %v", descs) } } // --------------------------------------------------------------------------- // DefaultBundle 测试 // --------------------------------------------------------------------------- func TestDefaultBundle_HasStaticSections(t *testing.T) { bundle := NewDefaultBundle() statics := bundle.StaticSections() // 默认 Bundle 必须有至少 7 个静态 sections(对应早期方案 的 7 个函数) if len(statics) < 7 { t.Errorf("default bundle: expected >= 7 static sections, got %d", len(statics)) } // 每个静态 section 都应有非空内容 for _, s := range statics { if !s.Static { t.Errorf("section %q: expected Static=true", s.Name) } if s.Text == "" && s.Compute == nil { t.Errorf("section %q: has no content (both Text and Compute are nil/empty)", s.Name) } } } func TestDefaultBundle_HasDynamicSections(t *testing.T) { bundle := NewDefaultBundle() dynamics := bundle.DynamicSections() if len(dynamics) == 0 { t.Error("default bundle should have at least one dynamic section") } // 检查关键 sections 存在 names := make(map[string]bool) for _, s := range dynamics { names[s.Name] = true } for _, required := range []string{"env_info", "instructions"} { if !names[required] { t.Errorf("default bundle missing dynamic section %q", required) } } } func TestDefaultBundle_StaticSections_NotEmpty(t *testing.T) { bundle := NewDefaultBundle() reg := NewSectionRegistry() ctx := context.Background() for _, s := range bundle.StaticSections() { content := reg.Compute(ctx, s) if content == "" { t.Errorf("static section %q computed to empty string", s.Name) } } } func TestNewDefaultBundleRegistry_HasDefaultBundle(t *testing.T) { reg := NewDefaultBundleRegistry() bundle := reg.Resolve(DefaultBundleKey) if bundle == nil { t.Fatal("NewDefaultBundleRegistry should have default bundle pre-registered") } // 确认是合法的 Bundle(有静态 sections) if len(bundle.StaticSections()) == 0 { t.Error("default bundle should have static sections") } } // --------------------------------------------------------------------------- // Builder.BuildSystemPromptBlocks 集成测试 // --------------------------------------------------------------------------- func TestBuilder_BuildSystemPromptBlocks_NoCaching(t *testing.T) { b := NewBuilder("/tmp", 0) blocks := b.BuildSystemPromptBlocks(context.Background()) if len(blocks) == 0 { t.Error("expected at least one block") } // 不缓存时,单块,CacheScope 为空 if len(blocks) == 1 && blocks[0].CacheScope != "" { t.Errorf("no-caching mode: expected empty CacheScope, got %q", blocks[0].CacheScope) } } func TestBuilder_BuildSystemPromptBlocks_WithCaching(t *testing.T) { b := NewBuilder("/tmp", 0) b.SetEnableCaching(true) blocks := b.BuildSystemPromptBlocks(context.Background()) if len(blocks) == 0 { t.Error("expected at least one block") } // 有缓存时至少有一个块有 CacheScope hasCacheable := false for _, blk := range blocks { if blk.CacheScope != "" { hasCacheable = true break } } if !hasCacheable { t.Error("caching enabled: expected at least one block with non-empty CacheScope") } } func TestBuilder_BuildSystemPrompt_BackwardCompat(t *testing.T) { b := NewBuilder("/tmp", 0) // 向后兼容:BuildSystemPrompt 应返回非空字符串 sp := b.BuildSystemPrompt() if sp == "" { t.Error("BuildSystemPrompt() should return non-empty string") } // 包含静态提示词的关键内容 if !strings.Contains(sp, "software engineering") { t.Errorf("system prompt missing expected content, got length %d", len(sp)) } } func TestBuilder_CustomSystemPrompt_Overrides_Bundle(t *testing.T) { b := NewBuilder("/tmp", 0) b.SetSystemPrompt("MY CUSTOM PROMPT") sp := b.BuildSystemPrompt() if !strings.Contains(sp, "MY CUSTOM PROMPT") { t.Error("custom systemPrompt should appear in output") } // 自定义时不应包含 Bundle 的内置文字 // 注意:动态内容(env_info 等)仍然追加 } // --------------------------------------------------------------------------- // 辅助测试类型 // --------------------------------------------------------------------------- // testBundle 是测试用的简单 PromptBundle 实现. type testBundle struct { name string staticSecs []*Section dynamicSecs []*Section } func (b *testBundle) StaticSections() []*Section { return b.staticSecs } func (b *testBundle) DynamicSections() []*Section { return b.dynamicSecs } // --------------------------------------------------------------------------- // NoCacheReason diagnostic event regression guards // --------------------------------------------------------------------------- // recordingObserver captures observer events for test assertion. // // recordingObserver 捕获 observer 事件供测试断言. type recordingObserver struct { events []recordedEvent errors []recordedError } type recordedEvent struct { name string data map[string]any } type recordedError struct { err error ctx map[string]any } func (r *recordingObserver) Event(name string, data map[string]any) { r.events = append(r.events, recordedEvent{name: name, data: data}) } func (r *recordingObserver) Error(err error, ctx map[string]any) { r.errors = append(r.errors, recordedError{err: err, ctx: ctx}) } // TestSectionRegistry_CacheBreak_EmitsDiagnosticWithReason locks the design // intent of Section.NoCacheReason: when a CacheBreak section is computed, // the observer receives a `section_cache_break` event whose data carries the // section Name and the NoCacheReason verbatim. This is what makes // NoCacheReason actually useful at runtime (not just a code-review comment). // // TestSectionRegistry_CacheBreak_EmitsDiagnosticWithReason 锁定 // Section.NoCacheReason 的设计意图: CacheBreak section 被计算时, observer // 收到 `section_cache_break` 事件, data 原样携带 section Name 与 NoCacheReason. // 这让 NoCacheReason 在运行时真正发挥作用 (不只是一条代码审查注释). func TestSectionRegistry_CacheBreak_EmitsDiagnosticWithReason(t *testing.T) { obs := &recordingObserver{} reg := NewSectionRegistryWithObserver(obs) sec := VolatileSection("mcp_status", func(_ context.Context) string { return "connected: 2 servers" }, "mcp server connections change mid-session") ctx := context.Background() got := reg.Compute(ctx, sec) if got != "connected: 2 servers" { t.Fatalf("Compute returned %q, want resolved text", got) } if len(obs.events) != 1 { t.Fatalf("expected exactly 1 observer event, got %d", len(obs.events)) } ev := obs.events[0] if ev.name != "section_cache_break" { t.Errorf("event name = %q, want section_cache_break", ev.name) } if ev.data["name"] != "mcp_status" { t.Errorf("event data[name] = %v, want mcp_status", ev.data["name"]) } if ev.data["reason"] != "mcp server connections change mid-session" { t.Errorf("event data[reason] = %v, want mcp server connections change mid-session", ev.data["reason"]) } // Computing again must emit a second event (CacheBreak bypasses cache // and every computation is observable). // // 第二次 Compute 必须再发一次事件 (CacheBreak 跳过缓存, 每次计算都可观测). reg.Compute(ctx, sec) if len(obs.events) != 2 { t.Errorf("second Compute should emit a second event; got %d total", len(obs.events)) } } // TestSectionRegistry_NonCacheBreak_NoDiagnosticEvent guards the invariant // that non-CacheBreak sections (static / dynamic cached) never emit the // `section_cache_break` event — the event is strictly a cache-miss signal, // not a general "section computed" signal. // // TestSectionRegistry_NonCacheBreak_NoDiagnosticEvent 保护 invariant: // 非 CacheBreak section (静态/动态缓存) 永不发 `section_cache_break` 事件 // -- 此事件严格是 cache-miss 信号, 不是通用"section 已计算"信号. func TestSectionRegistry_NonCacheBreak_NoDiagnosticEvent(t *testing.T) { obs := &recordingObserver{} reg := NewSectionRegistryWithObserver(obs) staticSec := StaticSection("role", "you are flyto") dynSec := DynamicSection("env", func(_ context.Context) string { return "cwd=/tmp" }) ctx := context.Background() _ = reg.Compute(ctx, staticSec) _ = reg.Compute(ctx, dynSec) _ = reg.Compute(ctx, staticSec) // cache hit _ = reg.Compute(ctx, dynSec) // cache hit if len(obs.events) != 0 { t.Errorf("non-CacheBreak sections must not emit section_cache_break; got %d events", len(obs.events)) } } // TestBundleKey_String locks the load-bearing role of BundleKey.String(): // operators reading logs/errors see "/", and the method's // SelectorExpr reads of .ModelFamily/.Scenario are what keep those two // fields out of the dead-field-scan false-positive list (struct-as-map-key // pattern alone would leave them unread by any .Field accessor). // // TestBundleKey_String 锁 BundleKey.String() 的承载作用: 运维读日志/ // 错误时看到 "/", 方法内对 .ModelFamily/.Scenario 的 // SelectorExpr 读正是让这两字段不在 dead-field-scan 假阳性列表的依据 // (仅 struct-as-map-key 无任何 .Field 读). func TestBundleKey_String(t *testing.T) { cases := []struct { name string key BundleKey want string }{ {"default", BundleKey{ModelFamily: "claude", Scenario: "programming"}, "claude/programming"}, {"industry", BundleKey{ModelFamily: "qwen", Scenario: "warehouse"}, "qwen/warehouse"}, {"zero_value", BundleKey{}, "/"}, {"partial_empty_family", BundleKey{Scenario: "programming"}, "/programming"}, {"partial_empty_scenario", BundleKey{ModelFamily: "claude"}, "claude/"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := tc.key.String(); got != tc.want { t.Errorf("%+v.String() = %q, want %q", tc.key, got, tc.want) } }) } } // TestDefaultBundle_MCPServersVolatile_EndToEnd proves the wire is live on // the real production path: the default Claude+programming bundle contains // an mcp_servers VolatileSection, BuildPromptBlocks walks it each turn, and // the SectionRegistry's `section_cache_break` observer event fires carrying // the exact NoCacheReason string declared in the section definition. // // Without this guard, the NoCacheReason wire is only exercised by contrived // VolatileSections in unit tests -- never by a real consumer. This test // fails if anyone removes the mcp_servers section from the default bundle // or accidentally downgrades it from Volatile. // // TestDefaultBundle_MCPServersVolatile_EndToEnd 证明真 wire 挂在生产路径上: // 默认 Claude+programming bundle 含 mcp_servers VolatileSection, // BuildPromptBlocks 每轮遍历到它, SectionRegistry 的 `section_cache_break` // observer 事件真实触发, 携带 section 定义处声明的 NoCacheReason 原文. // // 没有这条 guard, NoCacheReason wire 只被单元测试里虚构的 VolatileSection // 触发, 真实消费者永远走不到. 若有人从默认 bundle 移除 mcp_servers section // 或把 Volatile 降级, 此 test 失败. func TestDefaultBundle_MCPServersVolatile_EndToEnd(t *testing.T) { obs := &recordingObserver{} reg := NewSectionRegistryWithObserver(obs) bundle := NewDefaultBundle() ctx := context.Background() ctx = WithCwd(ctx, "/tmp") ctx = WithMCPServerStatuses(ctx, []MCPServerStatus{ {Name: "filesystem", Connected: true}, {Name: "git", Connected: false}, }) blocks := BuildPromptBlocks(ctx, bundle, reg, true) if len(blocks) == 0 { t.Fatal("BuildPromptBlocks returned no blocks") } // Locate the section_cache_break event for mcp_servers. Not just any // event -- the exact section name must match (guards against silent // renames elsewhere in the bundle). // // 定位 mcp_servers 的 section_cache_break 事件. 不是任意事件 -- // section 名字必须精确匹配 (防止 bundle 内其他地方静默重命名). var found *recordedEvent for i, ev := range obs.events { if ev.name == "section_cache_break" && ev.data["name"] == "mcp_servers" { found = &obs.events[i] break } } if found == nil { t.Fatalf("expected section_cache_break event for mcp_servers on real default-bundle path; got events: %+v", obs.events) } reason, _ := found.data["reason"].(string) if !strings.Contains(reason, "MCP server connections") { t.Errorf("event reason = %q, want containing 'MCP server connections'", reason) } // The rendered block must carry both server names; proves ctx→section // wiring is intact end-to-end, not just that the event fired. // // 渲染块必须含两个 server 名字; 证明 ctx→section 接线端到端完整, 不只 // 是事件触发. joined := "" for _, b := range blocks { joined += b.Text + "\n" } if !strings.Contains(joined, "filesystem (connected)") { t.Errorf("rendered prompt missing 'filesystem (connected)'; got:\n%s", joined) } if !strings.Contains(joined, "git (disconnected)") { t.Errorf("rendered prompt missing 'git (disconnected)'; got:\n%s", joined) } } // TestDefaultBundle_MCPServersVolatile_EmptyStatuses_NoBlock guards the // no-MCP-configured case: empty statuses → empty section text → dropped // from the final prompt. The section is still *computed* (CacheBreak fires // the event), but no stray "# MCP Servers" header leaks in. // // TestDefaultBundle_MCPServersVolatile_EmptyStatuses_NoBlock 保护未配置 MCP // 的情况: 空 statuses → section 空文本 → 最终 prompt 丢弃. section 仍被 // 计算 (CacheBreak 事件触发), 但不会泄漏空的 "# MCP Servers" 头. func TestDefaultBundle_MCPServersVolatile_EmptyStatuses_NoBlock(t *testing.T) { obs := &recordingObserver{} reg := NewSectionRegistryWithObserver(obs) bundle := NewDefaultBundle() ctx := WithCwd(context.Background(), "/tmp") blocks := BuildPromptBlocks(ctx, bundle, reg, true) joined := "" for _, b := range blocks { joined += b.Text } if strings.Contains(joined, "# MCP Servers") { t.Errorf("empty statuses must drop the section; got:\n%s", joined) } // Event still fires (volatile is computed every turn by definition), // because NoCacheReason is meant to explain *why* we bypassed cache, // not conditional on content. // // 事件仍触发 (volatile 按定义每轮都计算), NoCacheReason 意图说明*为何* // 跳过 cache, 不以内容为条件. sawEvent := false for _, ev := range obs.events { if ev.name == "section_cache_break" && ev.data["name"] == "mcp_servers" { sawEvent = true break } } if !sawEvent { t.Error("section_cache_break event must fire even when statuses are empty (Volatile is computed every turn regardless of output)") } } // TestSectionRegistry_NilObserver_NoCrash guards the zero-value contract: // a registry built via NewSectionRegistry (no observer) must compute // CacheBreak sections without panicking. // // TestSectionRegistry_NilObserver_NoCrash 保护 zero-value 契约: // NewSectionRegistry (无 observer) 构造的 registry 计算 CacheBreak section // 时不应 panic. func TestSectionRegistry_NilObserver_NoCrash(t *testing.T) { reg := NewSectionRegistry() sec := VolatileSection("vol", func(_ context.Context) string { return "v" }, "for test") got := reg.Compute(context.Background(), sec) if got != "v" { t.Errorf("Compute returned %q, want v", got) } } // --------------------------------------------------------------------------- // Section.Static contract-violation regression guards // --------------------------------------------------------------------------- // violatingBundle is a test bundle that deliberately places sections in the // wrong bucket, simulating an SDK consumer who hand-builds &Section{...} // and forgets to align Static with the interface method. // // violatingBundle 是刻意把 section 放错 bucket 的测试 bundle, 模拟 SDK // 消费者手搓 &Section{...} 却忘了把 Static 和接口方法对齐. type violatingBundle struct { staticSecs []*Section dynamicSecs []*Section } func (b *violatingBundle) StaticSections() []*Section { return b.staticSecs } func (b *violatingBundle) DynamicSections() []*Section { return b.dynamicSecs } // TestBuildPromptBlocks_StaticBucket_NonStaticSection_EmitsViolation guards // Static field invariant (a): a section with Static=false placed in // StaticSections() fires `section_contract_violation` carrying the section // Name plus bucket/expected/actual labels. Computation still proceeds (the // check is diagnostic, not fatal) so a misconfigured consumer still gets a // working prompt, just with an actionable event in the observer stream. // // TestBuildPromptBlocks_StaticBucket_NonStaticSection_EmitsViolation 保护 // Static 字段 invariant (a): Static=false 的 section 放进 StaticSections() // 会发 `section_contract_violation` 事件, 携带 section Name + bucket/ // expected/actual 标签. 计算仍继续 (check 是诊断不是 fatal), 配置错误的 // 消费者仍能拿到可用 prompt, 同时 observer 流里有可行动事件. func TestBuildPromptBlocks_StaticBucket_NonStaticSection_EmitsViolation(t *testing.T) { obs := &recordingObserver{} reg := NewSectionRegistryWithObserver(obs) bundle := &violatingBundle{ staticSecs: []*Section{ // Hand-built with Static=false but placed in StaticSections(). // 手搓 Static=false 却放进 StaticSections(). {Name: "misplaced_role", Text: "you are flyto", Static: false}, }, } blocks := BuildPromptBlocks(context.Background(), bundle, reg, true) if len(blocks) == 0 { t.Fatal("BuildPromptBlocks returned no blocks despite violation being non-fatal") } var violation *recordedEvent for i, ev := range obs.events { if ev.name == "section_contract_violation" { violation = &obs.events[i] break } } if violation == nil { t.Fatalf("expected section_contract_violation event; got: %+v", obs.events) } if violation.data["name"] != "misplaced_role" { t.Errorf("violation name = %v, want misplaced_role", violation.data["name"]) } if violation.data["bucket"] != "StaticSections" { t.Errorf("violation bucket = %v, want StaticSections", violation.data["bucket"]) } if violation.data["expected_static"] != true { t.Errorf("violation expected_static = %v, want true", violation.data["expected_static"]) } if violation.data["actual_static"] != false { t.Errorf("violation actual_static = %v, want false", violation.data["actual_static"]) } } // TestBuildPromptBlocks_DynamicBucket_StaticSection_EmitsViolation guards // the inverse invariant: a Static=true section placed in DynamicSections() // also fires the violation event. This catches the SDK consumer who copies // a StaticSection and moves it into the dynamic list without flipping the // flag, which would silently forgo the future "global" cache tier. // // TestBuildPromptBlocks_DynamicBucket_StaticSection_EmitsViolation 保护 // 反向 invariant: Static=true section 放进 DynamicSections() 同样发违规 // 事件. 抓 SDK 消费者复制 StaticSection 挪进动态列表却忘改 flag, 否则会 // 静默错过未来的 "global" cache tier. func TestBuildPromptBlocks_DynamicBucket_StaticSection_EmitsViolation(t *testing.T) { obs := &recordingObserver{} reg := NewSectionRegistryWithObserver(obs) bundle := &violatingBundle{ dynamicSecs: []*Section{ {Name: "misplaced_role", Text: "you are flyto", Static: true}, }, } _ = BuildPromptBlocks(context.Background(), bundle, reg, true) var violation *recordedEvent for i, ev := range obs.events { if ev.name == "section_contract_violation" { violation = &obs.events[i] break } } if violation == nil { t.Fatalf("expected section_contract_violation event; got: %+v", obs.events) } if violation.data["bucket"] != "DynamicSections" { t.Errorf("violation bucket = %v, want DynamicSections", violation.data["bucket"]) } if violation.data["expected_static"] != false { t.Errorf("violation expected_static = %v, want false", violation.data["expected_static"]) } if violation.data["actual_static"] != true { t.Errorf("violation actual_static = %v, want true", violation.data["actual_static"]) } } // TestBuildPromptBlocks_DefaultBundle_NoViolations proves the built-in // default Claude+programming bundle is itself free of contract violations. // Without this, a future edit that accidentally flips a section's Static // flag would silently start polluting every engine's observer stream. // // TestBuildPromptBlocks_DefaultBundle_NoViolations 证明内置的 Claude+ // programming 默认 bundle 自身无契约违规. 否则某次编辑意外翻转 section // 的 Static flag 会静默污染每个 engine 的 observer 流. func TestBuildPromptBlocks_DefaultBundle_NoViolations(t *testing.T) { obs := &recordingObserver{} reg := NewSectionRegistryWithObserver(obs) bundle := NewDefaultBundle() ctx := WithCwd(context.Background(), "/tmp") _ = BuildPromptBlocks(ctx, bundle, reg, true) for _, ev := range obs.events { if ev.name == "section_contract_violation" { t.Errorf("default bundle emitted contract violation: %+v", ev) } } }