skill_test.go
1 package tools 2 3 import ( 4 "context" 5 "encoding/json" 6 "os" 7 "path/filepath" 8 "strings" 9 "testing" 10 11 "github.com/Kocoro-lab/ShanClaw/internal/skills" 12 ) 13 14 func TestUseSkill_HappyPath(t *testing.T) { 15 s := &skills.Skill{ 16 Name: "pdf", Description: "Extract text from PDFs", 17 Prompt: "# PDF Guide\n\nUse pypdf to extract text.", Dir: t.TempDir(), 18 } 19 skillList := []*skills.Skill{s} 20 tool := newUseSkillTool(&skillList) 21 22 args, _ := json.Marshal(map[string]string{"skill_name": "pdf"}) 23 result, err := tool.Run(context.Background(), string(args)) 24 if err != nil { 25 t.Fatalf("error: %v", err) 26 } 27 if result.IsError { 28 t.Error("should not be error") 29 } 30 if !strings.Contains(result.Content, "# PDF Guide") { 31 t.Errorf("missing body: %s", result.Content) 32 } 33 } 34 35 func TestUseSkill_WithArgs(t *testing.T) { 36 s := &skills.Skill{Name: "pdf", Prompt: "# PDF Guide", Dir: t.TempDir()} 37 skillList := []*skills.Skill{s} 38 tool := newUseSkillTool(&skillList) 39 40 args, _ := json.Marshal(map[string]string{"skill_name": "pdf", "args": "merge two PDFs"}) 41 result, err := tool.Run(context.Background(), string(args)) 42 if err != nil { 43 t.Fatalf("error: %v", err) 44 } 45 if !strings.Contains(result.Content, "## User Context") { 46 t.Error("missing User Context") 47 } 48 if !strings.Contains(result.Content, "merge two PDFs") { 49 t.Error("missing args") 50 } 51 } 52 53 func TestUseSkill_UnknownSkill(t *testing.T) { 54 s := &skills.Skill{Name: "pdf", Prompt: "body"} 55 skillList := []*skills.Skill{s} 56 tool := newUseSkillTool(&skillList) 57 58 args, _ := json.Marshal(map[string]string{"skill_name": "nonexistent"}) 59 result, err := tool.Run(context.Background(), string(args)) 60 if err != nil { 61 t.Fatalf("error: %v", err) 62 } 63 if !result.IsError { 64 t.Fatal("should be error") 65 } 66 if !strings.Contains(result.Content, "pdf") { 67 t.Errorf("should list available: %s", result.Content) 68 } 69 } 70 71 func TestUseSkill_NoSkills(t *testing.T) { 72 var skillList []*skills.Skill 73 tool := newUseSkillTool(&skillList) 74 75 args, _ := json.Marshal(map[string]string{"skill_name": "anything"}) 76 result, err := tool.Run(context.Background(), string(args)) 77 if err != nil { 78 t.Fatalf("error: %v", err) 79 } 80 if !result.IsError { 81 t.Fatal("should be error") 82 } 83 } 84 85 func TestUseSkill_RelativePathRewrite(t *testing.T) { 86 dir := t.TempDir() 87 os.MkdirAll(filepath.Join(dir, "scripts"), 0o755) 88 os.WriteFile(filepath.Join(dir, "scripts", "extract.py"), []byte("print('hi')"), 0o644) 89 90 s := &skills.Skill{Name: "pdf", Prompt: "Run scripts/extract.py to extract.", Dir: dir} 91 skillList := []*skills.Skill{s} 92 tool := newUseSkillTool(&skillList) 93 94 args, _ := json.Marshal(map[string]string{"skill_name": "pdf"}) 95 result, err := tool.Run(context.Background(), string(args)) 96 if err != nil { 97 t.Fatalf("error: %v", err) 98 } 99 expected := filepath.Join(dir, "scripts/extract.py") 100 if !strings.Contains(result.Content, expected) { 101 t.Errorf("expected absolute path %s in: %s", expected, result.Content) 102 } 103 } 104 105 func TestUseSkill_PromptNeverContainsSecretValues(t *testing.T) { 106 // Regression test: skill prompts with $KEY references MUST be returned 107 // verbatim — secret values must never be substituted into the content 108 // that goes into the session transcript. 109 s := &skills.Skill{ 110 Name: "my-skill", 111 Prompt: "Run: curl -H \"Authorization: $MY_API_KEY\" https://api.example.com", 112 Dir: t.TempDir(), 113 } 114 skillList := []*skills.Skill{s} 115 tool := newUseSkillTool(&skillList) 116 117 args, _ := json.Marshal(map[string]string{"skill_name": "my-skill"}) 118 result, err := tool.Run(context.Background(), string(args)) 119 if err != nil { 120 t.Fatalf("error: %v", err) 121 } 122 if !strings.Contains(result.Content, "$MY_API_KEY") { 123 t.Errorf("prompt must retain $MY_API_KEY literally, got: %s", result.Content) 124 } 125 } 126 127 func TestUseSkill_RegistersActivatedSkill(t *testing.T) { 128 s := &skills.Skill{Name: "my-skill", Slug: "my-skill", Prompt: "body", Dir: t.TempDir()} 129 skillList := []*skills.Skill{s} 130 tool := newUseSkillTool(&skillList) 131 132 set := skills.NewActivatedSet() 133 ctx := skills.WithActivatedSet(context.Background(), set) 134 135 args, _ := json.Marshal(map[string]string{"skill_name": "my-skill"}) 136 if _, err := tool.Run(ctx, string(args)); err != nil { 137 t.Fatalf("error: %v", err) 138 } 139 140 names := set.Names() 141 if len(names) != 1 || names[0] != "my-skill" { 142 t.Errorf("expected activated set to contain [my-skill], got %v", names) 143 } 144 } 145 146 // TestUseSkill_RegistersSlugWhenNameDiffers ensures activation uses the 147 // on-disk Slug (the key SecretsStore is indexed by) rather than the 148 // frontmatter Name when the two differ. Regression target: xiaohongshu- 149 // mcp-skills where Name="xiaohongshu" but secrets live under 150 // Slug="xiaohongshu-mcp-skills". 151 func TestUseSkill_RegistersSlugWhenNameDiffers(t *testing.T) { 152 s := &skills.Skill{Name: "xiaohongshu", Slug: "xiaohongshu-mcp-skills", Prompt: "body", Dir: t.TempDir()} 153 skillList := []*skills.Skill{s} 154 tool := newUseSkillTool(&skillList) 155 156 set := skills.NewActivatedSet() 157 ctx := skills.WithActivatedSet(context.Background(), set) 158 159 // LLM activates by Name (what it sees in the "Available Skills" list). 160 args, _ := json.Marshal(map[string]string{"skill_name": "xiaohongshu"}) 161 if _, err := tool.Run(ctx, string(args)); err != nil { 162 t.Fatalf("error: %v", err) 163 } 164 165 names := set.Names() 166 if len(names) != 1 || names[0] != "xiaohongshu-mcp-skills" { 167 t.Errorf("expected activated set to contain the Slug [xiaohongshu-mcp-skills], got %v", names) 168 } 169 } 170 171 // TestUseSkill_ActivationBySlug covers the fallback path: some callers may 172 // address the skill by its Slug (directory name) instead of frontmatter 173 // Name. Both must resolve to the same skill and register the Slug. 174 func TestUseSkill_ActivationBySlug(t *testing.T) { 175 s := &skills.Skill{Name: "xiaohongshu", Slug: "xiaohongshu-mcp-skills", Prompt: "body", Dir: t.TempDir()} 176 skillList := []*skills.Skill{s} 177 tool := newUseSkillTool(&skillList) 178 179 set := skills.NewActivatedSet() 180 ctx := skills.WithActivatedSet(context.Background(), set) 181 182 args, _ := json.Marshal(map[string]string{"skill_name": "xiaohongshu-mcp-skills"}) 183 if _, err := tool.Run(ctx, string(args)); err != nil { 184 t.Fatalf("error: %v", err) 185 } 186 187 names := set.Names() 188 if len(names) != 1 || names[0] != "xiaohongshu-mcp-skills" { 189 t.Errorf("expected activated set to contain [xiaohongshu-mcp-skills], got %v", names) 190 } 191 } 192 193 func TestUseSkill_NoActivatedSetInContext_NoPanic(t *testing.T) { 194 // Tools called without an activated set (e.g. in non-daemon contexts) 195 // must not crash — Add on nil set is a no-op. 196 s := &skills.Skill{Name: "my-skill", Prompt: "body", Dir: t.TempDir()} 197 skillList := []*skills.Skill{s} 198 tool := newUseSkillTool(&skillList) 199 200 args, _ := json.Marshal(map[string]string{"skill_name": "my-skill"}) 201 if _, err := tool.Run(context.Background(), string(args)); err != nil { 202 t.Fatalf("error: %v", err) 203 } 204 }