返回 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 contribs := contribute(t, HooksContributor(hook.LoadOptions{ProjectRoot: projectRoot, HomeDir: home}))
178 if len(contribs) != 3 {
179 t.Fatalf("contributed %d hooks, want 3: %+v", len(contribs), contribs)
180 }
181 ids := []string{}
182 for _, ct := range contribs {
183 if ct.Kind != KindHook {
184 t.Fatalf("kind = %s, want hook", ct.Kind)
185 }
186 if ct.Source.Scope != ScopeProject {
187 t.Fatalf("hook %s scope = %s, want project", ct.ID, ct.Source.Scope)
188 }
189 if _, ok := ct.Payload.(hook.ResolvedHook); !ok {
190 t.Fatalf("hook %s payload = %T, want hook.ResolvedHook", ct.ID, ct.Payload)
191 }
192 ids = append(ids, ct.ID)
193 }
194 want := []string{"PreToolUse#0", "SessionStart#0", "SessionStart#1"}
195 for i, id := range ids {
196 if id != want[i] {
197 t.Fatalf("hook IDs = %v, want %v", ids, want)
198 }
199 }
200 // Hooks of one event from two tiers must both survive a build.
201 snap, _, err := NewBuilder().AddContributor(
202 HooksContributor(hook.LoadOptions{ProjectRoot: projectRoot, HomeDir: home}),
203 ).Build(context.Background())
204 if err != nil {
205 t.Fatalf("Build with hooks failed: %v", err)
206 }
207 if got := snap.Catalog().ByKind(KindHook); len(got) != 3 {
208 t.Fatalf("effective hooks = %d, want all 3 (additive)", len(got))
209 }
210 }
211
212 // TestMCPServersContributor pins the provenance → tier mapping: plugin
213 // package → plugin tier, project/workspace config → project tier, user-level
214 // → global tier.
215 func TestMCPServersContributor(t *testing.T) {
216 contribs := contribute(t, MCPServersContributor(
217 plugin.Spec{Name: "fs", Package: "pkgA", Command: "fs-server"},
218 plugin.Spec{Name: "web", ConfigSource: "project_config", URL: "http://x"},
219 plugin.Spec{Name: "legacy", Command: "legacy-server"},
220 ))
221 if len(contribs) != 3 {
222 t.Fatalf("contributed %d servers, want 3", len(contribs))
223 }
224 byID := map[string]Contribution{}
225 for _, ct := range contribs {
226 if ct.Kind != KindMCPServer {
227 t.Fatalf("kind = %s, want mcp_server", ct.Kind)
228 }
229 if _, ok := ct.Payload.(plugin.Spec); !ok {
230 t.Fatalf("server %s payload = %T, want plugin.Spec", ct.ID, ct.Payload)
231 }
232 byID[ct.ID] = ct
233 }
234 if byID["fs"].Source.Scope != ScopePlugin || byID["fs"].Source.PluginID != "pkgA" {
235 t.Fatalf("fs source = %+v, want plugin tier owned by pkgA", byID["fs"].Source)
236 }
237 if byID["web"].Source.Scope != ScopeProject {
238 t.Fatalf("web source = %+v, want project tier", byID["web"].Source)
239 }
240 if byID["legacy"].Source.Scope != ScopeGlobal {
241 t.Fatalf("legacy source = %+v, want global tier", byID["legacy"].Source)
242 }
243 }
244
245 // TestProvidersContributor: descriptors become KindProvider keyed by ref at
246 // the builtin tier.
247 func TestProvidersContributor(t *testing.T) {
248 contribs := contribute(t, ProvidersContributor(
249 provider.Descriptor{Ref: "deepseek/deepseek-chat", DisplayName: "DeepSeek"},
250 provider.Descriptor{Ref: "openai/gpt-5"},
251 ))
252 if len(contribs) != 2 {
253 t.Fatalf("contributed %d providers, want 2", len(contribs))
254 }
255 for _, ct := range contribs {
256 if ct.Kind != KindProvider {
257 t.Fatalf("kind = %s, want provider", ct.Kind)
258 }
259 if ct.Source.Scope != ScopeBuiltin {
260 t.Fatalf("provider %s scope = %s, want builtin", ct.ID, ct.Source.Scope)
261 }
262 desc, ok := ct.Payload.(provider.Descriptor)
263 if !ok || desc.Ref != ct.ID {
264 t.Fatalf("provider %s payload = %+v, want matching Descriptor", ct.ID, ct.Payload)
265 }
266 }
267 }
268
269 // TestAdaptersAssembleTogether: the realistic end-to-end path — every
270 // adapter feeding one builder, producing a frozen snapshot whose schema order
271 // and hash are stable across rebuilds.
272 func TestAdaptersAssembleTogether(t *testing.T) {
273 projectRoot := t.TempDir()
274 home := t.TempDir()
275 writeSkill(t, filepath.Join(projectRoot, ".reasonix", skill.SkillsDirname), "projskill", "Project skill")
276 cmdDir := t.TempDir()
277 if err := os.WriteFile(filepath.Join(cmdDir, "review.md"), []byte("review body"), 0o644); err != nil {
278 t.Fatal(err)
279 }
280
281 build := func() *RuntimeSnapshot {
282 b := NewBuilder().WithSystemPrompt("sys").WithGeneration(1)
283 b.AddContributor(
284 BuiltinToolsContributor(),
285 SkillsContributor(skill.New(skill.Options{
286 HomeDir: home, ProjectRoot: projectRoot, DisableBuiltins: true, Stderr: io.Discard,
287 })),
288 CommandsContributor(command.Root{Path: cmdDir}),
289 HooksContributor(hook.LoadOptions{ProjectRoot: projectRoot, HomeDir: home}),
290 MCPServersContributor(plugin.Spec{Name: "fs", Package: "pkgA"}),
291 ProvidersContributor(provider.Descriptor{Ref: "deepseek/deepseek-chat"}),
292 )
293 snap, set, err := b.Build(context.Background())
294 if err != nil {
295 t.Fatalf("Build: %v", err)
296 }
297 if set.Generation() != 1 {
298 t.Fatalf("set generation = %d, want 1", set.Generation())
299 }
300 return snap
301 }
302 first, second := build(), build()
303 if first.CacheHash() != second.CacheHash() {
304 t.Fatal("identical discovery state produced different CacheHash")
305 }
306 if !first.Catalog().Frozen() {
307 t.Fatal("snapshot catalog is not frozen")
308 }
309 if len(first.ToolSchemas()) == 0 {
310 t.Fatal("no tool schemas in snapshot")
311 }
312 if got := first.Catalog().ByKind(KindSkill); len(got) != 1 || got[0].ID != "projskill" {
313 t.Fatalf("skills in snapshot = %v, want projskill", got)
314 }
315 if got := first.Catalog().ByKind(KindMCPServer); len(got) != 1 || got[0].ID != "fs" {
316 t.Fatalf("MCP servers in snapshot = %v, want fs", got)
317 }
318 }
319
319 lines GO