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