返回 DeepSeek-Reasonix
skills_app_test.go
根目录 / desktop / skills_app_test.go
1 package main
2
3 import (
4 "os"
5 "path/filepath"
6 "strconv"
7 "testing"
8
9 "reasonix/internal/config"
10 "reasonix/internal/control"
11 "reasonix/internal/skill"
12 )
13
14 func TestNormalizeSkillPathDirectoryLayout(t *testing.T) {
15 root := t.TempDir()
16 skillDir := filepath.Join(root, "my-skill")
17 if err := os.MkdirAll(skillDir, 0o755); err != nil {
18 t.Fatal(err)
19 }
20 if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: x\n---\nbody"), 0o644); err != nil {
21 t.Fatal(err)
22 }
23 if got := normalizeSkillPath(skillDir); got != root {
24 t.Fatalf("normalizeSkillPath(%q) = %q, want %q", skillDir, got, root)
25 }
26 }
27
28 func TestSkillRootsViewCountsProjectSkills(t *testing.T) {
29 home := t.TempDir()
30 t.Setenv("HOME", home)
31 t.Setenv("USERPROFILE", home)
32 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
33 t.Setenv("AppData", filepath.Join(home, "AppData"))
34 project := t.TempDir()
35 root := filepath.Join(project, ".reasonix", "skills")
36 if err := os.MkdirAll(root, 0o755); err != nil {
37 t.Fatal(err)
38 }
39 if err := os.WriteFile(filepath.Join(root, "proj.md"), []byte("---\ndescription: project\n---\nbody"), 0o644); err != nil {
40 t.Fatal(err)
41 }
42 wd, err := os.Getwd()
43 if err != nil {
44 t.Fatal(err)
45 }
46 defer os.Chdir(wd)
47 if err := os.Chdir(project); err != nil {
48 t.Fatal(err)
49 }
50
51 roots := skillRootsView()
52 want := realTestPath(root)
53 for _, r := range roots {
54 if config.CanonicalSkillPath(r.Dir) == want {
55 if r.Status != "ok" || r.Skills != 1 || r.Scope != "project" {
56 t.Fatalf("project root view = %+v", r)
57 }
58 if len(r.SkillItems) != 1 || r.SkillItems[0].Name != "proj" || r.SkillItems[0].Description != "project" {
59 t.Fatalf("project root skill items = %+v", r.SkillItems)
60 }
61 return
62 }
63 }
64 t.Fatalf("project skill root %q not found in %+v", root, roots)
65 }
66
67 func TestSkillRootsViewUsesActiveWorkspaceForRelativeProjectPaths(t *testing.T) {
68 home := t.TempDir()
69 t.Setenv("HOME", home)
70 t.Setenv("USERPROFILE", home)
71 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
72 t.Setenv("AppData", filepath.Join(home, "AppData"))
73 project := t.TempDir()
74 root := filepath.Join(project, "relative-skills")
75 if err := os.MkdirAll(root, 0o755); err != nil {
76 t.Fatal(err)
77 }
78 if err := os.WriteFile(filepath.Join(root, "relative.md"), []byte("---\ndescription: relative\n---\nbody"), 0o644); err != nil {
79 t.Fatal(err)
80 }
81 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[skills]\npaths = [\"relative-skills\"]\n"), 0o644); err != nil {
82 t.Fatal(err)
83 }
84 cfg, err := config.LoadForRootReadOnly(project)
85 if err != nil {
86 t.Fatal(err)
87 }
88 roots := skillRootsViewFrom(project, cfg, config.LoadForEdit(config.UserConfigPath()))
89 if got := skillRootCount(roots, root); got != 1 {
90 t.Fatalf("relative project skill root count = %d, want 1; roots=%+v", got, roots)
91 }
92 }
93
94 func TestSkillRootsViewShowsProjectExcludedPathAsDisabled(t *testing.T) {
95 home := t.TempDir()
96 t.Setenv("HOME", home)
97 t.Setenv("USERPROFILE", home)
98 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
99 t.Setenv("AppData", filepath.Join(home, "AppData"))
100 project := t.TempDir()
101 root := filepath.Join(project, "project-skills")
102 if err := os.MkdirAll(root, 0o755); err != nil {
103 t.Fatal(err)
104 }
105 if err := os.WriteFile(filepath.Join(root, "disabled.md"), []byte("---\ndescription: disabled\n---\nbody"), 0o644); err != nil {
106 t.Fatal(err)
107 }
108 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[skills]\nexcluded_paths = [\"project-skills\"]\n"), 0o644); err != nil {
109 t.Fatal(err)
110 }
111 cfg, err := config.LoadForRootReadOnly(project)
112 if err != nil {
113 t.Fatal(err)
114 }
115 roots := skillRootsViewFrom(project, cfg, config.LoadForEdit(config.UserConfigPath()))
116 for _, view := range roots {
117 if realTestPath(view.Dir) != realTestPath(root) {
118 continue
119 }
120 if view.Enabled || view.Status != "disabled" || !view.Configured || view.Skills != 0 {
121 t.Fatalf("project excluded root view = %+v", view)
122 }
123 return
124 }
125 t.Fatalf("project excluded skill root %q not found in %+v", root, roots)
126 }
127
128 func TestSkillSettingsEditProjectOwnedFields(t *testing.T) {
129 home := t.TempDir()
130 t.Setenv("HOME", home)
131 t.Setenv("USERPROFILE", home)
132 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
133 t.Setenv("AppData", filepath.Join(home, "AppData"))
134 project := t.TempDir()
135 root := filepath.Join(project, "project-skills")
136 if err := os.MkdirAll(root, 0o755); err != nil {
137 t.Fatal(err)
138 }
139 projectConfig := filepath.Join(project, "reasonix.toml")
140 if err := os.WriteFile(projectConfig, []byte("[skills]\npaths = [\"project-skills\"]\ndisable_implicit_invocation = true\n"), 0o644); err != nil {
141 t.Fatal(err)
142 }
143 wd, err := os.Getwd()
144 if err != nil {
145 t.Fatal(err)
146 }
147 defer os.Chdir(wd)
148 if err := os.Chdir(project); err != nil {
149 t.Fatal(err)
150 }
151 app := NewApp()
152 if err := app.SetSkillPathEnabled(root, false); err != nil {
153 t.Fatalf("SetSkillPathEnabled: %v", err)
154 }
155 projectCfg, err := config.LoadForEditReadOnlyStrict(projectConfig)
156 if err != nil {
157 t.Fatal(err)
158 }
159 if len(projectCfg.Skills.ExcludedPaths) != 1 || realTestPath(projectCfg.Skills.ExcludedPaths[0]) != realTestPath(root) {
160 t.Fatalf("project excluded paths = %v, want %q", projectCfg.Skills.ExcludedPaths, root)
161 }
162 if err := app.SetSkillPathEnabled(root, true); err != nil {
163 t.Fatalf("SetSkillPathEnabled restore: %v", err)
164 }
165 projectCfg, err = config.LoadForEditReadOnlyStrict(projectConfig)
166 if err != nil {
167 t.Fatal(err)
168 }
169 if len(projectCfg.Skills.ExcludedPaths) != 0 {
170 t.Fatalf("project excluded paths after restore = %v, want empty", projectCfg.Skills.ExcludedPaths)
171 }
172 if err := app.SetSkillImplicitInvocation(false); err != nil {
173 t.Fatalf("SetSkillImplicitInvocation: %v", err)
174 }
175 projectCfg, err = config.LoadForEditReadOnlyStrict(projectConfig)
176 if err != nil {
177 t.Fatal(err)
178 }
179 if len(projectCfg.Skills.ExcludedPaths) != 0 {
180 t.Fatalf("project excluded paths changed during policy edit: %v", projectCfg.Skills.ExcludedPaths)
181 }
182 if !projectCfg.Skills.DisableImplicitInvocation {
183 t.Fatal("project implicit invocation policy was overwritten")
184 }
185 if err := app.SetSkillImplicitInvocation(true); err != nil {
186 t.Fatalf("SetSkillImplicitInvocation restore: %v", err)
187 }
188 projectCfg, err = config.LoadForEditReadOnlyStrict(projectConfig)
189 if err != nil {
190 t.Fatal(err)
191 }
192 if projectCfg.Skills.DisableImplicitInvocation {
193 t.Fatal("project implicit invocation policy did not restore")
194 }
195 userCfg := config.LoadForEdit(config.UserConfigPath())
196 if len(userCfg.Skills.ExcludedPaths) != 0 || userCfg.Skills.DisableImplicitInvocation {
197 t.Fatalf("project-owned skill edits leaked into user config: %+v", userCfg.Skills)
198 }
199 }
200
201 func TestSkillRootsViewMarksEnvConfiguredCustomRoot(t *testing.T) {
202 home := t.TempDir()
203 t.Setenv("HOME", home)
204 t.Setenv("USERPROFILE", home)
205 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
206 t.Setenv("AppData", filepath.Join(home, "AppData"))
207 project := t.TempDir()
208 root := filepath.Join(home, "custom-skills")
209 if err := os.MkdirAll(root, 0o755); err != nil {
210 t.Fatal(err)
211 }
212 if err := os.WriteFile(filepath.Join(root, "custom.md"), []byte("---\ndescription: custom\n---\nbody"), 0o644); err != nil {
213 t.Fatal(err)
214 }
215 t.Setenv("REASONIX_TEST_SKILL_ROOT", root)
216 cfgPath := config.UserConfigPath()
217 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
218 t.Fatal(err)
219 }
220 if err := os.WriteFile(cfgPath, []byte("[skills]\npaths = [\"${REASONIX_TEST_SKILL_ROOT}\"]\n"), 0o644); err != nil {
221 t.Fatal(err)
222 }
223 wd, err := os.Getwd()
224 if err != nil {
225 t.Fatal(err)
226 }
227 defer os.Chdir(wd)
228 if err := os.Chdir(project); err != nil {
229 t.Fatal(err)
230 }
231
232 roots := skillRootsView()
233 want := realTestPath(root)
234 for _, r := range roots {
235 if realTestPath(r.Dir) == want {
236 if !r.Configured || r.Skills != 1 || r.Scope != "custom" {
237 t.Fatalf("custom root view = %+v, want configured custom root with one skill", r)
238 }
239 if len(r.SkillItems) != 1 || r.SkillItems[0].Name != "custom" || r.SkillItems[0].Scope != "custom" {
240 t.Fatalf("custom root skill items = %+v", r.SkillItems)
241 }
242 return
243 }
244 }
245 t.Fatalf("custom skill root %q not found in %+v", root, roots)
246 }
247
248 func TestSkillRootsViewDedupesConfiguredConventionRoot(t *testing.T) {
249 home := t.TempDir()
250 t.Setenv("HOME", home)
251 t.Setenv("USERPROFILE", home)
252 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
253 t.Setenv("AppData", filepath.Join(home, "AppData"))
254 project := t.TempDir()
255 root := filepath.Join(config.ReasonixHomeDir(), "skills")
256 if err := os.MkdirAll(root, 0o755); err != nil {
257 t.Fatal(err)
258 }
259 if err := os.WriteFile(filepath.Join(root, "global.md"), []byte("---\ndescription: global\n---\nbody"), 0o644); err != nil {
260 t.Fatal(err)
261 }
262 cfgPath := config.UserConfigPath()
263 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
264 t.Fatal(err)
265 }
266 if err := os.WriteFile(cfgPath, []byte("[skills]\npaths = ["+strconv.Quote(root)+"]\n"), 0o644); err != nil {
267 t.Fatal(err)
268 }
269 wd, err := os.Getwd()
270 if err != nil {
271 t.Fatal(err)
272 }
273 defer os.Chdir(wd)
274 if err := os.Chdir(project); err != nil {
275 t.Fatal(err)
276 }
277
278 roots := skillRootsView()
279 want := realTestPath(root)
280 var matches []SkillRootView
281 for _, r := range roots {
282 if realTestPath(r.Dir) == want {
283 matches = append(matches, r)
284 }
285 }
286 if len(matches) != 1 {
287 t.Fatalf("skill root %q appears %d times, want once: %+v", root, len(matches), matches)
288 }
289 if !matches[0].Configured || matches[0].Skills != 1 || len(matches[0].SkillItems) != 1 {
290 t.Fatalf("deduped root should keep configured metadata and skills, got %+v", matches[0])
291 }
292 }
293
294 func TestSkillRootsViewDedupesConfiguredProjectConventionRoot(t *testing.T) {
295 home := t.TempDir()
296 t.Setenv("HOME", home)
297 t.Setenv("USERPROFILE", home)
298 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
299 t.Setenv("AppData", filepath.Join(home, "AppData"))
300 project := t.TempDir()
301 root := filepath.Join(project, ".reasonix", "skills")
302 if err := os.MkdirAll(root, 0o755); err != nil {
303 t.Fatal(err)
304 }
305 if err := os.WriteFile(filepath.Join(root, "project.md"), []byte("---\ndescription: project\n---\nbody"), 0o644); err != nil {
306 t.Fatal(err)
307 }
308 wd, err := os.Getwd()
309 if err != nil {
310 t.Fatal(err)
311 }
312 defer os.Chdir(wd)
313 if err := os.Chdir(project); err != nil {
314 t.Fatal(err)
315 }
316 cwd, err := os.Getwd()
317 if err != nil {
318 t.Fatal(err)
319 }
320 root = filepath.Join(cwd, ".reasonix", "skills")
321 cfgPath := config.UserConfigPath()
322 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
323 t.Fatal(err)
324 }
325 if err := os.WriteFile(cfgPath, []byte("[skills]\npaths = ["+strconv.Quote(root)+"]\n"), 0o644); err != nil {
326 t.Fatal(err)
327 }
328
329 roots := skillRootsView()
330 want := realTestPath(root)
331 var matches []SkillRootView
332 for _, r := range roots {
333 if realTestPath(r.Dir) == want {
334 matches = append(matches, r)
335 }
336 }
337 if len(matches) != 1 {
338 t.Fatalf("project skill root %q appears %d times, want once: %+v", root, len(matches), matches)
339 }
340 if !matches[0].Configured || matches[0].Skills != 1 || len(matches[0].SkillItems) != 1 {
341 t.Fatalf("deduped project root should keep configured metadata and skills, got %+v", matches[0])
342 }
343 if matches[0].Status == "inactive" {
344 t.Fatalf("deduped project root should stay active, got %+v", matches[0])
345 }
346 }
347
348 func TestSkillRootsViewShowsExcludedConventionRootAsDisabled(t *testing.T) {
349 home := t.TempDir()
350 t.Setenv("HOME", home)
351 t.Setenv("USERPROFILE", home)
352 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
353 t.Setenv("AppData", filepath.Join(home, "AppData"))
354 project := t.TempDir()
355 root := filepath.Join(home, ".agents", "skills")
356 if err := os.MkdirAll(root, 0o755); err != nil {
357 t.Fatal(err)
358 }
359 if err := os.WriteFile(filepath.Join(root, "noisy.md"), []byte("---\ndescription: noisy\n---\nbody"), 0o644); err != nil {
360 t.Fatal(err)
361 }
362 cfgPath := config.UserConfigPath()
363 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
364 t.Fatal(err)
365 }
366 if err := os.WriteFile(cfgPath, []byte("[skills]\nexcluded_paths = [\"~/.agents/skills\"]\n"), 0o644); err != nil {
367 t.Fatal(err)
368 }
369 wd, err := os.Getwd()
370 if err != nil {
371 t.Fatal(err)
372 }
373 defer os.Chdir(wd)
374 if err := os.Chdir(project); err != nil {
375 t.Fatal(err)
376 }
377
378 roots := skillRootsView()
379 want := config.CanonicalSkillPath("~/.agents/skills")
380 for _, r := range roots {
381 if config.CanonicalSkillPath(r.Dir) == want {
382 if r.Enabled || r.Status != "disabled" || r.Skills != 0 {
383 t.Fatalf("excluded convention root should remain visible as disabled, got %+v", r)
384 }
385 return
386 }
387 }
388 t.Fatalf("excluded convention root should remain visible as disabled, roots=%+v", roots)
389 }
390
391 func TestRemoveSkillPathPseudoDeletesConventionRoot(t *testing.T) {
392 home := t.TempDir()
393 t.Setenv("HOME", home)
394 t.Setenv("USERPROFILE", home)
395 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
396 t.Setenv("AppData", filepath.Join(home, "AppData"))
397 path := filepath.Join(home, ".agents", "skills")
398 app := NewApp()
399
400 if err := app.RemoveSkillPath(path); err != nil {
401 t.Fatalf("RemoveSkillPath: %v", err)
402 }
403 cfg := config.LoadForEdit(config.UserConfigPath())
404 if len(cfg.Skills.ExcludedPaths) != 1 || realTestPath(cfg.Skills.ExcludedPaths[0]) != realTestPath(path) {
405 t.Fatalf("excluded paths = %v, want %q", cfg.Skills.ExcludedPaths, path)
406 }
407 }
408
409 func TestAddSkillPathRestoresConventionRootWithoutCustomPath(t *testing.T) {
410 home := t.TempDir()
411 t.Setenv("HOME", home)
412 t.Setenv("USERPROFILE", home)
413 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
414 t.Setenv("AppData", filepath.Join(home, "AppData"))
415 path := filepath.Join(home, ".agents", "skills")
416 cfgPath := config.UserConfigPath()
417 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
418 t.Fatal(err)
419 }
420 if err := os.WriteFile(cfgPath, []byte("[skills]\nexcluded_paths = [\"~/.agents/skills\"]\n"), 0o644); err != nil {
421 t.Fatal(err)
422 }
423 app := NewApp()
424
425 if err := app.AddSkillPath(path); err != nil {
426 t.Fatalf("AddSkillPath: %v", err)
427 }
428 cfg := config.LoadForEdit(config.UserConfigPath())
429 if len(cfg.Skills.ExcludedPaths) != 0 {
430 t.Fatalf("excluded paths after restore = %v, want empty", cfg.Skills.ExcludedPaths)
431 }
432 if len(cfg.Skills.Paths) != 0 {
433 t.Fatalf("restored convention root should not become custom path: %v", cfg.Skills.Paths)
434 }
435 }
436
437 func TestCapabilitiesIncludesDisabledSkills(t *testing.T) {
438 a := NewApp()
439 a.setTestCtrl(control.New(control.Options{
440 Skills: []skill.Skill{
441 {Name: "explore", Description: "enabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
442 },
443 AllSkills: []skill.Skill{
444 {Name: "explore", Description: "enabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
445 {Name: "review", Description: "disabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
446 },
447 }), "")
448 defer a.activeCtrl().Close()
449 home := t.TempDir()
450 t.Setenv("HOME", home)
451 t.Setenv("USERPROFILE", home)
452 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
453 t.Setenv("AppData", filepath.Join(home, "AppData"))
454 cfgPath := config.UserConfigPath()
455 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
456 t.Fatal(err)
457 }
458 if err := os.WriteFile(cfgPath, []byte("[skills]\ndisabled_skills = [\"review\"]\n"), 0o644); err != nil {
459 t.Fatal(err)
460 }
461
462 settingsView := a.SkillsSettings()
463 assertSkillStates := func(t *testing.T, skills []SkillView) {
464 t.Helper()
465 states := map[string]bool{}
466 for _, sk := range skills {
467 states[sk.Name] = sk.Enabled
468 }
469 if states["explore"] != true {
470 t.Fatalf("explore should be enabled in skills view: %+v", skills)
471 }
472 enabled, ok := states["review"]
473 if !ok || enabled {
474 t.Fatalf("review should be disabled but present in skills view: %+v", skills)
475 }
476 }
477 assertSkillStates(t, settingsView.Skills)
478 assertSkillStates(t, a.Capabilities().Skills)
479 }
480
481 func TestSkillsSettingsCarriesSubagentProfileFields(t *testing.T) {
482 a := NewApp()
483 a.setTestCtrl(control.New(control.Options{
484 AllSkills: []skill.Skill{
485 {
486 Name: "my-agent", Description: "a custom subagent", Scope: skill.ScopeGlobal, RunAs: skill.RunSubagent,
487 Model: "deepseek-pro", Effort: "high", AllowedTools: []string{"read_file", "grep"},
488 Color: "amber", Invocation: "manual",
489 },
490 },
491 }), "")
492 defer a.activeCtrl().Close()
493
494 views := a.SkillsSettings().Skills
495 if len(views) != 1 {
496 t.Fatalf("Skills = %+v, want exactly one entry", views)
497 }
498 got := views[0]
499 if got.Model != "deepseek-pro" || got.Effort != "high" || got.Color != "amber" || got.Invocation != "/my-agent" || got.InvocationMode != "manual" {
500 t.Fatalf("subagent profile fields not carried through: %+v", got)
501 }
502 if len(got.AllowedTools) != 2 || got.AllowedTools[0] != "read_file" || got.AllowedTools[1] != "grep" {
503 t.Fatalf("AllowedTools not carried through: %v", got.AllowedTools)
504 }
505 }
506
507 func TestSkillsSettingsCarriesSkillSourceDir(t *testing.T) {
508 home := t.TempDir()
509 t.Setenv("HOME", home)
510 t.Setenv("USERPROFILE", home)
511 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
512 t.Setenv("AppData", filepath.Join(home, "AppData"))
513 project := t.TempDir()
514 root := filepath.Join(project, ".reasonix", "skills")
515 skillDir := filepath.Join(root, "owned")
516 skillPath := filepath.Join(skillDir, "SKILL.md")
517 if err := os.MkdirAll(skillDir, 0o755); err != nil {
518 t.Fatal(err)
519 }
520 if err := os.WriteFile(skillPath, []byte("---\ndescription: owned\n---\nbody"), 0o644); err != nil {
521 t.Fatal(err)
522 }
523 wd, err := os.Getwd()
524 if err != nil {
525 t.Fatal(err)
526 }
527 defer os.Chdir(wd)
528 if err := os.Chdir(project); err != nil {
529 t.Fatal(err)
530 }
531
532 a := NewApp()
533 a.setTestCtrl(control.New(control.Options{
534 AllSkills: []skill.Skill{{Name: "owned", Description: "owned", Scope: skill.ScopeProject, Path: skillPath}},
535 }), "")
536 defer a.activeCtrl().Close()
537
538 views := a.SkillsSettings().Skills
539 if len(views) != 1 {
540 t.Fatalf("Skills = %+v, want exactly one entry", views)
541 }
542 if got, want := realTestPath(views[0].SourceDir), realTestPath(root); got != want {
543 t.Fatalf("SourceDir = %q, want %q", got, want)
544 }
545 }
546
547 func TestSkillSourceDirPrefersLongestMatchingRoot(t *testing.T) {
548 parent := t.TempDir()
549 nested := filepath.Join(parent, "nested")
550 skillPath := filepath.Join(nested, "example", "SKILL.md")
551 got := skillSourceDir(skill.Skill{Scope: skill.ScopeCustom, Path: skillPath}, []SkillRootView{
552 {Dir: parent, Scope: string(skill.ScopeCustom)},
553 {Dir: nested, Scope: string(skill.ScopeCustom)},
554 })
555 if realTestPath(got) != realTestPath(nested) {
556 t.Fatalf("skillSourceDir = %q, want longest matching root %q", got, nested)
557 }
558 }
559
560 func TestAvailableSubagentToolsExcludesAlwaysHiddenTools(t *testing.T) {
561 a := NewApp()
562 for _, view := range a.AvailableSubagentTools() {
563 for _, hidden := range []string{"install_skill", "install_source", "parallel_tasks", "wait", "bash_output", "kill_shell"} {
564 if view.Name == hidden {
565 t.Fatalf("AvailableSubagentTools should exclude always-hidden tool %q", hidden)
566 }
567 }
568 }
569 }
570
571 func TestSkillsSettingsRefreshInvalidatesSkillRootsCache(t *testing.T) {
572 home := t.TempDir()
573 t.Setenv("HOME", home)
574 t.Setenv("USERPROFILE", home)
575 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
576 t.Setenv("AppData", filepath.Join(home, "AppData"))
577 project := t.TempDir()
578 root := filepath.Join(project, ".reasonix", "skills")
579 if err := os.MkdirAll(root, 0o755); err != nil {
580 t.Fatal(err)
581 }
582 if err := os.WriteFile(filepath.Join(root, "one.md"), []byte("---\ndescription: one\n---\nbody"), 0o644); err != nil {
583 t.Fatal(err)
584 }
585 wd, err := os.Getwd()
586 if err != nil {
587 t.Fatal(err)
588 }
589 defer os.Chdir(wd)
590 if err := os.Chdir(project); err != nil {
591 t.Fatal(err)
592 }
593 a := NewApp()
594 a.setTestCtrl(control.New(control.Options{}), "")
595 defer a.activeCtrl().Close()
596
597 first := a.SkillsSettings()
598 if got := skillRootCount(first.SkillRoots, root); got != 1 {
599 t.Fatalf("initial project skill count = %d, want 1; roots=%+v", got, first.SkillRoots)
600 }
601 if err := os.WriteFile(filepath.Join(root, "two.md"), []byte("---\ndescription: two\n---\nbody"), 0o644); err != nil {
602 t.Fatal(err)
603 }
604 cached := a.SkillsSettings()
605 if got := skillRootCount(cached.SkillRoots, root); got != 1 {
606 t.Fatalf("cached project skill count = %d, want 1 before refresh; roots=%+v", got, cached.SkillRoots)
607 }
608 if err := a.RefreshSkills(); err != nil {
609 t.Fatalf("RefreshSkills: %v", err)
610 }
611 refreshed := a.SkillsSettings()
612 if got := skillRootCount(refreshed.SkillRoots, root); got != 2 {
613 t.Fatalf("refreshed project skill count = %d, want 2; roots=%+v", got, refreshed.SkillRoots)
614 }
615 }
616
617 func skillRootCount(roots []SkillRootView, path string) int {
618 want := realTestPath(path)
619 for _, r := range roots {
620 if realTestPath(r.Dir) == want {
621 return r.Skills
622 }
623 }
624 return -1
625 }
626
627 func realTestPath(path string) string {
628 if p, err := filepath.EvalSymlinks(path); err == nil {
629 path = p
630 }
631 return config.CanonicalSkillPath(path)
632 }
633
633 lines GO