返回 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 "runtime"
9 "slices"
10 "strconv"
11 "strings"
12 "testing"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/provider"
16 "reasonix/internal/tool"
17 )
18
19 // Golden baseline for the cache-stable provider contract. Without a deliberate
20 // migration, the system prompt, tool schemas, provider request, and prefix hash
21 // remain byte-identical. Session-context tests cover dynamic data separately.
22 //
23 // The stable-prefix/session-context migration intentionally updates the system
24 // and prefix goldens once while leaving tool_schemas.json byte-identical. Future
25 // reviewed provider-visible changes must regenerate with:
26 //
27 // REASONIX_UPDATE_GOLDEN=1 go test ./internal/boot -run TestGoldenBaseline -count=1
28 // REASONIX_GOLDEN_SHELL=powershell REASONIX_UPDATE_GOLDEN=1 go test ./internal/boot -run TestGoldenBaseline -count=1
29 //
30 // and call the cache impact out in the commit message.
31 //
32 // Machine dependence is designed out of the golden: the environment probe
33 // section is disabled in the fixture config (it embeds GOOS/GOARCH, shell
34 // labels, and probe output; internal/environment covers it with its own
35 // snapshot tests), and the workspace root path is normalized to <ROOT>.
36 const goldenBaselineDir = "testdata/golden"
37
38 // goldenBaseline is one deterministic capture of the provider-visible
39 // runtime surface with zero extensions installed.
40 type goldenBaseline struct {
41 SystemPrompt string
42 ToolSchemas []byte
43 ProviderReq []byte
44 PrefixShape agent.PrefixShape
45 }
46
47 func captureGoldenBaseline(t *testing.T) goldenBaseline {
48 t.Helper()
49 isolateConfigHome(t)
50 dir := robustTempDir(t)
51 t.Chdir(dir)
52
53 fixture := `
54 default_model = "test-model"
55
56 [agent]
57 system_prompt = "BASE SYSTEM PROMPT"
58
59 [environment]
60 enabled = false
61
62 [tools.shell]
63 # This golden records the Bash contract; Windows auto selects PowerShell.
64 # Pin the dialect just as the search engine below is pinned.
65 prefer = "bash"
66
67 [tools.search]
68 # Pin the grep engine: on "auto" the tool's description (and with it the tool
69 # schemas, provider request, and cache prefix) changes depending on whether rg
70 # happens to be on PATH, which would make this golden machine-dependent.
71 engine = "native"
72
73 [[providers]]
74 name = "test-model"
75 kind = "openai"
76 base_url = "https://example.invalid"
77 model = "x"
78 api_key_env = "REASONIX_TEST_KEY_UNSET"
79 `
80 forcePowerShell := runtime.GOOS == "windows" || os.Getenv("REASONIX_GOLDEN_SHELL") == "powershell"
81 if forcePowerShell {
82 // Pin 5.1 independently of whether PowerShell 7 is installed.
83 fixture = strings.Replace(fixture, `prefer = "bash"`, `prefer = "powershell"`, 1)
84 if runtime.GOOS != "windows" {
85 // A configured PowerShell path is enough to compose the Windows tool
86 // contract; Build does not execute it. This lets POSIX CI guard the
87 // Windows provider snapshot without Wine.
88 fake := filepath.Join(dir, "powershell")
89 if err := os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
90 t.Fatalf("write fake PowerShell: %v", err)
91 }
92 fixture = strings.Replace(fixture, `prefer = "powershell"`, "prefer = \"powershell\"\npath = "+strconv.Quote(fake), 1)
93 }
94 }
95 writeFile(t, dir, "reasonix.toml", fixture)
96
97 ctrl, err := Build(context.Background(), Options{})
98 if err != nil {
99 t.Fatalf("Build: %v", err)
100 }
101 defer ctrl.Close()
102
103 // 1. System prompt, with the machine-specific workspace root normalized.
104 prompt := systemMessage(ctrl.History())
105 if strings.TrimSpace(prompt) == "" {
106 t.Fatal("Build composed an empty system prompt")
107 }
108 actualDir, err := os.Getwd()
109 if err != nil {
110 t.Fatalf("resolve fixture working directory: %v", err)
111 }
112 prompt = normalizeGoldenRoot(prompt, dir, actualDir)
113
114 // 2. Provider-visible tool contract, through the same canonical schema
115 // path the runtime registry uses.
116 entries := ctrl.ToolContractEntries()
117 if len(entries) == 0 {
118 t.Fatal("Build registered no tools")
119 }
120 toolJSON, err := json.MarshalIndent(entries, "", " ")
121 if err != nil {
122 t.Fatalf("marshal tool contract: %v", err)
123 }
124
125 // 3. Provider request serialization: a fixed synthetic conversation plus
126 // the live tool schemas, assembled the same way the agent loop assembles
127 // requests (ModelMessages + CreatedAt stripped before send).
128 schemas := make([]provider.ToolSchema, 0, len(entries))
129 for _, e := range entries {
130 schemas = append(schemas, provider.ToolSchema{
131 Name: e.Name,
132 Description: e.Description,
133 Parameters: e.Schema,
134 })
135 }
136 temp := 0.7
137 req := provider.Request{
138 Messages: provider.ModelMessages([]provider.Message{
139 {Role: provider.RoleSystem, Content: prompt},
140 {Role: provider.RoleUser, Content: "USER PROMPT", RawContent: "USER PROMPT", CreatedAt: 1700000000000},
141 {Role: provider.RoleAssistant, Content: "ASSISTANT REPLY", ReasoningContent: "THINKING", ReasoningSignature: "sig", ToolCalls: []provider.ToolCall{{ID: "call_1", Name: "read", Arguments: `{"path":"a.txt"}`}}, CreatedAt: 1700000001000},
142 {Role: provider.RoleTool, Content: "TOOL RESULT", ToolCallID: "call_1", Name: "read", CreatedAt: 1700000002000},
143 {Role: provider.RoleUser, Content: "LOCAL ONLY", LocalOnly: true},
144 }),
145 Tools: schemas,
146 Temperature: &temp,
147 MaxTokens: 1024,
148 }
149 for i := range req.Messages {
150 req.Messages[i].CreatedAt = 0
151 }
152 reqJSON, err := json.MarshalIndent(req, "", " ")
153 if err != nil {
154 t.Fatalf("marshal provider request: %v", err)
155 }
156
157 return goldenBaseline{
158 SystemPrompt: prompt,
159 ToolSchemas: append(toolJSON, '\n'),
160 ProviderReq: append(reqJSON, '\n'),
161 PrefixShape: agent.CaptureShape(prompt, schemas, 0),
162 }
163 }
164
165 // normalizeGoldenRoot replaces every known spelling of the workspace root
166 // (including the actual cwd and symlink-evaluated forms) with a stable
167 // placeholder. Windows may report the cwd using an 8.3 short-path alias even
168 // when TempDir returned the long form, so both values are required.
169 func normalizeGoldenRoot(prompt string, roots ...string) string {
170 out := prompt
171 seen := make(map[string]struct{}, len(roots)*2)
172 replace := func(root string) {
173 if root == "" {
174 return
175 }
176 if _, ok := seen[root]; ok {
177 return
178 }
179 // System-prompt workspace paths are Go-quoted. On Windows that doubles
180 // backslashes, so replace the quoted spelling before the raw alias.
181 out = strings.ReplaceAll(out, strconv.Quote(root), strconv.Quote("<ROOT>"))
182 out = strings.ReplaceAll(out, root, "<ROOT>")
183 seen[root] = struct{}{}
184 }
185 for _, root := range roots {
186 replace(root)
187 if real, err := filepath.EvalSymlinks(root); err == nil && real != root {
188 replace(real)
189 }
190 }
191 return out
192 }
193
194 func TestNormalizeGoldenRootReplacesQuotedWindowsPath(t *testing.T) {
195 root := `C:\Users\RUNNER~1\AppData\Local\Temp\reasonix-test-123`
196 prompt := "Current workspace: " + strconv.Quote(root)
197 if got, want := normalizeGoldenRoot(prompt, root), `Current workspace: "<ROOT>"`; got != want {
198 t.Fatalf("normalizeGoldenRoot = %q, want %q", got, want)
199 }
200 }
201
202 func TestNormalizeGoldenRootReplacesEveryAlias(t *testing.T) {
203 prompt := "long=/tmp/reasonix-long short=/tmp/reasonix-short"
204 got := normalizeGoldenRoot(prompt, "/tmp/reasonix-long", "/tmp/reasonix-short")
205 if got != "long=<ROOT> short=<ROOT>" {
206 t.Fatalf("normalizeGoldenRoot = %q", got)
207 }
208 }
209
210 func TestGoldenBaselineNoExtensions(t *testing.T) {
211 // Resolve the golden directory before the fixture chdirs into a temp
212 // workspace, or reads/writes would land inside the fixture.
213 goldenDir, err := filepath.Abs(goldenBaselineDir)
214 if err != nil {
215 t.Fatalf("resolve golden dir: %v", err)
216 }
217 if runtime.GOOS == "windows" || os.Getenv("REASONIX_GOLDEN_SHELL") == "powershell" {
218 goldenDir = filepath.Join(goldenDir, "windows-powershell")
219 }
220
221 first := captureGoldenBaseline(t)
222
223 // In-run determinism: an identical second Build must capture the exact
224 // same surface before we bother comparing against the committed golden.
225 second := captureGoldenBaseline(t)
226 if first.SystemPrompt != second.SystemPrompt {
227 t.Fatalf("system prompt is not deterministic across identical Builds, first diff: %q", firstDivergence(first.SystemPrompt, second.SystemPrompt))
228 }
229 if string(first.ToolSchemas) != string(second.ToolSchemas) {
230 t.Fatal("tool schemas are not deterministic across identical Builds")
231 }
232 if string(first.ProviderReq) != string(second.ProviderReq) {
233 t.Fatal("provider request serialization is not deterministic across identical Builds")
234 }
235
236 shapeJSON, err := json.MarshalIndent(first.PrefixShape, "", " ")
237 if err != nil {
238 t.Fatalf("marshal prefix shape: %v", err)
239 }
240 shapeJSON = append(shapeJSON, '\n')
241
242 artifacts := map[string][]byte{
243 "system_prompt.txt": []byte(first.SystemPrompt),
244 "tool_schemas.json": first.ToolSchemas,
245 "provider_request.json": first.ProviderReq,
246 "prefix_shape.json": shapeJSON,
247 }
248
249 if os.Getenv("REASONIX_UPDATE_GOLDEN") == "1" {
250 if err := os.MkdirAll(goldenDir, 0o755); err != nil {
251 t.Fatalf("mkdir golden dir: %v", err)
252 }
253 for name, data := range artifacts {
254 if err := os.WriteFile(filepath.Join(goldenDir, name), data, 0o644); err != nil {
255 t.Fatalf("update golden %s: %v", name, err)
256 }
257 t.Logf("updated golden %s (%d bytes)", name, len(data))
258 }
259 return
260 }
261
262 for name, want := range artifacts {
263 got, err := os.ReadFile(filepath.Join(goldenDir, name))
264 if err != nil {
265 t.Fatalf("read golden %s: %v (record it with REASONIX_UPDATE_GOLDEN=1)", name, err)
266 }
267 if string(got) != string(want) {
268 t.Fatalf("golden %s drifted from the committed cache-contract baseline (%d bytes golden, %d bytes actual); first diff: %q\n"+
269 "If this drift is deliberate, regenerate with REASONIX_UPDATE_GOLDEN=1 and document the cache impact.",
270 name, len(got), len(want), firstDivergence(string(got), string(want)))
271 }
272 }
273 }
274
275 func TestWindowsProviderSurfaceUsesPwshAndFormalJobsOnly(t *testing.T) {
276 if runtime.GOOS != "windows" {
277 t.Setenv("REASONIX_GOLDEN_SHELL", "powershell")
278 }
279 baseline := captureGoldenBaseline(t)
280 var entries []tool.ContractEntry
281 if err := json.Unmarshal(baseline.ToolSchemas, &entries); err != nil {
282 t.Fatal(err)
283 }
284 byName := make(map[string]tool.ContractEntry, len(entries))
285 for _, entry := range entries {
286 byName[entry.Name] = entry
287 }
288 for _, want := range []string{"pwsh", "job_output", "job_kill"} {
289 if _, ok := byName[want]; !ok {
290 t.Fatalf("Windows provider surface missing %q: %v", want, byName)
291 }
292 }
293 for _, hidden := range []string{"bash", "Bash", "PowerShell", "powershell", "bash_output", "wait", "kill_shell"} {
294 if _, ok := byName[hidden]; ok {
295 t.Fatalf("compatibility alias %q leaked into Windows provider schema", hidden)
296 }
297 }
298 var schema struct {
299 Required []string `json:"required"`
300 Properties map[string]json.RawMessage `json:"properties"`
301 }
302 if err := json.Unmarshal(byName["pwsh"].Schema, &schema); err != nil {
303 t.Fatal(err)
304 }
305 if !slices.Contains(schema.Required, "command") || !slices.Contains(schema.Required, "description") {
306 t.Fatalf("pwsh required fields = %v", schema.Required)
307 }
308 if _, ok := schema.Properties["preserve_background_processes"]; ok {
309 t.Fatal("pwsh schema exposed preserve_background_processes")
310 }
311 }
312
313 // TestGoldenBaselineContractSanity cross-checks the golden tool contract
314 // against the compile-time builtin contract so the boot-level golden cannot
315 // silently drift away from the tool package's own committed contract.
316 func TestGoldenBaselineContractSanity(t *testing.T) {
317 builtins := tool.BuiltinContractEntries()
318 if len(builtins) == 0 {
319 t.Fatal("no builtin contract entries")
320 }
321 seen := make(map[string]bool, len(builtins))
322 for _, e := range builtins {
323 if seen[e.Name] {
324 t.Fatalf("duplicate builtin contract entry %q", e.Name)
325 }
326 seen[e.Name] = true
327 if len(e.Schema) == 0 {
328 t.Fatalf("builtin contract entry %q has an empty canonical schema", e.Name)
329 }
330 }
331 }
332
332 lines GO