/ internal / tools / skill_test.go
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  }