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