返回 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 realTestPath(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 TestSkillRootsViewMarksEnvConfiguredCustomRoot(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(home, "custom-skills")
75 if err := os.MkdirAll(root, 0o755); err != nil {
76 t.Fatal(err)
77 }
78 if err := os.WriteFile(filepath.Join(root, "custom.md"), []byte("---\ndescription: custom\n---\nbody"), 0o644); err != nil {
79 t.Fatal(err)
80 }
81 t.Setenv("REASONIX_TEST_SKILL_ROOT", root)
82 cfgPath := config.UserConfigPath()
83 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
84 t.Fatal(err)
85 }
86 if err := os.WriteFile(cfgPath, []byte("[skills]\npaths = [\"${REASONIX_TEST_SKILL_ROOT}\"]\n"), 0o644); err != nil {
87 t.Fatal(err)
88 }
89 wd, err := os.Getwd()
90 if err != nil {
91 t.Fatal(err)
92 }
93 defer os.Chdir(wd)
94 if err := os.Chdir(project); err != nil {
95 t.Fatal(err)
96 }
97
98 roots := skillRootsView()
99 want := realTestPath(root)
100 for _, r := range roots {
101 if realTestPath(r.Dir) == want {
102 if !r.Configured || r.Skills != 1 || r.Scope != "custom" {
103 t.Fatalf("custom root view = %+v, want configured custom root with one skill", r)
104 }
105 if len(r.SkillItems) != 1 || r.SkillItems[0].Name != "custom" || r.SkillItems[0].Scope != "custom" {
106 t.Fatalf("custom root skill items = %+v", r.SkillItems)
107 }
108 return
109 }
110 }
111 t.Fatalf("custom skill root %q not found in %+v", root, roots)
112 }
113
114 func TestSkillRootsViewDedupesConfiguredConventionRoot(t *testing.T) {
115 home := t.TempDir()
116 t.Setenv("HOME", home)
117 t.Setenv("USERPROFILE", home)
118 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
119 t.Setenv("AppData", filepath.Join(home, "AppData"))
120 project := t.TempDir()
121 root := filepath.Join(config.ReasonixHomeDir(), "skills")
122 if err := os.MkdirAll(root, 0o755); err != nil {
123 t.Fatal(err)
124 }
125 if err := os.WriteFile(filepath.Join(root, "global.md"), []byte("---\ndescription: global\n---\nbody"), 0o644); err != nil {
126 t.Fatal(err)
127 }
128 cfgPath := config.UserConfigPath()
129 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
130 t.Fatal(err)
131 }
132 if err := os.WriteFile(cfgPath, []byte("[skills]\npaths = ["+strconv.Quote(root)+"]\n"), 0o644); err != nil {
133 t.Fatal(err)
134 }
135 wd, err := os.Getwd()
136 if err != nil {
137 t.Fatal(err)
138 }
139 defer os.Chdir(wd)
140 if err := os.Chdir(project); err != nil {
141 t.Fatal(err)
142 }
143
144 roots := skillRootsView()
145 want := realTestPath(root)
146 var matches []SkillRootView
147 for _, r := range roots {
148 if realTestPath(r.Dir) == want {
149 matches = append(matches, r)
150 }
151 }
152 if len(matches) != 1 {
153 t.Fatalf("skill root %q appears %d times, want once: %+v", root, len(matches), matches)
154 }
155 if !matches[0].Configured || matches[0].Skills != 1 || len(matches[0].SkillItems) != 1 {
156 t.Fatalf("deduped root should keep configured metadata and skills, got %+v", matches[0])
157 }
158 }
159
160 func TestSkillRootsViewDedupesConfiguredProjectConventionRoot(t *testing.T) {
161 home := t.TempDir()
162 t.Setenv("HOME", home)
163 t.Setenv("USERPROFILE", home)
164 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
165 t.Setenv("AppData", filepath.Join(home, "AppData"))
166 project := t.TempDir()
167 root := filepath.Join(project, ".reasonix", "skills")
168 if err := os.MkdirAll(root, 0o755); err != nil {
169 t.Fatal(err)
170 }
171 if err := os.WriteFile(filepath.Join(root, "project.md"), []byte("---\ndescription: project\n---\nbody"), 0o644); err != nil {
172 t.Fatal(err)
173 }
174 wd, err := os.Getwd()
175 if err != nil {
176 t.Fatal(err)
177 }
178 defer os.Chdir(wd)
179 if err := os.Chdir(project); err != nil {
180 t.Fatal(err)
181 }
182 cwd, err := os.Getwd()
183 if err != nil {
184 t.Fatal(err)
185 }
186 root = filepath.Join(cwd, ".reasonix", "skills")
187 cfgPath := config.UserConfigPath()
188 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
189 t.Fatal(err)
190 }
191 if err := os.WriteFile(cfgPath, []byte("[skills]\npaths = ["+strconv.Quote(root)+"]\n"), 0o644); err != nil {
192 t.Fatal(err)
193 }
194
195 roots := skillRootsView()
196 want := realTestPath(root)
197 var matches []SkillRootView
198 for _, r := range roots {
199 if realTestPath(r.Dir) == want {
200 matches = append(matches, r)
201 }
202 }
203 if len(matches) != 1 {
204 t.Fatalf("project skill root %q appears %d times, want once: %+v", root, len(matches), matches)
205 }
206 if !matches[0].Configured || matches[0].Skills != 1 || len(matches[0].SkillItems) != 1 {
207 t.Fatalf("deduped project root should keep configured metadata and skills, got %+v", matches[0])
208 }
209 if matches[0].Status == "inactive" {
210 t.Fatalf("deduped project root should stay active, got %+v", matches[0])
211 }
212 }
213
214 func TestSkillRootsViewOmitsExcludedConventionRoot(t *testing.T) {
215 home := t.TempDir()
216 t.Setenv("HOME", home)
217 t.Setenv("USERPROFILE", home)
218 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
219 t.Setenv("AppData", filepath.Join(home, "AppData"))
220 project := t.TempDir()
221 root := filepath.Join(home, ".agents", "skills")
222 if err := os.MkdirAll(root, 0o755); err != nil {
223 t.Fatal(err)
224 }
225 if err := os.WriteFile(filepath.Join(root, "noisy.md"), []byte("---\ndescription: noisy\n---\nbody"), 0o644); err != nil {
226 t.Fatal(err)
227 }
228 cfgPath := config.UserConfigPath()
229 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
230 t.Fatal(err)
231 }
232 if err := os.WriteFile(cfgPath, []byte("[skills]\nexcluded_paths = [\"~/.agents/skills\"]\n"), 0o644); err != nil {
233 t.Fatal(err)
234 }
235 wd, err := os.Getwd()
236 if err != nil {
237 t.Fatal(err)
238 }
239 defer os.Chdir(wd)
240 if err := os.Chdir(project); err != nil {
241 t.Fatal(err)
242 }
243
244 roots := skillRootsView()
245 want := realTestPath(root)
246 for _, r := range roots {
247 if realTestPath(r.Dir) == want {
248 t.Fatalf("excluded convention root should be hidden, got %+v in %+v", r, roots)
249 }
250 }
251 }
252
253 func TestRemoveSkillPathPseudoDeletesConventionRoot(t *testing.T) {
254 home := t.TempDir()
255 t.Setenv("HOME", home)
256 t.Setenv("USERPROFILE", home)
257 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
258 t.Setenv("AppData", filepath.Join(home, "AppData"))
259 path := filepath.Join(home, ".agents", "skills")
260 app := NewApp()
261
262 if err := app.RemoveSkillPath(path); err != nil {
263 t.Fatalf("RemoveSkillPath: %v", err)
264 }
265 cfg := config.LoadForEdit(config.UserConfigPath())
266 if len(cfg.Skills.ExcludedPaths) != 1 || realTestPath(cfg.Skills.ExcludedPaths[0]) != realTestPath(path) {
267 t.Fatalf("excluded paths = %v, want %q", cfg.Skills.ExcludedPaths, path)
268 }
269 }
270
271 func TestAddSkillPathRestoresConventionRootWithoutCustomPath(t *testing.T) {
272 home := t.TempDir()
273 t.Setenv("HOME", home)
274 t.Setenv("USERPROFILE", home)
275 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
276 t.Setenv("AppData", filepath.Join(home, "AppData"))
277 path := filepath.Join(home, ".agents", "skills")
278 cfgPath := config.UserConfigPath()
279 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
280 t.Fatal(err)
281 }
282 if err := os.WriteFile(cfgPath, []byte("[skills]\nexcluded_paths = [\"~/.agents/skills\"]\n"), 0o644); err != nil {
283 t.Fatal(err)
284 }
285 app := NewApp()
286
287 if err := app.AddSkillPath(path); err != nil {
288 t.Fatalf("AddSkillPath: %v", err)
289 }
290 cfg := config.LoadForEdit(config.UserConfigPath())
291 if len(cfg.Skills.ExcludedPaths) != 0 {
292 t.Fatalf("excluded paths after restore = %v, want empty", cfg.Skills.ExcludedPaths)
293 }
294 if len(cfg.Skills.Paths) != 0 {
295 t.Fatalf("restored convention root should not become custom path: %v", cfg.Skills.Paths)
296 }
297 }
298
299 func TestCapabilitiesIncludesDisabledSkills(t *testing.T) {
300 a := NewApp()
301 a.setTestCtrl(control.New(control.Options{
302 Skills: []skill.Skill{
303 {Name: "explore", Description: "enabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
304 },
305 AllSkills: []skill.Skill{
306 {Name: "explore", Description: "enabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
307 {Name: "review", Description: "disabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
308 },
309 }), "")
310 defer a.activeCtrl().Close()
311 home := t.TempDir()
312 t.Setenv("HOME", home)
313 t.Setenv("USERPROFILE", home)
314 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
315 t.Setenv("AppData", filepath.Join(home, "AppData"))
316 cfgPath := config.UserConfigPath()
317 if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
318 t.Fatal(err)
319 }
320 if err := os.WriteFile(cfgPath, []byte("[skills]\ndisabled_skills = [\"review\"]\n"), 0o644); err != nil {
321 t.Fatal(err)
322 }
323
324 settingsView := a.SkillsSettings()
325 assertSkillStates := func(t *testing.T, skills []SkillView) {
326 t.Helper()
327 states := map[string]bool{}
328 for _, sk := range skills {
329 states[sk.Name] = sk.Enabled
330 }
331 if states["explore"] != true {
332 t.Fatalf("explore should be enabled in skills view: %+v", skills)
333 }
334 enabled, ok := states["review"]
335 if !ok || enabled {
336 t.Fatalf("review should be disabled but present in skills view: %+v", skills)
337 }
338 }
339 assertSkillStates(t, settingsView.Skills)
340 assertSkillStates(t, a.Capabilities().Skills)
341 }
342
343 func TestSkillsSettingsCarriesSubagentProfileFields(t *testing.T) {
344 a := NewApp()
345 a.setTestCtrl(control.New(control.Options{
346 AllSkills: []skill.Skill{
347 {
348 Name: "my-agent", Description: "a custom subagent", Scope: skill.ScopeGlobal, RunAs: skill.RunSubagent,
349 Model: "deepseek-pro", Effort: "high", AllowedTools: []string{"read_file", "grep"},
350 Color: "amber", Invocation: "manual",
351 },
352 },
353 }), "")
354 defer a.activeCtrl().Close()
355
356 views := a.SkillsSettings().Skills
357 if len(views) != 1 {
358 t.Fatalf("Skills = %+v, want exactly one entry", views)
359 }
360 got := views[0]
361 if got.Model != "deepseek-pro" || got.Effort != "high" || got.Color != "amber" || got.Invocation != "/my-agent" || got.InvocationMode != "manual" {
362 t.Fatalf("subagent profile fields not carried through: %+v", got)
363 }
364 if len(got.AllowedTools) != 2 || got.AllowedTools[0] != "read_file" || got.AllowedTools[1] != "grep" {
365 t.Fatalf("AllowedTools not carried through: %v", got.AllowedTools)
366 }
367 }
368
369 func TestAvailableSubagentToolsExcludesAlwaysHiddenTools(t *testing.T) {
370 a := NewApp()
371 for _, view := range a.AvailableSubagentTools() {
372 for _, hidden := range []string{"install_skill", "install_source", "parallel_tasks", "wait", "bash_output", "kill_shell"} {
373 if view.Name == hidden {
374 t.Fatalf("AvailableSubagentTools should exclude always-hidden tool %q", hidden)
375 }
376 }
377 }
378 }
379
380 func TestSkillsSettingsRefreshInvalidatesSkillRootsCache(t *testing.T) {
381 home := t.TempDir()
382 t.Setenv("HOME", home)
383 t.Setenv("USERPROFILE", home)
384 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
385 t.Setenv("AppData", filepath.Join(home, "AppData"))
386 project := t.TempDir()
387 root := filepath.Join(project, ".reasonix", "skills")
388 if err := os.MkdirAll(root, 0o755); err != nil {
389 t.Fatal(err)
390 }
391 if err := os.WriteFile(filepath.Join(root, "one.md"), []byte("---\ndescription: one\n---\nbody"), 0o644); err != nil {
392 t.Fatal(err)
393 }
394 wd, err := os.Getwd()
395 if err != nil {
396 t.Fatal(err)
397 }
398 defer os.Chdir(wd)
399 if err := os.Chdir(project); err != nil {
400 t.Fatal(err)
401 }
402 a := NewApp()
403 a.setTestCtrl(control.New(control.Options{}), "")
404 defer a.activeCtrl().Close()
405
406 first := a.SkillsSettings()
407 if got := skillRootCount(first.SkillRoots, root); got != 1 {
408 t.Fatalf("initial project skill count = %d, want 1; roots=%+v", got, first.SkillRoots)
409 }
410 if err := os.WriteFile(filepath.Join(root, "two.md"), []byte("---\ndescription: two\n---\nbody"), 0o644); err != nil {
411 t.Fatal(err)
412 }
413 cached := a.SkillsSettings()
414 if got := skillRootCount(cached.SkillRoots, root); got != 1 {
415 t.Fatalf("cached project skill count = %d, want 1 before refresh; roots=%+v", got, cached.SkillRoots)
416 }
417 if err := a.RefreshSkills(); err != nil {
418 t.Fatalf("RefreshSkills: %v", err)
419 }
420 refreshed := a.SkillsSettings()
421 if got := skillRootCount(refreshed.SkillRoots, root); got != 2 {
422 t.Fatalf("refreshed project skill count = %d, want 2; roots=%+v", got, refreshed.SkillRoots)
423 }
424 }
425
426 func skillRootCount(roots []SkillRootView, path string) int {
427 want := realTestPath(path)
428 for _, r := range roots {
429 if realTestPath(r.Dir) == want {
430 return r.Skills
431 }
432 }
433 return -1
434 }
435
436 func realTestPath(path string) string {
437 if p, err := filepath.EvalSymlinks(path); err == nil {
438 path = p
439 }
440 return config.CanonicalSkillPath(path)
441 }
442
442 lines GO