| 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 |