返回 DeepSeek-Reasonix
prompt_stability_test.go
根目录 / internal / boot / prompt_stability_test.go
1 package boot
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "reasonix/internal/agent/testutil"
11 "reasonix/internal/memory"
12 "reasonix/internal/provider"
13 "reasonix/internal/sessioncontext"
14 )
15
16 func sessionContextMessage(msgs []provider.Message) string {
17 for _, message := range msgs {
18 if sessioncontext.IsContent(message.Content) {
19 return message.Content
20 }
21 }
22 return ""
23 }
24
25 // TestBuildComposesByteStableSystemPrompt is the boot-level byte-stability
26 // guard: two Builds over the same workspace and config must compose the exact
27 // same system prompt. The system prompt is the provider-cached prefix of every
28 // request in every session — any byte of nondeterminism here (probe flaps,
29 // unsorted iteration, time-dependent content) cold-starts the provider cache
30 // for the whole machine, which is precisely the "desktop costs more" class
31 // (#2945). Environment probes are covered cross-process by the persisted
32 // snapshot tests in internal/environment; this test pins the rest of the
33 // composition (memory, skills index, output style, workspace line, policies).
34 func TestBuildComposesByteStableSystemPrompt(t *testing.T) {
35 isolateConfigHome(t)
36 dir := robustTempDir(t)
37 t.Chdir(dir)
38
39 writeFile(t, dir, "reasonix.toml", `
40 default_model = "test-model"
41
42 [agent]
43 system_prompt = "BASE SYSTEM PROMPT"
44
45 [[providers]]
46 name = "test-model"
47 kind = "openai"
48 base_url = "https://example.invalid"
49 model = "x"
50 api_key_env = "REASONIX_TEST_KEY_UNSET"
51 `)
52 approveWorkspace(t, dir)
53 writeFile(t, dir, "REASONIX.md", "Project rule: keep the prompt prefix stable.")
54
55 first, err := Build(context.Background(), Options{})
56 if err != nil {
57 t.Fatalf("first Build: %v", err)
58 }
59 firstPrompt := systemMessage(first.History())
60 first.Close()
61 if strings.TrimSpace(firstPrompt) == "" {
62 t.Fatal("first Build composed an empty system prompt")
63 }
64
65 second, err := Build(context.Background(), Options{})
66 if err != nil {
67 t.Fatalf("second Build: %v", err)
68 }
69 secondPrompt := systemMessage(second.History())
70 second.Close()
71
72 if firstPrompt != secondPrompt {
73 t.Fatalf("system prompt is not byte-stable across identical Builds:\nfirst (%d bytes)\nsecond (%d bytes)\nfirst diff site: %q",
74 len(firstPrompt), len(secondPrompt), firstDivergence(firstPrompt, secondPrompt))
75 }
76 }
77
78 func TestBackgroundMemoryAndSkillCatalogChangesDoNotChangeSystemPrompt(t *testing.T) {
79 isolateConfigHome(t)
80 dir := robustTempDir(t)
81 t.Chdir(dir)
82 writeFile(t, dir, "reasonix.toml", `
83 default_model = "test-model"
84
85 [agent]
86 system_prompt = "STABLE BASE"
87
88 [environment]
89 enabled = false
90
91 [[providers]]
92 name = "test-model"
93 kind = "openai"
94 base_url = "https://example.invalid"
95 model = "x"
96 api_key_env = "REASONIX_TEST_KEY_UNSET"
97 `)
98 approveWorkspace(t, dir)
99
100 buildSystem := func() (*memory.Set, string) {
101 ctrl, err := Build(context.Background(), Options{})
102 if err != nil {
103 t.Fatal(err)
104 }
105 defer ctrl.Close()
106 return ctrl.Memory(), systemMessage(ctrl.History())
107 }
108 mem, baseline := buildSystem()
109 if _, err := mem.Store.Save(memory.Memory{
110 Name: "dynamic-cache-fact", Description: "background-only fact",
111 Activation: memory.ActivationRelevant, Body: "secret body",
112 }); err != nil {
113 t.Fatal(err)
114 }
115 skillPath := filepath.Join(dir, ".reasonix", "skills", "dynamic-skill", "SKILL.md")
116 writeFile(t, dir, ".reasonix/skills/dynamic-skill/SKILL.md", "---\ndescription: dynamic catalog entry\n---\nbody")
117
118 _, afterAdd := buildSystem()
119 if afterAdd != baseline {
120 t.Fatalf("background memory/skill addition changed system prompt: %q", firstDivergence(baseline, afterAdd))
121 }
122 if strings.Contains(afterAdd, "dynamic-cache-fact") || strings.Contains(afterAdd, "dynamic-skill") || strings.Contains(afterAdd, "secret body") {
123 t.Fatalf("dynamic catalog data leaked into system:\n%s", afterAdd)
124 }
125 if err := os.Remove(skillPath); err != nil {
126 t.Fatal(err)
127 }
128 if err := mem.Store.Delete("dynamic-cache-fact"); err != nil {
129 t.Fatal(err)
130 }
131 _, afterDelete := buildSystem()
132 if afterDelete != baseline {
133 t.Fatalf("background memory/skill deletion changed system prompt: %q", firstDivergence(baseline, afterDelete))
134 }
135
136 writeFile(t, dir, "AGENTS.md", "Standing rule: preserve the public API.")
137 _, withStanding := buildSystem()
138 if withStanding == baseline || !strings.Contains(withStanding, "Standing rule: preserve the public API.") {
139 t.Fatalf("standing instruction did not intentionally change system:\n%s", withStanding)
140 }
141 }
142
143 func TestDisableImplicitSkillInvocationOmitsPolicyAndCatalogButKeepsSlashSkill(t *testing.T) {
144 isolateConfigHome(t)
145 dir := robustTempDir(t)
146 t.Chdir(dir)
147 registerBootTokenProfileTestProvider()
148 prov := testutil.NewMock("implicit-off", testutil.Turn{Text: "done"})
149 setBootTokenProfileTestProvider(t, prov)
150 writeFile(t, dir, "reasonix.toml", `
151 default_model = "test-model"
152
153 [agent]
154 system_prompt = "BASE"
155
156 [environment]
157 enabled = false
158
159 [skills]
160 disable_implicit_invocation = true
161
162 [[providers]]
163 name = "test-model"
164 kind = "boot-token-profile-test"
165 model = "x"
166 `)
167 approveWorkspace(t, dir)
168 writeFile(t, dir, ".reasonix/skills/hot/SKILL.md", "---\ndescription: explicit hot skill\n---\nHOT BODY")
169
170 ctrl, err := Build(context.Background(), Options{})
171 if err != nil {
172 t.Fatal(err)
173 }
174 defer ctrl.Close()
175 if sys := systemMessage(ctrl.History()); strings.Contains(sys, "# Skills") || strings.Contains(sys, "explicit hot skill") {
176 t.Fatalf("implicit-off system contains skill policy/catalog:\n%s", sys)
177 }
178 if rendered, ok := ctrl.RunSkill("/hot now"); !ok || !strings.Contains(rendered, "HOT BODY") {
179 t.Fatalf("explicit slash skill = %q, %v", rendered, ok)
180 }
181 _ = ctrl.Run(context.Background(), "capture request prefix")
182 if req := prov.LastRequest(); req == nil || strings.Contains(sessionContextMessage(req.Messages), "explicit hot skill") {
183 t.Fatalf("implicit-off request unexpectedly contains skills catalog: %+v", req)
184 }
185 }
186
187 // firstDivergence returns a small window around the first differing byte so a
188 // failure names the drifting prompt section instead of dumping both prompts.
189 func firstDivergence(a, b string) string {
190 limit := min(len(b), len(a))
191 i := 0
192 for i < limit && a[i] == b[i] {
193 i++
194 }
195 start := max(i-40, 0)
196 endA := min(i+40, len(a))
197 endB := min(i+40, len(b))
198 return "..." + a[start:endA] + "... vs ..." + b[start:endB] + "..."
199 }
200
200 lines GO