| 1 | package extension |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "math/rand" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/internal/provider" |
| 12 | ) |
| 13 | |
| 14 | // staticContributor returns a contributor with a fixed name and contribution |
| 15 | // list — the test stand-in for a real discovery adapter. |
| 16 | func staticContributor(name string, contribs ...Contribution) Contributor { |
| 17 | return ContributorFunc{ |
| 18 | ContributorName: name, |
| 19 | Fn: func(context.Context) ([]Contribution, error) { return contribs, nil }, |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | // determinismContributors builds a mixed set of contributors: cross-tier |
| 24 | // tool shadowing, skills, commands, additive hooks, and interceptors with |
| 25 | // overlapping priorities and plugin IDs. |
| 26 | func determinismContributors() []Contributor { |
| 27 | return []Contributor{ |
| 28 | staticContributor("tools-builtin", |
| 29 | Contribution{Kind: KindTool, ID: "read_file", Source: src(ScopeBuiltin, "", "builtin"), Payload: schemaPayload("read_file", "builtin read")}, |
| 30 | Contribution{Kind: KindTool, ID: "write_file", Source: src(ScopeBuiltin, "", "builtin"), Payload: schemaPayload("write_file", "builtin write")}, |
| 31 | ), |
| 32 | staticContributor("tools-project", |
| 33 | // Project tier shadows the builtin read_file. |
| 34 | Contribution{Kind: KindTool, ID: "read_file", Source: src(ScopeProject, "", "project"), Payload: schemaPayload("read_file", "project read")}, |
| 35 | Contribution{Kind: KindTool, ID: "grep", Source: src(ScopeProject, "", "project"), Payload: schemaPayload("grep", "project grep")}, |
| 36 | ), |
| 37 | staticContributor("skills", |
| 38 | Contribution{Kind: KindSkill, ID: "review", Source: src(ScopeProject, "", "project"), Payload: "review body"}, |
| 39 | Contribution{Kind: KindSkill, ID: "lint", Source: src(ScopeGlobal, "", "user"), Payload: "lint body"}, |
| 40 | ), |
| 41 | staticContributor("commands", |
| 42 | Contribution{Kind: KindCommand, ID: "deploy", Source: src(ScopeProject, "", "project"), Payload: "deploy body"}, |
| 43 | ), |
| 44 | staticContributor("hooks", |
| 45 | Contribution{Kind: KindHook, ID: "PreToolUse#0", Source: src(ScopeProject, "", "project"), Payload: "hook-a"}, |
| 46 | Contribution{Kind: KindHook, ID: "PreToolUse#1", Source: src(ScopeGlobal, "", "global"), Payload: "hook-b"}, |
| 47 | ), |
| 48 | staticContributor("interceptors-a", |
| 49 | Contribution{Kind: KindInterceptor, ID: string(PointToolBefore), Priority: 10, Source: src(ScopePlugin, "plug-b", "plugin"), Payload: "i1"}, |
| 50 | Contribution{Kind: KindInterceptor, ID: string(PointToolBefore), Priority: -5, Source: src(ScopePlugin, "plug-a", "plugin"), Payload: "i2"}, |
| 51 | Contribution{Kind: KindInterceptor, ID: string(PointProviderRequest), Priority: 0, Source: src(ScopeProject, "", "project"), Payload: "i3"}, |
| 52 | ), |
| 53 | staticContributor("interceptors-b", |
| 54 | // Same priority and plugin as one above: per-contributor order |
| 55 | // breaks the tie, so the chain must stay stable across |
| 56 | // contributor permutations. |
| 57 | Contribution{Kind: KindInterceptor, ID: string(PointToolBefore), Priority: -5, Source: src(ScopePlugin, "plug-a", "plugin"), Payload: "i4"}, |
| 58 | ), |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | // TestBuildDeterminism permutes contributor registration order 100 times and |
| 63 | // requires byte-identical snapshots. Registration order is caller-controlled |
| 64 | // and arbitrary; the snapshot may only depend on contribution data. |
| 65 | func TestBuildDeterminism(t *testing.T) { |
| 66 | contributors := determinismContributors() |
| 67 | type fingerprint struct { |
| 68 | schemas []byte |
| 69 | chains []byte |
| 70 | catalog []byte |
| 71 | hash string |
| 72 | } |
| 73 | var reference *fingerprint |
| 74 | for seed := int64(0); seed < 100; seed++ { |
| 75 | r := rand.New(rand.NewSource(seed)) |
| 76 | perm := r.Perm(len(contributors)) |
| 77 | b := NewBuilder().WithSystemPrompt("system prompt v1").WithGeneration(7) |
| 78 | for _, idx := range perm { |
| 79 | b.AddContributor(contributors[idx]) |
| 80 | } |
| 81 | snap, _, err := b.Build(context.Background()) |
| 82 | if err != nil { |
| 83 | t.Fatalf("seed %d: Build failed: %v", seed, err) |
| 84 | } |
| 85 | schemasJSON, err := json.Marshal(snap.ToolSchemas()) |
| 86 | if err != nil { |
| 87 | t.Fatalf("seed %d: marshal schemas: %v", seed, err) |
| 88 | } |
| 89 | chainsJSON, err := json.Marshal(snap.InterceptorChain()) |
| 90 | if err != nil { |
| 91 | t.Fatalf("seed %d: marshal chains: %v", seed, err) |
| 92 | } |
| 93 | catalogJSON, err := json.Marshal(snap.Catalog().All()) |
| 94 | if err != nil { |
| 95 | t.Fatalf("seed %d: marshal catalog: %v", seed, err) |
| 96 | } |
| 97 | got := fingerprint{schemas: schemasJSON, chains: chainsJSON, catalog: catalogJSON, hash: snap.CacheHash()} |
| 98 | if reference == nil { |
| 99 | reference = &got |
| 100 | continue |
| 101 | } |
| 102 | if string(got.schemas) != string(reference.schemas) { |
| 103 | t.Fatalf("seed %d: ToolSchemas order diverged:\n%s\nvs\n%s", seed, got.schemas, reference.schemas) |
| 104 | } |
| 105 | if string(got.chains) != string(reference.chains) { |
| 106 | t.Fatalf("seed %d: InterceptorChain order diverged:\n%s\nvs\n%s", seed, got.chains, reference.chains) |
| 107 | } |
| 108 | if string(got.catalog) != string(reference.catalog) { |
| 109 | t.Fatalf("seed %d: catalog order diverged", seed) |
| 110 | } |
| 111 | if got.hash != reference.hash { |
| 112 | t.Fatalf("seed %d: CacheHash diverged: %s vs %s", seed, got.hash, reference.hash) |
| 113 | } |
| 114 | } |
| 115 | // The cross-tier shadow must resolve to the project tool regardless of |
| 116 | // ordering — check the reference fingerprint content, not just equality. |
| 117 | b := NewBuilder().WithSystemPrompt("system prompt v1") |
| 118 | b.AddContributor(contributors...) |
| 119 | snap, _, err := b.Build(context.Background()) |
| 120 | if err != nil { |
| 121 | t.Fatalf("reference build: %v", err) |
| 122 | } |
| 123 | for _, s := range snap.ToolSchemas() { |
| 124 | if s.Name == "read_file" && s.Description != "project read" { |
| 125 | t.Fatalf("read_file winner = %q, want project-tier schema", s.Description) |
| 126 | } |
| 127 | } |
| 128 | // Tool schemas must be sorted by name. |
| 129 | names := []string{} |
| 130 | for _, s := range snap.ToolSchemas() { |
| 131 | names = append(names, s.Name) |
| 132 | } |
| 133 | for i := 1; i < len(names); i++ { |
| 134 | if names[i-1] >= names[i] { |
| 135 | t.Fatalf("ToolSchemas not sorted: %v", names) |
| 136 | } |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // TestConflictCommandSameTier: two plugins offering the same command ID is a |
| 141 | // hard failure naming both, not a silent last-writer-wins. |
| 142 | func TestConflictCommandSameTier(t *testing.T) { |
| 143 | b := NewBuilder() |
| 144 | b.AddContributor( |
| 145 | staticContributor("a", Contribution{Kind: KindCommand, ID: "deploy", Source: src(ScopePlugin, "pa", "plugin"), Payload: "a"}), |
| 146 | staticContributor("b", Contribution{Kind: KindCommand, ID: "deploy", Source: src(ScopePlugin, "pb", "plugin"), Payload: "b"}), |
| 147 | ) |
| 148 | _, _, err := b.Build(context.Background()) |
| 149 | if err == nil { |
| 150 | t.Fatal("Build succeeded, want ConflictError") |
| 151 | } |
| 152 | var conflict *ConflictError |
| 153 | if !errors.As(err, &conflict) { |
| 154 | t.Fatalf("error %v is not a *ConflictError", err) |
| 155 | } |
| 156 | if conflict.Kind != KindCommand || conflict.ID != "deploy" { |
| 157 | t.Fatalf("conflict = (%s, %s), want (command, deploy)", conflict.Kind, conflict.ID) |
| 158 | } |
| 159 | if !strings.Contains(err.Error(), "pa") || !strings.Contains(err.Error(), "pb") { |
| 160 | t.Fatalf("conflict error must name both plugins, got: %v", err) |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | // TestConflictProviderSameTier pins the same rule for provider refs. |
| 165 | func TestConflictProviderSameTier(t *testing.T) { |
| 166 | b := NewBuilder() |
| 167 | b.AddContributor( |
| 168 | staticContributor("a", Contribution{Kind: KindProvider, ID: "openai/gpt-5", Source: src(ScopePlugin, "pa", "plugin"), Payload: provider.Descriptor{Ref: "openai/gpt-5"}}), |
| 169 | staticContributor("b", Contribution{Kind: KindProvider, ID: "openai/gpt-5", Source: src(ScopePlugin, "pb", "plugin"), Payload: provider.Descriptor{Ref: "openai/gpt-5"}}), |
| 170 | ) |
| 171 | _, _, err := b.Build(context.Background()) |
| 172 | var conflict *ConflictError |
| 173 | if !errors.As(err, &conflict) { |
| 174 | t.Fatalf("Build error = %v, want *ConflictError", err) |
| 175 | } |
| 176 | if conflict.Kind != KindProvider || conflict.ID != "openai/gpt-5" { |
| 177 | t.Fatalf("conflict = (%s, %s), want (provider, openai/gpt-5)", conflict.Kind, conflict.ID) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | // TestConflictMCPServerSameTier pins the same rule for MCP server names. |
| 182 | func TestConflictMCPServerSameTier(t *testing.T) { |
| 183 | b := NewBuilder() |
| 184 | b.AddContributor( |
| 185 | staticContributor("a", Contribution{Kind: KindMCPServer, ID: "fs", Source: src(ScopePlugin, "pa", "plugin"), Payload: "spec-a"}), |
| 186 | staticContributor("b", Contribution{Kind: KindMCPServer, ID: "fs", Source: src(ScopePlugin, "pb", "plugin"), Payload: "spec-b"}), |
| 187 | ) |
| 188 | _, _, err := b.Build(context.Background()) |
| 189 | var conflict *ConflictError |
| 190 | if !errors.As(err, &conflict) { |
| 191 | t.Fatalf("Build error = %v, want *ConflictError", err) |
| 192 | } |
| 193 | if conflict.Kind != KindMCPServer || conflict.ID != "fs" { |
| 194 | t.Fatalf("conflict = (%s, %s), want (mcp_server, fs)", conflict.Kind, conflict.ID) |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | // TestCrossTierShadows: the same canonical ID at different tiers is ordinary |
| 199 | // shadowing — higher tier wins, no error, and the loser is gone from the |
| 200 | // effective catalog. |
| 201 | func TestCrossTierShadows(t *testing.T) { |
| 202 | b := NewBuilder() |
| 203 | b.AddContributor( |
| 204 | staticContributor("plugin", Contribution{Kind: KindCommand, ID: "deploy", Source: src(ScopePlugin, "pa", "plugin"), Payload: "from-plugin"}), |
| 205 | staticContributor("project", Contribution{Kind: KindCommand, ID: "deploy", Source: src(ScopeProject, "", "project"), Payload: "from-project"}), |
| 206 | ) |
| 207 | snap, _, err := b.Build(context.Background()) |
| 208 | if err != nil { |
| 209 | t.Fatalf("Build failed: %v", err) |
| 210 | } |
| 211 | winners := snap.Catalog().Get(KindCommand, "deploy") |
| 212 | if len(winners) != 1 { |
| 213 | t.Fatalf("effective catalog holds %d deploy commands, want 1 winner", len(winners)) |
| 214 | } |
| 215 | if winners[0].Payload != "from-project" { |
| 216 | t.Fatalf("winner payload = %v, want the project-tier contribution", winners[0].Payload) |
| 217 | } |
| 218 | if winners[0].Source.Scope != ScopeProject { |
| 219 | t.Fatalf("winner scope = %s, want project", winners[0].Source.Scope) |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | // TestBuildValidationErrors exercises the per-kind ID shape checks: every |
| 224 | // malformed contribution must be rejected before resolution. |
| 225 | func TestBuildValidationErrors(t *testing.T) { |
| 226 | cases := []struct { |
| 227 | name string |
| 228 | contrib Contribution |
| 229 | want string |
| 230 | }{ |
| 231 | {"empty id", Contribution{Kind: KindTool, Source: src(ScopeBuiltin, "", "builtin"), Payload: schemaPayload("x", "x")}, "empty ID"}, |
| 232 | {"unknown kind", Contribution{Kind: "wat", ID: "x", Source: src(ScopeBuiltin, "", "builtin")}, "unknown kind"}, |
| 233 | {"uppercase tool", Contribution{Kind: KindTool, ID: "ReadFile", Source: src(ScopeBuiltin, "", "builtin"), Payload: schemaPayload("ReadFile", "x")}, "lowercase"}, |
| 234 | {"malformed mcp id", Contribution{Kind: KindTool, ID: "mcp__bad", Source: src(ScopeBuiltin, "", "builtin"), Payload: schemaPayload("mcp__bad", "x")}, "mcp__<server>__<tool>"}, |
| 235 | {"mcp payload without prefix", Contribution{Kind: KindTool, ID: "plain", Source: src(ScopeBuiltin, "", "builtin"), Payload: fakeMCPTool{name: "plain"}}, "must start with mcp__"}, |
| 236 | {"bad tool payload", Contribution{Kind: KindTool, ID: "plain", Source: src(ScopeBuiltin, "", "builtin"), Payload: 42}, "payload"}, |
| 237 | {"bad provider ref", Contribution{Kind: KindProvider, ID: "openai", Source: src(ScopeBuiltin, "", "builtin"), Payload: provider.Descriptor{Ref: "openai"}}, "<name>/<model>"}, |
| 238 | {"unknown scope", Contribution{Kind: KindSkill, ID: "s", Source: ContributionSource{Scope: "moon", Origin: "x"}}, "unknown scope"}, |
| 239 | {"whitespace id", Contribution{Kind: KindSkill, ID: "a b", Source: src(ScopeGlobal, "", "user")}, "whitespace"}, |
| 240 | {"unknown point", Contribution{Kind: KindInterceptor, ID: "tool.middle", Source: src(ScopePlugin, "p", "plugin")}, "unknown interceptor point"}, |
| 241 | {"priority out of range", Contribution{Kind: KindInterceptor, ID: string(PointToolAfter), Priority: 5000, Source: src(ScopePlugin, "p", "plugin")}, "out of range"}, |
| 242 | } |
| 243 | for _, tc := range cases { |
| 244 | t.Run(tc.name, func(t *testing.T) { |
| 245 | b := NewBuilder() |
| 246 | b.AddContributor(staticContributor("bad", tc.contrib)) |
| 247 | _, _, err := b.Build(context.Background()) |
| 248 | if err == nil { |
| 249 | t.Fatalf("Build succeeded, want validation error containing %q", tc.want) |
| 250 | } |
| 251 | var verr *ValidationError |
| 252 | if !errors.As(err, &verr) { |
| 253 | t.Fatalf("error %v is not a *ValidationError", err) |
| 254 | } |
| 255 | if !strings.Contains(err.Error(), tc.want) { |
| 256 | t.Fatalf("error %q does not contain %q", err.Error(), tc.want) |
| 257 | } |
| 258 | }) |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | // TestContributorErrorPropagates: a failing discovery source must fail the |
| 263 | // build — a half-built snapshot is worse than none. |
| 264 | func TestContributorErrorPropagates(t *testing.T) { |
| 265 | boom := ContributorFunc{ |
| 266 | ContributorName: "boom", |
| 267 | Fn: func(context.Context) ([]Contribution, error) { return nil, errors.New("disk exploded") }, |
| 268 | } |
| 269 | b := NewBuilder() |
| 270 | b.AddContributor(boom) |
| 271 | _, _, err := b.Build(context.Background()) |
| 272 | if err == nil || !strings.Contains(err.Error(), "boom") || !strings.Contains(err.Error(), "disk exploded") { |
| 273 | t.Fatalf("Build error = %v, want contributor name + cause", err) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | // TestActivatorSeam: the default activator binds an empty set to the snapshot |
| 278 | // generation; a custom activator observes the frozen snapshot. |
| 279 | func TestActivatorSeam(t *testing.T) { |
| 280 | snap, set, err := NewBuilder().WithGeneration(42).Build(context.Background()) |
| 281 | if err != nil { |
| 282 | t.Fatalf("Build: %v", err) |
| 283 | } |
| 284 | if set.Generation() != snap.Generation() || set.Generation() != 42 { |
| 285 | t.Fatalf("set generation = %d, want 42", set.Generation()) |
| 286 | } |
| 287 | if set.Len() != 0 { |
| 288 | t.Fatalf("default set holds %d closers, want 0", set.Len()) |
| 289 | } |
| 290 | |
| 291 | var observed *RuntimeSnapshot |
| 292 | custom := NewBuilder().WithGeneration(9).WithActivator(func(_ context.Context, s *RuntimeSnapshot) (*RuntimeSet, error) { |
| 293 | observed = s |
| 294 | return nil, nil // nil set must become an empty set, not a nil dereference |
| 295 | }) |
| 296 | snap2, set2, err := custom.Build(context.Background()) |
| 297 | if err != nil { |
| 298 | t.Fatalf("custom Build: %v", err) |
| 299 | } |
| 300 | if observed != snap2 { |
| 301 | t.Fatal("activator did not receive the built snapshot") |
| 302 | } |
| 303 | if set2 == nil || set2.Generation() != 9 { |
| 304 | t.Fatalf("nil activator result handled wrongly: %+v", set2) |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | // fakeMCPTool is an MCP-backed tool payload: it must be namespaced under |
| 309 | // mcp__ or validation rejects it. |
| 310 | type fakeMCPTool struct{ name string } |
| 311 | |
| 312 | func (f fakeMCPTool) Name() string { return f.name } |
| 313 | func (f fakeMCPTool) Description() string { return "fake mcp tool" } |
| 314 | func (f fakeMCPTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) } |
| 315 | func (f fakeMCPTool) ReadOnly() bool { return true } |
| 316 | func (f fakeMCPTool) MCPServerName() string { return "srv" } |
| 317 | func (f fakeMCPTool) MCPRawToolName() string { return "raw" } |
| 318 | func (f fakeMCPTool) Execute(context.Context, json.RawMessage) (string, error) { |
| 319 | return "", nil |
| 320 | } |
| 321 | |
| 322 | // TestMCPToolNamespacedAccepted: the same MCP payload passes once its ID |
| 323 | // carries the required namespace. |
| 324 | func TestMCPToolNamespacedAccepted(t *testing.T) { |
| 325 | b := NewBuilder() |
| 326 | b.AddContributor(staticContributor("mcp", |
| 327 | Contribution{Kind: KindTool, ID: "mcp__srv__raw", Source: src(ScopePlugin, "p", "plugin"), Payload: fakeMCPTool{name: "mcp__srv__raw"}}), |
| 328 | ) |
| 329 | snap, _, err := b.Build(context.Background()) |
| 330 | if err != nil { |
| 331 | t.Fatalf("Build failed: %v", err) |
| 332 | } |
| 333 | if len(snap.ToolSchemas()) != 1 || snap.ToolSchemas()[0].Name != "mcp__srv__raw" { |
| 334 | t.Fatalf("schemas = %+v, want the namespaced MCP tool", snap.ToolSchemas()) |
| 335 | } |
| 336 | } |
| 337 |