返回 DeepSeek-Reasonix
prompt_stability_test.go
根目录 / internal / boot / prompt_stability_test.go
1 package boot
2
3 import (
4 "context"
5 "strings"
6 "testing"
7 )
8
9 // TestBuildComposesByteStableSystemPrompt is the boot-level byte-stability
10 // guard: two Builds over the same workspace and config must compose the exact
11 // same system prompt. The system prompt is the provider-cached prefix of every
12 // request in every session — any byte of nondeterminism here (probe flaps,
13 // unsorted iteration, time-dependent content) cold-starts the provider cache
14 // for the whole machine, which is precisely the "desktop costs more" class
15 // (#2945). Environment probes are covered cross-process by the persisted
16 // snapshot tests in internal/environment; this test pins the rest of the
17 // composition (memory, skills index, output style, workspace line, policies).
18 func TestBuildComposesByteStableSystemPrompt(t *testing.T) {
19 isolateConfigHome(t)
20 dir := robustTempDir(t)
21 t.Chdir(dir)
22
23 writeFile(t, dir, "reasonix.toml", `
24 default_model = "test-model"
25
26 [agent]
27 system_prompt = "BASE SYSTEM PROMPT"
28
29 [[providers]]
30 name = "test-model"
31 kind = "openai"
32 base_url = "https://example.invalid"
33 model = "x"
34 api_key_env = "REASONIX_TEST_KEY_UNSET"
35 `)
36 writeFile(t, dir, "REASONIX.md", "Project rule: keep the prompt prefix stable.")
37
38 first, err := Build(context.Background(), Options{})
39 if err != nil {
40 t.Fatalf("first Build: %v", err)
41 }
42 firstPrompt := systemMessage(first.History())
43 first.Close()
44 if strings.TrimSpace(firstPrompt) == "" {
45 t.Fatal("first Build composed an empty system prompt")
46 }
47
48 second, err := Build(context.Background(), Options{})
49 if err != nil {
50 t.Fatalf("second Build: %v", err)
51 }
52 secondPrompt := systemMessage(second.History())
53 second.Close()
54
55 if firstPrompt != secondPrompt {
56 t.Fatalf("system prompt is not byte-stable across identical Builds:\nfirst (%d bytes)\nsecond (%d bytes)\nfirst diff site: %q",
57 len(firstPrompt), len(secondPrompt), firstDivergence(firstPrompt, secondPrompt))
58 }
59 }
60
61 // firstDivergence returns a small window around the first differing byte so a
62 // failure names the drifting prompt section instead of dumping both prompts.
63 func firstDivergence(a, b string) string {
64 limit := len(a)
65 if len(b) < limit {
66 limit = len(b)
67 }
68 i := 0
69 for i < limit && a[i] == b[i] {
70 i++
71 }
72 start := i - 40
73 if start < 0 {
74 start = 0
75 }
76 endA := i + 40
77 if endA > len(a) {
78 endA = len(a)
79 }
80 endB := i + 40
81 if endB > len(b) {
82 endB = len(b)
83 }
84 return "..." + a[start:endA] + "... vs ..." + b[start:endB] + "..."
85 }
86
86 lines GO