返回 DeepSeek-Reasonix
adapters_test.go
根目录 / internal / extension / adapters_test.go
1 package extension
2
3 import (
4 "context"
5 "io"
6 "os"
7 "path/filepath"
8 "testing"
9
10 "reasonix/internal/command"
11 "reasonix/internal/hook"
12 "reasonix/internal/plugin"
13 "reasonix/internal/provider"
14 "reasonix/internal/skill"
15 "reasonix/internal/tool"
16
17 // Registers the compile-time built-ins the adapter wraps.
18 _ "reasonix/internal/tool/builtin"
19 )
20
21 // contribute is a small harness: run one contributor and return its
22 // contributions, failing the test on error.
23 func contribute(t *testing.T, c Contributor) []Contribution {
24 t.Helper()
25 out, err := c.Contribute(context.Background())
26 if err != nil {
27 t.Fatalf("%s.Contribute: %v", c.Name(), err)
28 }
29 return out
30 }
31
32 // TestBuiltinToolsContributor: every registered built-in becomes a KindTool
33 // at the builtin tier, and the whole set must pass kernel validation —
34 // built-ins violating the ID contract would be a real wiring bug.
35 func TestBuiltinToolsContributor(t *testing.T) {
36 contribs := contribute(t, BuiltinToolsContributor())
37 if len(contribs) == 0 {
38 t.Fatal("no built-in tools contributed — is internal/tool/builtin imported?")
39 }
40 if len(contribs) != len(tool.Builtins()) {
41 t.Fatalf("contributed %d tools, want %d", len(contribs), len(tool.Builtins()))
42 }
43 for _, ct := range contribs {
44 if ct.Kind != KindTool {
45 t.Fatalf("kind = %s, want tool", ct.Kind)
46 }
47 if ct.Source.Scope != ScopeBuiltin {
48 t.Fatalf("tool %s scope = %s, want builtin", ct.ID, ct.Source.Scope)
49 }
50 if _, ok := ct.Payload.(tool.Tool); !ok {
51 t.Fatalf("tool %s payload = %T, want tool.Tool", ct.ID, ct.Payload)
52 }
53 }
54 snap, _, err := NewBuilder().AddContributor(BuiltinToolsContributor()).Build(context.Background())
55 if err != nil {
56 t.Fatalf("Build with built-in tools failed: %v", err)
57 }
58 if len(snap.ToolSchemas()) != len(contribs) {
59 t.Fatalf("snapshot schemas = %d, want %d", len(snap.ToolSchemas()), len(contribs))
60 }
61 }
62
63 // writeSkill creates a <root>/<name>/SKILL.md fixture.
64 func writeSkill(t *testing.T, root, name, desc string) {
65 t.Helper()
66 dir := filepath.Join(root, name)
67 if err := os.MkdirAll(dir, 0o755); err != nil {
68 t.Fatal(err)
69 }
70 body := "---\ndescription: " + desc + "\n---\nbody of " + name + "\n"
71 if err := os.WriteFile(filepath.Join(dir, skill.SkillFile), []byte(body), 0o644); err != nil {
72 t.Fatal(err)
73 }
74 }
75
76 // TestSkillsContributor: project skills keep the project tier; plugin skills
77 // become ScopePlugin with the package as PluginID and a package-qualified
78 // slash ID — the same identity the user invokes.
79 func TestSkillsContributor(t *testing.T) {
80 projectRoot := t.TempDir()
81 home := t.TempDir()
82 pluginRoot := t.TempDir()
83 writeSkill(t, filepath.Join(projectRoot, ".reasonix", skill.SkillsDirname), "projskill", "Project skill")
84 writeSkill(t, pluginRoot, "plugskill", "Plugin skill")
85
86 store := skill.New(skill.Options{
87 HomeDir: home,
88 ProjectRoot: projectRoot,
89 CustomPaths: []string{pluginRoot},
90 PluginPaths: map[string][]string{pluginRoot: {"mypkg"}},
91 DisableBuiltins: true,
92 Stderr: io.Discard,
93 })
94 contribs := contribute(t, SkillsContributor(store))
95 if len(contribs) != 2 {
96 t.Fatalf("contributed %d skills, want 2: %+v", len(contribs), contribs)
97 }
98 byID := map[string]Contribution{}
99 for _, ct := range contribs {
100 if ct.Kind != KindSkill {
101 t.Fatalf("kind = %s, want skill", ct.Kind)
102 }
103 if _, ok := ct.Payload.(skill.Skill); !ok {
104 t.Fatalf("skill %s payload = %T, want skill.Skill", ct.ID, ct.Payload)
105 }
106 byID[ct.ID] = ct
107 }
108 proj, ok := byID["projskill"]
109 if !ok {
110 t.Fatalf("missing projskill contribution: %v", byID)
111 }
112 if proj.Source.Scope != ScopeProject || proj.Source.PluginID != "" {
113 t.Fatalf("projskill source = %+v, want project tier, no plugin", proj.Source)
114 }
115 plug, ok := byID["mypkg:plugskill"]
116 if !ok {
117 t.Fatalf("missing mypkg:plugskill contribution: %v", byID)
118 }
119 if plug.Source.Scope != ScopePlugin || plug.Source.PluginID != "mypkg" {
120 t.Fatalf("plugskill source = %+v, want plugin tier owned by mypkg", plug.Source)
121 }
122 }
123
124 // TestCommandsContributor: LoadRoots resolution runs first — the plugin
125 // command arrives under its qualified name, its unambiguous short alias is
126 // retained as a hidden compatibility entry, and plain commands map to the
127 // project tier.
128 func TestCommandsContributor(t *testing.T) {
129 userDir := t.TempDir()
130 pluginDir := t.TempDir()
131 if err := os.WriteFile(filepath.Join(userDir, "review.md"), []byte("---\ndescription: Review code\n---\nreview $ARGUMENTS"), 0o644); err != nil {
132 t.Fatal(err)
133 }
134 if err := os.WriteFile(filepath.Join(pluginDir, "commit.md"), []byte("---\ndescription: Commit\n---\ncommit $ARGUMENTS"), 0o644); err != nil {
135 t.Fatal(err)
136 }
137 contribs := contribute(t, CommandsContributor(
138 command.Root{Path: userDir},
139 command.Root{Path: pluginDir, Plugin: "pkg"},
140 ))
141 byID := map[string]Contribution{}
142 for _, ct := range contribs {
143 if ct.Kind != KindCommand {
144 t.Fatalf("kind = %s, want command", ct.Kind)
145 }
146 if _, ok := ct.Payload.(command.Command); !ok {
147 t.Fatalf("command %s payload = %T, want command.Command", ct.ID, ct.Payload)
148 }
149 byID[ct.ID] = ct
150 }
151 if len(byID) != 3 {
152 t.Fatalf("command IDs = %v, want review, pkg:commit, and the hidden commit alias", byID)
153 }
154 if byID["review"].Source.Scope != ScopeProject {
155 t.Fatalf("review scope = %s, want project", byID["review"].Source.Scope)
156 }
157 for _, id := range []string{"pkg:commit", "commit"} {
158 if byID[id].Source.Scope != ScopePlugin || byID[id].Source.PluginID != "pkg" {
159 t.Fatalf("%s source = %+v, want plugin tier owned by pkg", id, byID[id].Source)
160 }
161 }
162 }
163
164 // TestHooksContributor: hooks are additive, keyed "event#n" in load order,
165 // scoped by the settings file they came from.
166 func TestHooksContributor(t *testing.T) {
167 projectRoot := t.TempDir()
168 home := t.TempDir()
169 settingsDir := filepath.Join(projectRoot, hook.SettingsDirname)
170 if err := os.MkdirAll(settingsDir, 0o755); err != nil {
171 t.Fatal(err)
172 }
173 settings := `{"hooks": {"PreToolUse": [{"command": "echo pre"}], "SessionStart": [{"command": "echo a"}, {"command": "echo b"}]}}`
174 if err := os.WriteFile(filepath.Join(settingsDir, hook.SettingsFilename), []byte(settings), 0o644); err != nil {
175 t.Fatal(err)
176 }
177 if err := hook.ApproveProjectHooks(hook.LoadOptions{ProjectRoot: projectRoot, HomeDir: home}); err != nil {
178 t.Fatal(err)
179 }
180 contribs := contribute(t, HooksContributor(hook.LoadOptions{ProjectRoot: projectRoot, HomeDir: home}))
181 if len(contribs) != 3 {
182 t.Fatalf("contributed %d hooks, want 3: %+v", len(contribs), contribs)
183 }
184 ids := []string{}
185 for _, ct := range contribs {
186 if ct.Kind != KindHook {
187 t.Fatalf("kind = %s, want hook", ct.Kind)
188 }
189 if ct.Source.Scope != ScopeProject {
190 t.Fatalf("hook %s scope = %s, want project", ct.ID, ct.Source.Scope)
191 }
192 if _, ok := ct.Payload.(hook.ResolvedHook); !ok {
193 t.Fatalf("hook %s payload = %T, want hook.ResolvedHook", ct.ID, ct.Payload)
194 }
195 ids = append(ids, ct.ID)
196 }
197 want := []string{"PreToolUse#0", "SessionStart#0", "SessionStart#1"}
198 for i, id := range ids {
199 if id != want[i] {
200 t.Fatalf("hook IDs = %v, want %v", ids, want)
201 }
202 }
203 // Hooks of one event from two tiers must both survive a build.
204 snap, _, err := NewBuilder().AddContributor(
205 HooksContributor(hook.LoadOptions{ProjectRoot: projectRoot, HomeDir: home}),
206 ).Build(context.Background())
207 if err != nil {
208 t.Fatalf("Build with hooks failed: %v", err)
209 }
210 if got := snap.Catalog().ByKind(KindHook); len(got) != 3 {
211 t.Fatalf("effective hooks = %d, want all 3 (additive)", len(got))
212 }
213 }
214
215 // TestMCPServersContributor pins the provenance → tier mapping: plugin
216 // package → plugin tier, project/workspace config → project tier, user-level
217 // → global tier.
218 func TestMCPServersContributor(t *testing.T) {
219 contribs := contribute(t, MCPServersContributor(
220 plugin.Spec{Name: "fs", Package: "pkgA", Command: "fs-server"},
221 plugin.Spec{Name: "web", ConfigSource: "project_config", URL: "http://x"},
222 plugin.Spec{Name: "legacy", Command: "legacy-server"},
223 ))
224 if len(contribs) != 3 {
225 t.Fatalf("contributed %d servers, want 3", len(contribs))
226 }
227 byID := map[string]Contribution{}
228 for _, ct := range contribs {
229 if ct.Kind != KindMCPServer {
230 t.Fatalf("kind = %s, want mcp_server", ct.Kind)
231 }
232 if _, ok := ct.Payload.(plugin.Spec); !ok {
233 t.Fatalf("server %s payload = %T, want plugin.Spec", ct.ID, ct.Payload)
234 }
235 byID[ct.ID] = ct
236 }
237 if byID["fs"].Source.Scope != ScopePlugin || byID["fs"].Source.PluginID != "pkgA" {
238 t.Fatalf("fs source = %+v, want plugin tier owned by pkgA", byID["fs"].Source)
239 }
240 if byID["web"].Source.Scope != ScopeProject {
241 t.Fatalf("web source = %+v, want project tier", byID["web"].Source)
242 }
243 if byID["legacy"].Source.Scope != ScopeGlobal {
244 t.Fatalf("legacy source = %+v, want global tier", byID["legacy"].Source)
245 }
246 }
247
248 // TestProvidersContributor: descriptors become KindProvider keyed by ref at
249 // the builtin tier.
250 func TestProvidersContributor(t *testing.T) {
251 contribs := contribute(t, ProvidersContributor(
252 provider.Descriptor{Ref: "deepseek/deepseek-chat", DisplayName: "DeepSeek"},
253 provider.Descriptor{Ref: "openai/gpt-5"},
254 ))
255 if len(contribs) != 2 {
256 t.Fatalf("contributed %d providers, want 2", len(contribs))
257 }
258 for _, ct := range contribs {
259 if ct.Kind != KindProvider {
260 t.Fatalf("kind = %s, want provider", ct.Kind)
261 }
262 if ct.Source.Scope != ScopeBuiltin {
263 t.Fatalf("provider %s scope = %s, want builtin", ct.ID, ct.Source.Scope)
264 }
265 desc, ok := ct.Payload.(provider.Descriptor)
266 if !ok || desc.Ref != ct.ID {
267 t.Fatalf("provider %s payload = %+v, want matching Descriptor", ct.ID, ct.Payload)
268 }
269 }
270 }
271
272 // TestAdaptersAssembleTogether: the realistic end-to-end path — every
273 // adapter feeding one builder, producing a frozen snapshot whose schema order
274 // and hash are stable across rebuilds.
275 func TestAdaptersAssembleTogether(t *testing.T) {
276 projectRoot := t.TempDir()
277 home := t.TempDir()
278 writeSkill(t, filepath.Join(projectRoot, ".reasonix", skill.SkillsDirname), "projskill", "Project skill")
279 cmdDir := t.TempDir()
280 if err := os.WriteFile(filepath.Join(cmdDir, "review.md"), []byte("review body"), 0o644); err != nil {
281 t.Fatal(err)
282 }
283
284 build := func() *RuntimeSnapshot {
285 b := NewBuilder().WithSystemPrompt("sys").WithGeneration(1)
286 b.AddContributor(
287 BuiltinToolsContributor(),
288 SkillsContributor(skill.New(skill.Options{
289 HomeDir: home, ProjectRoot: projectRoot, DisableBuiltins: true, Stderr: io.Discard,
290 })),
291 CommandsContributor(command.Root{Path: cmdDir}),
292 HooksContributor(hook.LoadOptions{ProjectRoot: projectRoot, HomeDir: home}),
293 MCPServersContributor(plugin.Spec{Name: "fs", Package: "pkgA"}),
294 ProvidersContributor(provider.Descriptor{Ref: "deepseek/deepseek-chat"}),
295 )
296 snap, set, err := b.Build(context.Background())
297 if err != nil {
298 t.Fatalf("Build: %v", err)
299 }
300 if set.Generation() != 1 {
301 t.Fatalf("set generation = %d, want 1", set.Generation())
302 }
303 return snap
304 }
305 first, second := build(), build()
306 if first.CacheHash() != second.CacheHash() {
307 t.Fatal("identical discovery state produced different CacheHash")
308 }
309 if !first.Catalog().Frozen() {
310 t.Fatal("snapshot catalog is not frozen")
311 }
312 if len(first.ToolSchemas()) == 0 {
313 t.Fatal("no tool schemas in snapshot")
314 }
315 if got := first.Catalog().ByKind(KindSkill); len(got) != 1 || got[0].ID != "projskill" {
316 t.Fatalf("skills in snapshot = %v, want projskill", got)
317 }
318 if got := first.Catalog().ByKind(KindMCPServer); len(got) != 1 || got[0].ID != "fs" {
319 t.Fatalf("MCP servers in snapshot = %v, want fs", got)
320 }
321 }
322
322 lines GO