package promptkit import ( "errors" "strings" "testing" ) // staticSection is a test-only Section that returns a fixed string. // 测试专用 Section, 返回固定字符串. type staticSection struct { name string body string err error } func (s *staticSection) Name() string { return s.name } func (s *staticSection) Render(_ Context) (string, error) { return s.body, s.err } // stubBundle is a test-only PromptBundle with configurable layers. // 测试专用 PromptBundle, 各层可配置. type stubBundle struct { base []Section conditional []Section triggers []Trigger } func (b *stubBundle) BaseSections() []Section { return b.base } func (b *stubBundle) ConditionalSections(_ BuildContext) []Section { return b.conditional } func (b *stubBundle) ReminderTriggers() []Trigger { return b.triggers } // stubTrigger is a test-only Trigger. // 测试专用 Trigger. type stubTrigger struct { on TriggerEvent out string } func (t *stubTrigger) On() TriggerEvent { return t.on } func (t *stubTrigger) Inject(_ Event) string { return t.out } func TestBuild_BaseAndConditionalAssemble(t *testing.T) { b := &stubBundle{ base: []Section{ &staticSection{name: "intro", body: "你是一个 agent"}, &staticSection{name: "tone", body: "保持简洁"}, }, conditional: []Section{ &staticSection{name: "git", body: "git status: clean"}, }, } out, boundary, err := Build(b, BuildContext{Cwd: "/tmp", HasGitRepo: true}) if err != nil { t.Fatalf("Build returned error: %v", err) } wantParts := []string{"你是一个 agent", "保持简洁", "git status: clean"} for _, p := range wantParts { if !strings.Contains(out, p) { t.Errorf("output missing %q; got:\n%s", p, out) } } // Cache boundary should fall after base sections, before conditional. // cache 边界应在 base 段之后, conditional 段之前. prefix := out[:boundary] if !strings.Contains(prefix, "保持简洁") { t.Errorf("cache prefix should contain last base section; got prefix:\n%s", prefix) } if strings.Contains(prefix, "git status") { t.Errorf("cache prefix should NOT contain conditional section; got prefix:\n%s", prefix) } } func TestBuild_EmptySectionSkipped(t *testing.T) { b := &stubBundle{ base: []Section{ &staticSection{name: "intro", body: "你是一个 agent"}, &staticSection{name: "empty", body: ""}, // skipped &staticSection{name: "tone", body: "保持简洁"}, }, } out, _, err := Build(b, BuildContext{}) if err != nil { t.Fatalf("Build returned error: %v", err) } // Empty section should not produce extra "\n\n". // 空段不应产生多余的 "\n\n". if strings.Count(out, "\n\n") != 2 { t.Errorf("expected 2 separators (intro + tone with trailing), got %d in:\n%q", strings.Count(out, "\n\n"), out) } } func TestBuild_NilBundle(t *testing.T) { _, _, err := Build(nil, BuildContext{}) if !errors.Is(err, ErrNilBundle) { t.Errorf("expected ErrNilBundle, got %v", err) } } func TestBuild_BaseSectionError(t *testing.T) { want := errors.New("boom") b := &stubBundle{ base: []Section{ &staticSection{name: "broken", body: "", err: want}, }, } _, _, err := Build(b, BuildContext{}) if err == nil { t.Fatalf("expected error from base section render") } var sre *SectionRenderError if !errors.As(err, &sre) { t.Fatalf("expected *SectionRenderError, got %T: %v", err, err) } if sre.Layer != "base" { t.Errorf("Layer = %q, want %q", sre.Layer, "base") } if sre.Section != "broken" { t.Errorf("Section = %q, want %q", sre.Section, "broken") } if !errors.Is(err, want) { t.Errorf("Unwrap should reach original cause; got %v", err) } } func TestBuild_ConditionalSectionError(t *testing.T) { want := errors.New("conditional boom") b := &stubBundle{ base: []Section{&staticSection{name: "intro", body: "ok"}}, conditional: []Section{ &staticSection{name: "git", body: "", err: want}, }, } _, boundary, err := Build(b, BuildContext{}) if err == nil { t.Fatalf("expected error from conditional section render") } var sre *SectionRenderError if !errors.As(err, &sre) { t.Fatalf("expected *SectionRenderError, got %T: %v", err, err) } if sre.Layer != "conditional" { t.Errorf("Layer = %q, want %q", sre.Layer, "conditional") } // boundary should still be reported for partial assembly. // 部分装配仍应报告 boundary. if boundary == 0 { t.Errorf("expected non-zero cache boundary for partial assembly") } } func TestBuild_ContextPropagatesToRender(t *testing.T) { got := BuildContext{} captured := false cap := &captureSection{capture: func(c Context) { got = c; captured = true }} b := &stubBundle{base: []Section{cap}} want := BuildContext{Cwd: "/foo", ModelFamily: "claude", Language: "zh-CN", HasGitRepo: true} _, _, err := Build(b, want) if err != nil { t.Fatalf("Build returned error: %v", err) } if !captured { t.Fatalf("Render not called") } if got.Cwd != want.Cwd || got.ModelFamily != want.ModelFamily || got.Language != want.Language || got.HasGitRepo != want.HasGitRepo { t.Errorf("Render received %+v, want %+v", got, want) } } func TestReminderTriggers_NotAssembledIntoSystemPrompt(t *testing.T) { // Layer 3 reminders are NOT in the assembled prompt; engine consumes // ReminderTriggers() separately. Build should ignore them entirely. // Layer 3 reminder 不进装配后的 prompt; engine 单独消费. Build 应完全忽略它们. b := &stubBundle{ base: []Section{&staticSection{name: "intro", body: "ok"}}, triggers: []Trigger{ &stubTrigger{on: EventToolResult, out: "不该出现在 system prompt"}, }, } out, _, err := Build(b, BuildContext{}) if err != nil { t.Fatalf("Build returned error: %v", err) } if strings.Contains(out, "不该出现在 system prompt") { t.Errorf("reminder leaked into system prompt:\n%s", out) } } // captureSection records the Context it receives. // 记录收到的 Context. type captureSection struct { capture func(Context) } func (s *captureSection) Name() string { return "capture" } func (s *captureSection) Render(ctx Context) (string, error) { s.capture(ctx) return "captured", nil }