返回 DeepSeek-Reasonix
builder_test.go
根目录 / internal / extension / builder_test.go
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
337 lines GO