Core features: - Microkernel architecture with Actor model - Session management with JSONL persistence - Tool system (5 built-in tools) - Skill system with SKILL.md parsing - Sandbox security execution - Ollama integration with gemma4:e4b - Prompt-based tool calling (compatible with native function calling) - REPL interface 11 packages, all tests passing
310 lines
7.6 KiB
Go
310 lines
7.6 KiB
Go
package skill
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// createTestSkill creates a temporary SKILL.md file for testing.
|
|
func createTestSkill(t *testing.T, dir, name, description string, triggers []string, body string) string {
|
|
t.Helper()
|
|
|
|
skillDir := filepath.Join(dir, name)
|
|
if err := os.MkdirAll(skillDir, 0755); err != nil {
|
|
t.Fatalf("failed to create skill dir: %v", err)
|
|
}
|
|
|
|
triggerStr := "[]"
|
|
if len(triggers) > 0 {
|
|
quoted := make([]string, len(triggers))
|
|
for i, tr := range triggers {
|
|
quoted[i] = `"` + tr + `"`
|
|
}
|
|
triggerStr = "[" + strings.Join(quoted, ", ") + "]"
|
|
}
|
|
|
|
manifest := "---\n"
|
|
manifest += "name: " + name + "\n"
|
|
manifest += "description: " + description + "\n"
|
|
manifest += "triggers: " + triggerStr + "\n"
|
|
manifest += "---\n\n"
|
|
manifest += body
|
|
|
|
manifestPath := filepath.Join(skillDir, "SKILL.md")
|
|
if err := os.WriteFile(manifestPath, []byte(manifest), 0644); err != nil {
|
|
t.Fatalf("failed to write SKILL.md: %v", err)
|
|
}
|
|
|
|
return manifestPath
|
|
}
|
|
|
|
func TestNewManager(t *testing.T) {
|
|
m := NewManager("")
|
|
if m == nil {
|
|
t.Fatal("NewManager() returned nil")
|
|
}
|
|
if m.SkillsDir() == "" {
|
|
t.Error("expected non-empty skills directory")
|
|
}
|
|
}
|
|
|
|
func TestNewManagerWithCustomDir(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
m := NewManager(tmpDir)
|
|
|
|
if m.SkillsDir() != tmpDir {
|
|
t.Errorf("expected skills dir %q, got %q", tmpDir, m.SkillsDir())
|
|
}
|
|
}
|
|
|
|
func TestLoadAllNoDirectory(t *testing.T) {
|
|
tmpDir := filepath.Join(t.TempDir(), "nonexistent")
|
|
m := NewManager(tmpDir)
|
|
|
|
count, err := m.LoadAll()
|
|
if err != nil {
|
|
t.Fatalf("LoadAll on nonexistent dir should not error: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Errorf("expected 0 skills, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestLoadAllWithSkills(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
createTestSkill(t, tmpDir, "skill-a", "Skill A", []string{"alpha", "a"}, "# Skill A\nContent")
|
|
createTestSkill(t, tmpDir, "skill-b", "Skill B", []string{"beta", "b"}, "# Skill B\nContent")
|
|
|
|
m := NewManager(tmpDir)
|
|
count, err := m.LoadAll()
|
|
if err != nil {
|
|
t.Fatalf("LoadAll failed: %v", err)
|
|
}
|
|
if count != 2 {
|
|
t.Errorf("expected 2 skills, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestGetSkill(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
createTestSkill(t, tmpDir, "test-skill", "Test Skill", []string{"test"}, "# Test")
|
|
|
|
m := NewManager(tmpDir)
|
|
m.LoadAll()
|
|
|
|
skill, ok := m.GetSkill("test-skill")
|
|
if !ok {
|
|
t.Fatal("GetSkill returned false for existing skill")
|
|
}
|
|
if skill.Name != "test-skill" {
|
|
t.Errorf("expected name 'test-skill', got %q", skill.Name)
|
|
}
|
|
if skill.Description != "Test Skill" {
|
|
t.Errorf("expected description 'Test Skill', got %q", skill.Description)
|
|
}
|
|
}
|
|
|
|
func TestGetSkillNotFound(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
m := NewManager(tmpDir)
|
|
m.LoadAll()
|
|
|
|
_, ok := m.GetSkill("nonexistent")
|
|
if ok {
|
|
t.Error("expected false for nonexistent skill")
|
|
}
|
|
}
|
|
|
|
func TestListSkills(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
createTestSkill(t, tmpDir, "beta", "Beta", nil, "# Beta")
|
|
createTestSkill(t, tmpDir, "alpha", "Alpha", nil, "# Alpha")
|
|
|
|
m := NewManager(tmpDir)
|
|
m.LoadAll()
|
|
|
|
skills := m.ListSkills()
|
|
if len(skills) != 2 {
|
|
t.Errorf("expected 2 skills, got %d", len(skills))
|
|
}
|
|
|
|
// Should be sorted alphabetically
|
|
if len(skills) >= 2 {
|
|
if skills[0].Name != "alpha" {
|
|
t.Errorf("expected first skill 'alpha', got %q", skills[0].Name)
|
|
}
|
|
if skills[1].Name != "beta" {
|
|
t.Errorf("expected second skill 'beta', got %q", skills[1].Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListSkillsEmpty(t *testing.T) {
|
|
m := NewManager(t.TempDir())
|
|
m.LoadAll()
|
|
|
|
skills := m.ListSkills()
|
|
if len(skills) != 0 {
|
|
t.Errorf("expected empty list, got %d skills", len(skills))
|
|
}
|
|
}
|
|
|
|
func TestFindSkill(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
createTestSkill(t, tmpDir, "browser", "Browser automation", []string{"browser", "navigate", "screenshot"}, "# Browser")
|
|
createTestSkill(t, tmpDir, "memory", "Project memory", []string{"memory", "remember"}, "# Memory")
|
|
createTestSkill(t, tmpDir, "convert", "File converter", []string{"convert", "pdf"}, "# Convert")
|
|
|
|
m := NewManager(tmpDir)
|
|
m.LoadAll()
|
|
|
|
// Find by trigger matching "browser"
|
|
results := m.FindSkill("I need to use the browser to navigate")
|
|
if len(results) == 0 {
|
|
t.Fatal("expected at least 1 match for 'browser'")
|
|
}
|
|
|
|
found := false
|
|
for _, s := range results {
|
|
if s.Name == "browser" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected 'browser' skill in results")
|
|
}
|
|
}
|
|
|
|
func TestFindSkillNoMatch(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
createTestSkill(t, tmpDir, "browser", "Browser", []string{"browser"}, "# Browser")
|
|
|
|
m := NewManager(tmpDir)
|
|
m.LoadAll()
|
|
|
|
results := m.FindSkill("completely unrelated query")
|
|
if len(results) != 0 {
|
|
t.Errorf("expected 0 matches, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestFindSkillCaseInsensitive(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
createTestSkill(t, tmpDir, "browser", "Browser", []string{"Browser"}, "# Browser")
|
|
|
|
m := NewManager(tmpDir)
|
|
m.LoadAll()
|
|
|
|
results := m.FindSkill("browser")
|
|
if len(results) != 1 {
|
|
t.Errorf("expected 1 match for lowercase 'browser', got %d", len(results))
|
|
}
|
|
|
|
results = m.FindSkill("BROWSER")
|
|
if len(results) != 1 {
|
|
t.Errorf("expected 1 match for uppercase 'BROWSER', got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestFindSkillRelevanceOrder(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
createTestSkill(t, tmpDir, "multi-match", "Multiple matches", []string{"alpha", "beta", "gamma"}, "# Multi")
|
|
createTestSkill(t, tmpDir, "single-match", "Single match", []string{"alpha"}, "# Single")
|
|
|
|
m := NewManager(tmpDir)
|
|
m.LoadAll()
|
|
|
|
// A query mentioning multiple triggers
|
|
results := m.FindSkill("alpha beta")
|
|
if len(results) != 2 {
|
|
t.Errorf("expected 2 matches, got %d", len(results))
|
|
}
|
|
|
|
// The multi-match skill should come first (more trigger matches)
|
|
if len(results) >= 2 {
|
|
if results[0].Name != "multi-match" {
|
|
t.Errorf("expected 'multi-match' first (more relevance), got %q", results[0].Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReload(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
createTestSkill(t, tmpDir, "initial", "Initial", []string{"init"}, "# Initial")
|
|
|
|
m := NewManager(tmpDir)
|
|
m.LoadAll()
|
|
|
|
if len(m.ListSkills()) != 1 {
|
|
t.Errorf("expected 1 skill after load, got %d", len(m.ListSkills()))
|
|
}
|
|
|
|
// Add another skill
|
|
createTestSkill(t, tmpDir, "added", "Added later", []string{"new"}, "# New")
|
|
|
|
count, err := m.Reload()
|
|
if err != nil {
|
|
t.Fatalf("Reload failed: %v", err)
|
|
}
|
|
if count != 2 {
|
|
t.Errorf("expected 2 skills after reload, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestSkillMatchTrigger(t *testing.T) {
|
|
skill := &Skill{
|
|
Name: "test",
|
|
Triggers: []string{"browser", "navigate"},
|
|
}
|
|
|
|
tests := []struct {
|
|
query string
|
|
want bool
|
|
}{
|
|
{"I need to use the browser", true},
|
|
{"navigate to a page", true},
|
|
{"Browser automation", true},
|
|
{"something unrelated", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := skill.MatchTrigger(tt.query)
|
|
if got != tt.want {
|
|
t.Errorf("MatchTrigger(%q) = %v, want %v", tt.query, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSkillHasScripts(t *testing.T) {
|
|
s1 := &Skill{Name: "no-scripts"}
|
|
if s1.HasScripts() {
|
|
t.Error("expected HasScripts() = false for empty scripts")
|
|
}
|
|
|
|
s2 := &Skill{Name: "has-scripts", Scripts: []string{"script.sh"}}
|
|
if !s2.HasScripts() {
|
|
t.Error("expected HasScripts() = true for non-empty scripts")
|
|
}
|
|
}
|
|
|
|
func TestExpandHome(t *testing.T) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
t.Fatalf("failed to get home dir: %v", err)
|
|
}
|
|
|
|
result := expandHome("~/test/path")
|
|
if !strings.HasPrefix(result, home) {
|
|
t.Errorf("expected path starting with %q, got %q", home, result)
|
|
}
|
|
|
|
// Non-tilde path should not change
|
|
if expandHome("/absolute/path") != "/absolute/path" {
|
|
t.Error("absolute path should not be modified")
|
|
}
|
|
}
|