返回 DeepSeek-Reasonix
system_prompt_test.go
根目录 / internal / config / system_prompt_test.go
1 package config
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 fileencoding "reasonix/internal/fileutil/encoding"
11 )
12
13 func TestDefaultSystemPromptStaysLean(t *testing.T) {
14 if len(DefaultSystemPrompt) > 240 {
15 t.Fatalf("default system prompt grew to %d bytes; keep workflows in mode contracts and tool descriptions", len(DefaultSystemPrompt))
16 }
17 for _, duplicate := range []string{"todo_write", "plan mode", "verify with tools", "acceptance criteria"} {
18 if strings.Contains(strings.ToLower(DefaultSystemPrompt), duplicate) {
19 t.Fatalf("default system prompt duplicates %q workflow guidance: %q", duplicate, DefaultSystemPrompt)
20 }
21 }
22 for _, want := range []string{"Reasonix", "available tools", "focused", "concise"} {
23 if !strings.Contains(DefaultSystemPrompt, want) {
24 t.Fatalf("default system prompt missing %q: %q", want, DefaultSystemPrompt)
25 }
26 }
27 }
28
29 func TestResolveSystemPromptForRootRelativePath(t *testing.T) {
30 root := t.TempDir()
31 t.Setenv("REASONIX_HOME", t.TempDir())
32 if err := os.MkdirAll(filepath.Join(root, "prompts"), 0o755); err != nil {
33 t.Fatal(err)
34 }
35 if err := os.WriteFile(filepath.Join(root, "prompts", "session.md"), []byte(" project session prompt \n"), 0o644); err != nil {
36 t.Fatal(err)
37 }
38 t.Chdir(t.TempDir())
39
40 cfg := Default()
41 cfg.Agent.SystemPromptFile = filepath.Join("prompts", "session.md")
42
43 got, err := cfg.ResolveSystemPromptForRoot(root)
44 if err != nil {
45 t.Fatalf("ResolveSystemPromptForRoot: %v", err)
46 }
47 if got != "project session prompt" {
48 t.Fatalf("system prompt = %q, want %q", got, "project session prompt")
49 }
50 }
51
52 func TestResolveSystemPromptForRootAbsolutePath(t *testing.T) {
53 root := t.TempDir()
54 path := filepath.Join(root, "session.md")
55 if err := os.WriteFile(path, []byte(" absolute session prompt \n"), 0o644); err != nil {
56 t.Fatal(err)
57 }
58 t.Chdir(t.TempDir())
59
60 cfg := Default()
61 cfg.Agent.SystemPromptFile = path
62
63 got, err := cfg.ResolveSystemPromptForRoot(t.TempDir())
64 if err != nil {
65 t.Fatalf("ResolveSystemPromptForRoot: %v", err)
66 }
67 if got != "absolute session prompt" {
68 t.Fatalf("system prompt = %q, want %q", got, "absolute session prompt")
69 }
70 }
71
72 func TestResolveSystemPromptForRootMissingFile(t *testing.T) {
73 home := t.TempDir()
74 root := t.TempDir()
75 t.Setenv("REASONIX_HOME", home)
76
77 cfg := Default()
78 cfg.Agent.SystemPromptFile = "prompts/does-not-exist.md"
79
80 _, err := cfg.ResolveSystemPromptForRoot(root)
81 if err == nil {
82 t.Fatal("expected error for missing system_prompt_file")
83 }
84 if !strings.Contains(err.Error(), "not found at any configured location") {
85 t.Fatalf("error %q missing friendly not-found message", err)
86 }
87 // All probed locations must be listed so users can see where it looked.
88 for _, tried := range []string{filepath.Join(root, "prompts", "does-not-exist.md"), filepath.Join(home, "prompts", "does-not-exist.md")} {
89 if !strings.Contains(err.Error(), tried) {
90 t.Fatalf("error %q does not list tried path %q", err, tried)
91 }
92 }
93 if got := cfg.InlineSystemPrompt(); got != DefaultSystemPrompt {
94 t.Fatalf("InlineSystemPrompt fallback = %q, want DefaultSystemPrompt", got)
95 }
96 }
97
98 func TestResolveSystemPromptProjectCannotReadReasonixHome(t *testing.T) {
99 home := t.TempDir()
100 root := t.TempDir()
101 t.Setenv("REASONIX_HOME", home)
102 secret := "OTHER_PROVIDER_SECRET=must-not-enter-prompt"
103 if err := os.WriteFile(filepath.Join(home, ".env"), []byte(secret), 0o600); err != nil {
104 t.Fatal(err)
105 }
106 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[agent]\nsystem_prompt_file = \".env\"\n"), 0o600); err != nil {
107 t.Fatal(err)
108 }
109
110 cfg, err := LoadForRootReadOnly(root)
111 if err != nil {
112 t.Fatalf("LoadForRootReadOnly: %v", err)
113 }
114 got, err := cfg.ResolveSystemPromptForRoot(root)
115 if err == nil {
116 t.Fatalf("ResolveSystemPromptForRoot = %q, want missing workspace file error", got)
117 }
118 if !IsMissingSystemPromptFile(err) {
119 t.Fatalf("error = %v, want missing prompt classification", err)
120 }
121 if strings.Contains(err.Error(), filepath.Join(home, ".env")) || strings.Contains(got, secret) {
122 t.Fatalf("project prompt resolution probed Reasonix credentials: got=%q err=%v", got, err)
123 }
124 }
125
126 func TestMergeFileSnapshotKeepsProjectPromptSourceAtomic(t *testing.T) {
127 home := t.TempDir()
128 root := t.TempDir()
129 t.Setenv("REASONIX_HOME", home)
130 const secret = "PROVIDER_SECRET=must-not-enter-system-prompt"
131 if err := os.WriteFile(filepath.Join(home, ".env"), []byte(secret), 0o600); err != nil {
132 t.Fatal(err)
133 }
134 projectConfig := filepath.Join(root, "reasonix.toml")
135 if err := os.WriteFile(projectConfig, []byte("[agent]\nsystem_prompt_file = \".env\"\n"), 0o600); err != nil {
136 t.Fatal(err)
137 }
138
139 cfg := Default()
140 reads := 0
141 meta, err := mergeFileSnapshotWithRead(cfg, projectConfig, func(path string) ([]byte, error) {
142 reads++
143 data, err := fileencoding.ReadFileUTF8(path)
144 if err != nil {
145 return nil, err
146 }
147 // Simulate an atomic config replacement after the read. The merged value
148 // and its provenance must still come from data, not from a second read.
149 if err := os.WriteFile(path, []byte("[agent]\nsystem_prompt = \"replacement\"\n"), 0o600); err != nil {
150 return nil, err
151 }
152 return data, nil
153 })
154 if err != nil {
155 t.Fatalf("mergeFileSnapshotWithRead: %v", err)
156 }
157 if reads != 1 {
158 t.Fatalf("config reads = %d, want exactly one", reads)
159 }
160 if !meta.IsDefined("agent", "system_prompt_file") {
161 t.Fatal("snapshot metadata lost project system_prompt_file")
162 }
163 cfg.systemPromptFileSource = promptFileSourceProject
164
165 got, err := cfg.ResolveSystemPromptForRoot(root)
166 if err == nil || !IsMissingSystemPromptFile(err) {
167 t.Fatalf("ResolveSystemPromptForRoot = %q, %v; want project-scoped missing error", got, err)
168 }
169 if strings.Contains(got, secret) || strings.Contains(err.Error(), filepath.Join(home, ".env")) {
170 t.Fatalf("project prompt resolution probed Reasonix credentials: got=%q err=%v", got, err)
171 }
172 }
173
174 func TestResolveSystemPromptUserConfigCanFallBackToReasonixHome(t *testing.T) {
175 home := t.TempDir()
176 root := t.TempDir()
177 t.Setenv("REASONIX_HOME", home)
178 if err := os.MkdirAll(filepath.Join(home, "prompts"), 0o755); err != nil {
179 t.Fatal(err)
180 }
181 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[agent]\nsystem_prompt_file = \"prompts/system.md\"\n"), 0o600); err != nil {
182 t.Fatal(err)
183 }
184 if err := os.WriteFile(filepath.Join(home, "prompts", "system.md"), []byte("trusted home prompt"), 0o600); err != nil {
185 t.Fatal(err)
186 }
187
188 cfg, err := LoadForRootReadOnly(root)
189 if err != nil {
190 t.Fatalf("LoadForRootReadOnly: %v", err)
191 }
192 got, err := cfg.ResolveSystemPromptForRoot(root)
193 if err != nil {
194 t.Fatalf("ResolveSystemPromptForRoot: %v", err)
195 }
196 if got != "trusted home prompt" {
197 t.Fatalf("system prompt = %q, want trusted home prompt", got)
198 }
199 }
200
201 func TestResolveSystemPromptProjectPathStaysInWorkspace(t *testing.T) {
202 home := t.TempDir()
203 root := t.TempDir()
204 t.Setenv("REASONIX_HOME", home)
205 for _, dir := range []string{filepath.Join(home, "prompts"), filepath.Join(root, "prompts")} {
206 if err := os.MkdirAll(dir, 0o755); err != nil {
207 t.Fatal(err)
208 }
209 }
210 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[agent]\nsystem_prompt_file = \"prompts/system.md\"\n"), 0o600); err != nil {
211 t.Fatal(err)
212 }
213 if err := os.WriteFile(filepath.Join(home, "prompts", "system.md"), []byte("home prompt"), 0o600); err != nil {
214 t.Fatal(err)
215 }
216 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[agent]\nsystem_prompt_file = \"prompts/system.md\"\n"), 0o600); err != nil {
217 t.Fatal(err)
218 }
219 if err := os.WriteFile(filepath.Join(root, "prompts", "system.md"), []byte("workspace prompt"), 0o600); err != nil {
220 t.Fatal(err)
221 }
222
223 cfg, err := LoadForRootReadOnly(root)
224 if err != nil {
225 t.Fatalf("LoadForRootReadOnly: %v", err)
226 }
227 got, err := cfg.ResolveSystemPromptForRoot(root)
228 if err != nil {
229 t.Fatalf("ResolveSystemPromptForRoot: %v", err)
230 }
231 if got != "workspace prompt" {
232 t.Fatalf("system prompt = %q, want workspace prompt", got)
233 }
234 }
235
236 func TestResolveSystemPromptRejectsProjectPathEscapes(t *testing.T) {
237 home := t.TempDir()
238 t.Setenv("REASONIX_HOME", home)
239 for _, test := range []struct {
240 name string
241 path func(root string) string
242 }{
243 {name: "parent", path: func(string) string { return filepath.Join("..", "outside.md") }},
244 {name: "absolute", path: func(root string) string { return filepath.Join(filepath.Dir(root), "outside.md") }},
245 } {
246 t.Run(test.name, func(t *testing.T) {
247 root := t.TempDir()
248 outside := test.path(root)
249 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(fmt.Sprintf("[agent]\nsystem_prompt_file = %q\n", outside)), 0o600); err != nil {
250 t.Fatal(err)
251 }
252 cfg, err := LoadForRootReadOnly(root)
253 if err != nil {
254 t.Fatalf("LoadForRootReadOnly: %v", err)
255 }
256 if _, err := cfg.ResolveSystemPromptForRoot(root); err == nil || IsMissingSystemPromptFile(err) || !strings.Contains(err.Error(), "relative path within the workspace") {
257 t.Fatalf("ResolveSystemPromptForRoot error = %v, want fatal containment error", err)
258 }
259 })
260 }
261 }
262
263 func TestResolveSystemPromptRejectsProjectSymlinkEscape(t *testing.T) {
264 home := t.TempDir()
265 root := t.TempDir()
266 outside := filepath.Join(t.TempDir(), "outside.md")
267 t.Setenv("REASONIX_HOME", home)
268 if err := os.WriteFile(outside, []byte("outside secret"), 0o600); err != nil {
269 t.Fatal(err)
270 }
271 if err := os.MkdirAll(filepath.Join(root, "prompts"), 0o755); err != nil {
272 t.Fatal(err)
273 }
274 if err := os.Symlink(outside, filepath.Join(root, "prompts", "system.md")); err != nil {
275 t.Skipf("symlink unavailable: %v", err)
276 }
277 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[agent]\nsystem_prompt_file = \"prompts/system.md\"\n"), 0o600); err != nil {
278 t.Fatal(err)
279 }
280 cfg, err := LoadForRootReadOnly(root)
281 if err != nil {
282 t.Fatalf("LoadForRootReadOnly: %v", err)
283 }
284 if got, err := cfg.ResolveSystemPromptForRoot(root); err == nil || IsMissingSystemPromptFile(err) || strings.Contains(got, "outside secret") {
285 t.Fatalf("ResolveSystemPromptForRoot = %q, %v; want fatal symlink containment error", got, err)
286 }
287 }
288
289 func TestResolveSystemPromptMixedReadErrorsAreNotMissing(t *testing.T) {
290 home := t.TempDir()
291 root := t.TempDir()
292 t.Setenv("REASONIX_HOME", home)
293 if err := os.MkdirAll(filepath.Join(home, "prompts", "system.md"), 0o755); err != nil {
294 t.Fatal(err)
295 }
296 cfg := Default()
297 cfg.Agent.SystemPromptFile = filepath.Join("prompts", "system.md")
298 if _, err := cfg.ResolveSystemPromptForRoot(root); err == nil || IsMissingSystemPromptFile(err) {
299 t.Fatalf("ResolveSystemPromptForRoot error = %v, want non-missing read failure", err)
300 }
301 }
302
303 func TestResolveSystemPromptForRootFallsBackToReasonixHome(t *testing.T) {
304 home := t.TempDir()
305 t.Setenv("REASONIX_HOME", home)
306 if err := os.MkdirAll(filepath.Join(home, "prompts"), 0o755); err != nil {
307 t.Fatal(err)
308 }
309 if err := os.WriteFile(filepath.Join(home, "prompts", "system.md"), []byte(" home prompt \n"), 0o644); err != nil {
310 t.Fatal(err)
311 }
312
313 cfg := Default()
314 cfg.Agent.SystemPromptFile = filepath.Join("prompts", "system.md")
315
316 // Workspace root has no such file; the Reasonix-home copy must win the probe.
317 got, err := cfg.ResolveSystemPromptForRoot(t.TempDir())
318 if err != nil {
319 t.Fatalf("ResolveSystemPromptForRoot: %v", err)
320 }
321 if got != "home prompt" {
322 t.Fatalf("system prompt = %q, want %q", got, "home prompt")
323 }
324 }
325
326 func TestResolveSystemPromptForRootWorkspaceWins(t *testing.T) {
327 home := t.TempDir()
328 root := t.TempDir()
329 t.Setenv("REASONIX_HOME", home)
330 for dir, content := range map[string]string{home: "home prompt", root: "workspace prompt"} {
331 if err := os.MkdirAll(filepath.Join(dir, "prompts"), 0o755); err != nil {
332 t.Fatal(err)
333 }
334 if err := os.WriteFile(filepath.Join(dir, "prompts", "system.md"), []byte(content), 0o644); err != nil {
335 t.Fatal(err)
336 }
337 }
338
339 cfg := Default()
340 cfg.Agent.SystemPromptFile = filepath.Join("prompts", "system.md")
341
342 got, err := cfg.ResolveSystemPromptForRoot(root)
343 if err != nil {
344 t.Fatalf("ResolveSystemPromptForRoot: %v", err)
345 }
346 if got != "workspace prompt" {
347 t.Fatalf("system prompt = %q, want workspace copy to win, got %q", got, "workspace prompt")
348 }
349 }
350
351 func TestResolveSystemPromptForRootDecodesGB18030(t *testing.T) {
352 root := t.TempDir()
353 t.Setenv("REASONIX_HOME", t.TempDir())
354 if err := os.MkdirAll(filepath.Join(root, "prompts"), 0o755); err != nil {
355 t.Fatal(err)
356 }
357 path := filepath.Join(root, "prompts", "session.md")
358 if err := os.WriteFile(path, fileencoding.Encode(" 请始终使用中文回答。 \n", fileencoding.GB18030), 0o644); err != nil {
359 t.Fatal(err)
360 }
361
362 cfg := Default()
363 cfg.Agent.SystemPromptFile = filepath.Join("prompts", "session.md")
364
365 got, err := cfg.ResolveSystemPromptForRoot(root)
366 if err != nil {
367 t.Fatalf("ResolveSystemPromptForRoot: %v", err)
368 }
369 if got != "请始终使用中文回答。" {
370 t.Fatalf("system prompt = %q, want decoded Chinese prompt", got)
371 }
372 }
373
373 lines GO