返回 DeepSeek-Reasonix
golden_baseline_test.go
根目录 / internal / boot / golden_baseline_test.go
1 package boot
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10 "testing"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/provider"
14 "reasonix/internal/tool"
15 )
16
17 // Golden baseline for the unified extension kernel / Extension Protocol v1
18 // refactor. The kernel rebuilds runtime assembly (tools, skills, commands,
19 // hooks, MCP, providers) behind a Builder that freezes an immutable
20 // RuntimeSnapshot. The hard product contract is: with no v1 extension
21 // installed, the system prompt, provider-visible tool schemas, provider
22 // request serialization, and the prompt-cache prefix hash must stay
23 // byte-identical to the pre-refactor runtime, because every byte of drift
24 // cold-starts the provider prompt cache for every session on the machine.
25 //
26 // These goldens are recorded from the pre-refactor runtime. Later refactor
27 // stages must keep this test green without regenerating the files. If a
28 // deliberate, reviewed change to the provider-visible contract ever happens,
29 // regenerate with:
30 //
31 // REASONIX_UPDATE_GOLDEN=1 go test ./internal/boot -run TestGoldenBaseline -count=1
32 //
33 // and call the cache impact out in the commit message.
34 //
35 // Machine dependence is designed out of the golden: the environment probe
36 // section is disabled in the fixture config (it embeds GOOS/GOARCH, shell
37 // labels, and probe output; internal/environment covers it with its own
38 // snapshot tests), and the workspace root path is normalized to <ROOT>.
39 const goldenBaselineDir = "testdata/golden"
40
41 // goldenBaseline is one deterministic capture of the provider-visible
42 // runtime surface with zero extensions installed.
43 type goldenBaseline struct {
44 SystemPrompt string
45 ToolSchemas []byte
46 ProviderReq []byte
47 PrefixShape agent.PrefixShape
48 }
49
50 func captureGoldenBaseline(t *testing.T) goldenBaseline {
51 t.Helper()
52 isolateConfigHome(t)
53 dir := robustTempDir(t)
54 t.Chdir(dir)
55
56 writeFile(t, dir, "reasonix.toml", `
57 default_model = "test-model"
58
59 [agent]
60 system_prompt = "BASE SYSTEM PROMPT"
61
62 [environment]
63 enabled = false
64
65 [tools.search]
66 # Pin the grep engine: on "auto" the tool's description (and with it the tool
67 # schemas, provider request, and cache prefix) changes depending on whether rg
68 # happens to be on PATH, which would make this golden machine-dependent.
69 engine = "native"
70
71 [[providers]]
72 name = "test-model"
73 kind = "openai"
74 base_url = "https://example.invalid"
75 model = "x"
76 api_key_env = "REASONIX_TEST_KEY_UNSET"
77 `)
78
79 ctrl, err := Build(context.Background(), Options{})
80 if err != nil {
81 t.Fatalf("Build: %v", err)
82 }
83 defer ctrl.Close()
84
85 // 1. System prompt, with the machine-specific workspace root normalized.
86 prompt := systemMessage(ctrl.History())
87 if strings.TrimSpace(prompt) == "" {
88 t.Fatal("Build composed an empty system prompt")
89 }
90 actualDir, err := os.Getwd()
91 if err != nil {
92 t.Fatalf("resolve fixture working directory: %v", err)
93 }
94 prompt = normalizeGoldenRoot(prompt, dir, actualDir)
95
96 // 2. Provider-visible tool contract, through the same canonical schema
97 // path the runtime registry uses.
98 entries := ctrl.ToolContractEntries()
99 if len(entries) == 0 {
100 t.Fatal("Build registered no tools")
101 }
102 toolJSON, err := json.MarshalIndent(entries, "", " ")
103 if err != nil {
104 t.Fatalf("marshal tool contract: %v", err)
105 }
106
107 // 3. Provider request serialization: a fixed synthetic conversation plus
108 // the live tool schemas, assembled the same way the agent loop assembles
109 // requests (ModelMessages + CreatedAt stripped before send).
110 schemas := make([]provider.ToolSchema, 0, len(entries))
111 for _, e := range entries {
112 schemas = append(schemas, provider.ToolSchema{
113 Name: e.Name,
114 Description: e.Description,
115 Parameters: e.Schema,
116 })
117 }
118 temp := 0.7
119 req := provider.Request{
120 Messages: provider.ModelMessages([]provider.Message{
121 {Role: provider.RoleSystem, Content: prompt},
122 {Role: provider.RoleUser, Content: "USER PROMPT", RawContent: "USER PROMPT", CreatedAt: 1700000000000},
123 {Role: provider.RoleAssistant, Content: "ASSISTANT REPLY", ReasoningContent: "THINKING", ReasoningSignature: "sig", ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "read", Arguments: `{"path":"a.txt"}`}}, CreatedAt: 1700000001000},
124 {Role: provider.RoleTool, Content: "TOOL RESULT", ToolCallID: "call_1", Name: "read", CreatedAt: 1700000002000},
125 {Role: provider.RoleUser, Content: "LOCAL ONLY", LocalOnly: true},
126 }),
127 Tools: schemas,
128 Temperature: &temp,
129 MaxTokens: 1024,
130 }
131 for i := range req.Messages {
132 req.Messages[i].CreatedAt = 0
133 }
134 reqJSON, err := json.MarshalIndent(req, "", " ")
135 if err != nil {
136 t.Fatalf("marshal provider request: %v", err)
137 }
138
139 return goldenBaseline{
140 SystemPrompt: prompt,
141 ToolSchemas: append(toolJSON, '\n'),
142 ProviderReq: append(reqJSON, '\n'),
143 PrefixShape: agent.CaptureShape(prompt, schemas, 0),
144 }
145 }
146
147 // normalizeGoldenRoot replaces every known spelling of the workspace root
148 // (including the actual cwd and symlink-evaluated forms) with a stable
149 // placeholder. Windows may report the cwd using an 8.3 short-path alias even
150 // when TempDir returned the long form, so both values are required.
151 func normalizeGoldenRoot(prompt string, roots ...string) string {
152 out := prompt
153 seen := make(map[string]struct{}, len(roots)*2)
154 replace := func(root string) {
155 if root == "" {
156 return
157 }
158 if _, ok := seen[root]; ok {
159 return
160 }
161 // System-prompt workspace paths are Go-quoted. On Windows that doubles
162 // backslashes, so replace the quoted spelling before the raw alias.
163 out = strings.ReplaceAll(out, strconv.Quote(root), strconv.Quote("<ROOT>"))
164 out = strings.ReplaceAll(out, root, "<ROOT>")
165 seen[root] = struct{}{}
166 }
167 for _, root := range roots {
168 replace(root)
169 if real, err := filepath.EvalSymlinks(root); err == nil && real != root {
170 replace(real)
171 }
172 }
173 return out
174 }
175
176 func TestNormalizeGoldenRootReplacesQuotedWindowsPath(t *testing.T) {
177 root := `C:\Users\RUNNER~1\AppData\Local\Temp\reasonix-test-123`
178 prompt := "Current workspace: " + strconv.Quote(root)
179 if got, want := normalizeGoldenRoot(prompt, root), `Current workspace: "<ROOT>"`; got != want {
180 t.Fatalf("normalizeGoldenRoot = %q, want %q", got, want)
181 }
182 }
183
184 func TestNormalizeGoldenRootReplacesEveryAlias(t *testing.T) {
185 prompt := "long=/tmp/reasonix-long short=/tmp/reasonix-short"
186 got := normalizeGoldenRoot(prompt, "/tmp/reasonix-long", "/tmp/reasonix-short")
187 if got != "long=<ROOT> short=<ROOT>" {
188 t.Fatalf("normalizeGoldenRoot = %q", got)
189 }
190 }
191
192 func TestGoldenBaselineNoExtensions(t *testing.T) {
193 // Resolve the golden directory before the fixture chdirs into a temp
194 // workspace, or reads/writes would land inside the fixture.
195 goldenDir, err := filepath.Abs(goldenBaselineDir)
196 if err != nil {
197 t.Fatalf("resolve golden dir: %v", err)
198 }
199
200 first := captureGoldenBaseline(t)
201
202 // In-run determinism: an identical second Build must capture the exact
203 // same surface before we bother comparing against the committed golden.
204 second := captureGoldenBaseline(t)
205 if first.SystemPrompt != second.SystemPrompt {
206 t.Fatalf("system prompt is not deterministic across identical Builds, first diff: %q", firstDivergence(first.SystemPrompt, second.SystemPrompt))
207 }
208 if string(first.ToolSchemas) != string(second.ToolSchemas) {
209 t.Fatal("tool schemas are not deterministic across identical Builds")
210 }
211 if string(first.ProviderReq) != string(second.ProviderReq) {
212 t.Fatal("provider request serialization is not deterministic across identical Builds")
213 }
214
215 shapeJSON, err := json.MarshalIndent(first.PrefixShape, "", " ")
216 if err != nil {
217 t.Fatalf("marshal prefix shape: %v", err)
218 }
219 shapeJSON = append(shapeJSON, '\n')
220
221 artifacts := map[string][]byte{
222 "system_prompt.txt": []byte(first.SystemPrompt),
223 "tool_schemas.json": first.ToolSchemas,
224 "provider_request.json": first.ProviderReq,
225 "prefix_shape.json": shapeJSON,
226 }
227
228 if os.Getenv("REASONIX_UPDATE_GOLDEN") == "1" {
229 if err := os.MkdirAll(goldenDir, 0o755); err != nil {
230 t.Fatalf("mkdir golden dir: %v", err)
231 }
232 for name, data := range artifacts {
233 if err := os.WriteFile(filepath.Join(goldenDir, name), data, 0o644); err != nil {
234 t.Fatalf("update golden %s: %v", name, err)
235 }
236 t.Logf("updated golden %s (%d bytes)", name, len(data))
237 }
238 return
239 }
240
241 for name, want := range artifacts {
242 got, err := os.ReadFile(filepath.Join(goldenDir, name))
243 if err != nil {
244 t.Fatalf("read golden %s: %v (record it with REASONIX_UPDATE_GOLDEN=1)", name, err)
245 }
246 if string(got) != string(want) {
247 t.Fatalf("golden %s drifted from the pre-refactor baseline (%d bytes golden, %d bytes actual); first diff: %q\n"+
248 "If this drift is deliberate, regenerate with REASONIX_UPDATE_GOLDEN=1 and document the cache impact.",
249 name, len(got), len(want), firstDivergence(string(got), string(want)))
250 }
251 }
252 }
253
254 // TestGoldenBaselineContractSanity cross-checks the golden tool contract
255 // against the compile-time builtin contract so the boot-level golden cannot
256 // silently drift away from the tool package's own committed contract.
257 func TestGoldenBaselineContractSanity(t *testing.T) {
258 builtins := tool.BuiltinContractEntries()
259 if len(builtins) == 0 {
260 t.Fatal("no builtin contract entries")
261 }
262 seen := make(map[string]bool, len(builtins))
263 for _, e := range builtins {
264 if seen[e.Name] {
265 t.Fatalf("duplicate builtin contract entry %q", e.Name)
266 }
267 seen[e.Name] = true
268 if len(e.Schema) == 0 {
269 t.Fatalf("builtin contract entry %q has an empty canonical schema", e.Name)
270 }
271 }
272 }
273
273 lines GO