fix(studio): match package agent inventory to runtime names

This commit is contained in:
xiexian committed 2026-10-05 03:41:01 +08:00
1 parent 8cfb5f75c7
commit cb1b36d726
12 files changed
+877 -50

No files matched your search

@@ -0,0 +1,56 @@
package pluginpkg
import (
"os"
"path/filepath"
"slices"
"strings"
"reasonix/internal/contract/config"
)
func scanAgentRefs(root *os.Root, dir string, depth int, seen *[]os.FileInfo, out *[]AgentRef) {
info, source := agentPathInfo(root, dir)
if source == nil {
return
}
defer source.Close()
if !info.IsDir() {
return
}
for _, previous := range *seen {
if os.SameFile(previous, info) {
return
}
}
*seen = append(*seen, info)
entries, err := source.ReadDir(-1)
if err != nil {
return
}
slices.SortFunc(entries, func(a, b os.DirEntry) int { return strings.Compare(a.Name(), b.Name()) })
for _, entry := range entries {
path := filepath.Join(dir, entry.Name())
entryInfo, handle := agentPathInfo(root, path)
if handle == nil {
continue
}
handle.Close()
var ref AgentRef
var ok bool
if entryInfo.IsDir() {
ref, ok = parseAgentRef(root, filepath.Join(path, "SKILL.md"), entry.Name())
} else if strings.EqualFold(filepath.Ext(entry.Name()), ".md") {
ref, ok = parseAgentRef(root, path, strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())))
}
if ok {
if depth == 1 || ref.Description != "" {
*out = append(*out, ref)
}
continue
}
if entryInfo.IsDir() && depth < (&config.Config{}).SkillMaxDepth() && !shouldSkipSkillScanDir(entry.Name()) {
scanAgentRefs(root, path, depth+1, seen, out)
}
}
}
@@ -0,0 +1,241 @@
package pluginpkg
import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"reasonix/internal/base/testenv"
)
func TestDeclaredAgentDirectoryInventory(t *testing.T) {
for _, tc := range []struct {
name, manifest, body string
}{
{"native-v2", NativeManifest, `{"apiVersion":"reasonix.io/plugin/v2","name":"directory-agents","contributes":{"agents":["agents"]}}`},
{"claude-convention", ClaudeManifest, `{"name":"directory-agents"}`},
} {
t.Run(tc.name, func(t *testing.T) {
root := testenv.TempDir(t)
writeTestFile(t, filepath.Join(root, tc.manifest), tc.body)
writeTestFile(t, filepath.Join(root, "agents", "review", "SKILL.md"), "---\nname: inspect\ndescription: Inspect selected files\nmodel: custom-model\ntools: [Read, Grep]\n---\nInspect files.")
writeTestFile(t, filepath.Join(root, "agents", "fallback", "SKILL.md"), "---\ndescription: Use directory name\nallowed-tools: [Read]\ntools: [Write]\n---\nInspect files.")
writeTestFile(t, filepath.Join(root, "agents", "flat.md"), "---\ndescription: Flat profile\n---\nInspect files.")
writeTestFile(t, filepath.Join(root, "agents", "notes", "README.md"), "Author notes.")
writeTestFile(t, filepath.Join(root, "agents", "invalid name", "SKILL.md"), "---\nname: valid\n---\nInvalid directory.")
pkg, _, err := ParseDir(root)
if err != nil {
t.Fatal(err)
}
agents := pkg.Inventory().Agents
if len(agents) != 3 || pkg.AgentCount() != 3 {
t.Fatalf("agents=%+v, count=%d", agents, pkg.AgentCount())
}
if agents[0].Name != "fallback" || agents[1].Name != "flat" || agents[2].Name != "inspect" {
t.Fatalf("agents=%+v", agents)
}
if !reflect.DeepEqual(agents[0].AllowedTools, []string{"Read"}) {
t.Fatalf("directory allowed-tools=%v", agents[0].AllowedTools)
}
agent := agents[2]
if agent.Path != filepath.Join(root, "agents", "review", "SKILL.md") ||
agent.Description != "Inspect selected files" || agent.Model != "custom-model" ||
!reflect.DeepEqual(agent.AllowedTools, []string{"Read", "Grep"}) {
t.Fatalf("directory agent=%+v", agent)
}
})
}
}
func TestClaudeAgentDirectoryDoesNotExpandConventionDiscovery(t *testing.T) {
root := testenv.TempDir(t)
writeTestFile(t, filepath.Join(root, ClaudeManifest), `{"name":"directory-agents"}`)
writeTestFile(t, filepath.Join(root, "agents", "review", "SKILL.md"), "---\ndescription: Inspect files\n---\nInspect files.")
pkg, _, err := ParseDir(root)
if err != nil {
t.Fatal(err)
}
if len(pkg.AgentRoots()) != 0 || len(pkg.Inventory().Agents) != 0 {
t.Fatalf("undeclared directory adopted: %+v", pkg)
}
}
func TestDeclaredNestedAgentInventory(t *testing.T) {
for _, manifest := range []string{NativeManifest, ClaudeManifest} {
t.Run(manifest, func(t *testing.T) {
root := testenv.TempDir(t)
body := `{"apiVersion":"reasonix.io/plugin/v2","name":"nested-agents","contributes":{"agents":["agents"]}}`
if manifest == ClaudeManifest {
body = `{"name":"nested-agents"}`
}
writeTestFile(t, filepath.Join(root, manifest), body)
writeTestFile(t, filepath.Join(root, "agents", "seed.md"), "---\ndescription: Root convention marker\n---\nReview.")
writeTestFile(t, filepath.Join(root, "agents", "group", "review", "SKILL.md"), "---\ndescription: Nested directory review\nmodel: custom-model\nallowed-tools: [Read, Grep]\ntools: [Write]\n---\nReview.")
writeTestFile(t, filepath.Join(root, "agents", "group", "flat.md"), "---\ndescription: Nested flat review\n---\nReview.")
writeTestFile(t, filepath.Join(root, "agents", "group", "README.md"), "Author notes.")
writeTestFile(t, filepath.Join(root, "agents", "scripts", "ignored.md"), "---\ndescription: Resource\n---\nIgnored.")
writeTestFile(t, filepath.Join(root, "agents", "group", "review", "resources.md"), "---\ndescription: Profile resource\n---\nIgnored.")
pkg, _, err := ParseDir(root)
if err != nil {
t.Fatal(err)
}
agents := pkg.Inventory().Agents
if len(agents) != 3 || pkg.AgentCount() != 3 || agents[0].Name != "flat" || agents[1].Name != "review" || agents[2].Name != "seed" {
t.Fatalf("nested inventory=%+v, count=%d", agents, pkg.AgentCount())
}
if got := agents[1]; got.Path != filepath.Join(root, "agents", "group", "review", "SKILL.md") || got.Description != "Nested directory review" || got.Model != "custom-model" || !reflect.DeepEqual(got.AllowedTools, []string{"Read", "Grep"}) {
t.Fatalf("nested metadata=%+v", got)
}
})
}
}
func TestNestedAgentInventoryStopsInternalSymlinkCycle(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires host privileges")
}
root := testenv.TempDir(t)
writeTestFile(t, filepath.Join(root, NativeManifest), `{"apiVersion":"reasonix.io/plugin/v2","name":"nested-agents","contributes":{"agents":["agents"]}}`)
writeTestFile(t, filepath.Join(root, "agents", "group", "review", "SKILL.md"), "---\ndescription: Nested review\n---\nReview.")
if err := os.Symlink("..", filepath.Join(root, "agents", "group", "loop")); err != nil {
t.Fatal(err)
}
pkg, _, err := ParseDir(root)
if err != nil {
t.Fatal(err)
}
if agents := pkg.Inventory().Agents; len(agents) != 1 || agents[0].Name != "review" {
t.Fatalf("cycle inventory=%+v", agents)
}
}
func TestDeclaredDirectoryAgentRuntimeNames(t *testing.T) {
for _, tc := range []struct{ directory, declared, want string }{
{"中文", "", "中文"},
{"cafe\u0301", "", "café"},
{"中文", "审查", "审查"},
{"review", "审查", "review"},
{"中文", "inspect", "inspect"},
} {
t.Run(tc.directory+"/"+tc.declared, func(t *testing.T) {
root := testenv.TempDir(t)
writeTestFile(t, filepath.Join(root, NativeManifest), `{"apiVersion":"reasonix.io/plugin/v2","name":"directory-agents","contributes":{"agents":["agents"]}}`)
writeTestFile(t, filepath.Join(root, "agents", "group", tc.directory, "SKILL.md"), "---\nname: "+tc.declared+"\ndescription: Directory profile\n---\nReview.")
pkg, _, err := ParseDir(root)
if err != nil {
t.Fatal(err)
}
if agents := pkg.Inventory().Agents; len(agents) != 1 || agents[0].Name != tc.want || agents[0].Invocation != "/"+tc.want {
t.Fatalf("agents=%+v, want %q", agents, tc.want)
}
})
}
}
func TestAgentInventoryChecksResolvedResidency(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation requires host privileges")
}
root, outside := testenv.TempDir(t), testenv.TempDir(t)
writeTestFile(t, filepath.Join(root, NativeManifest), `{"apiVersion":"reasonix.io/plugin/v2","name":"directory-agents","contributes":{"agents":["agents"]}}`)
writeTestFile(t, filepath.Join(root, "profiles", "resident.md"), "---\ndescription: Resident fixture\n---\nReview.")
writeTestFile(t, filepath.Join(outside, "profile.md"), "---\ndescription: Nonresident fixture\n---\nReview.")
if err := os.MkdirAll(filepath.Join(root, "empty"), 0o755); err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ name, target string }{
{"resident", filepath.Join("..", "..", "profiles", "resident.md")},
{"absolute-resident", filepath.Join(root, "profiles", "resident.md")},
{"nonresident", filepath.Join(outside, "profile.md")},
{"nonregular", filepath.Join("..", "..", "empty")},
{"broken", filepath.Join("..", "..", "missing.md")},
} {
path := filepath.Join(root, "agents", tc.name, "SKILL.md")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if tc.name == "nonresident" {
var err error
tc.target, err = filepath.Rel(filepath.Dir(path), tc.target)
if err != nil {
t.Fatal(err)
}
}
if err := os.Symlink(tc.target, path); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink(outside, filepath.Join(root, "agents", "nonresident-directory")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(outside, "profile.md"), filepath.Join(root, "agents", "nonresident-flat.md")); err != nil {
t.Fatal(err)
}
pkg, _, err := ParseDir(root)
if err != nil {
t.Fatal(err)
}
if agents := pkg.Inventory().Agents; len(agents) != 1 || agents[0].Name != "resident" {
t.Fatalf("resident-only inventory=%+v", agents)
}
}
func TestAgentDirectoryScanMatchesDefaultRuntimeDepth(t *testing.T) {
root := testenv.TempDir(t)
writeTestFile(t, filepath.Join(root, NativeManifest), `{"apiVersion":"reasonix.io/plugin/v2","name":"bounded-agents","contributes":{"agents":["agents"]}}`)
for _, rel := range []string{"agents/top.md", "agents/group/empty.md", "agents/group/child/limit.md", "agents/group/child/deeper/ignored.md"} {
body := "---\ndescription: Depth fixture\n---\nBODY"
if filepath.Base(rel) == "empty.md" {
body = "No description."
}
writeTestFile(t, filepath.Join(root, rel), body)
}
pkg, _, err := ParseDir(root)
if err != nil {
t.Fatal(err)
}
if agents := pkg.Inventory().Agents; len(agents) != 2 || agents[0].Name != "limit" || agents[1].Name != "top" {
t.Fatalf("default-depth inventory=%+v", agents)
}
}
func TestDirectoryAgentRefsUseBorrowedRoot(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("moving an open root requires POSIX directory semantics")
}
parent := testenv.TempDir(t)
rootPath := filepath.Join(parent, "package")
writeTestFile(t, filepath.Join(rootPath, "agents", "original", "SKILL.md"), "---\ndescription: Original profile\n---\nBODY")
root, err := os.OpenRoot(rootPath)
if err != nil {
t.Fatal(err)
}
defer root.Close()
if err := os.Rename(rootPath, filepath.Join(parent, "moved")); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(rootPath, "agents", "replacement", "SKILL.md"), "---\ndescription: Replacement profile\n---\nBODY")
for range 2 {
agents := loadAgentRefs(root, "agents")
if len(agents) != 1 || agents[0].Name != "original" || agents[0].Description != "Original profile" {
t.Fatalf("borrowed-root directory inventory=%+v", agents)
}
}
}
func TestDirectoryAgentSourcesRemainBounded(t *testing.T) {
root := testenv.TempDir(t)
writeTestFile(t, filepath.Join(root, NativeManifest), `{"apiVersion":"reasonix.io/plugin/v2","name":"bounded-agents","contributes":{"agents":["agents"]}}`)
writeTestFile(t, filepath.Join(root, "agents", "small", "SKILL.md"), "---\ndescription: Ordinary profile\n---\nBODY")
writeTestFile(t, filepath.Join(root, "agents", "large", "SKILL.md"), strings.Repeat("x", maxAgentSourceBytes+1))
pkg, _, err := ParseDir(root)
if err != nil {
t.Fatal(err)
}
if agents := pkg.Inventory().Agents; len(agents) != 1 || agents[0].Name != "small" {
t.Fatalf("bounded directory sources=%+v", agents)
}
}
+38
View File
@@ -0,0 +1,38 @@
package pluginpkg
import (
"fmt"
"slices"
"strings"
)
func (p Package) agentRefs() []AgentRef {
refs, _ := p.agentRefsWithWarnings()
return refs
}
func (p Package) agentNameWarnings() []string {
_, warnings := p.agentRefsWithWarnings()
return warnings
}
func (p Package) selectAgentRefs(refs []AgentRef) ([]AgentRef, []string) {
byName := map[string]string{}
seenPaths := map[string]bool{}
var warnings []string
filtered := refs[:0]
for _, ref := range refs {
if seenPaths[ref.Path] {
continue
}
seenPaths[ref.Path] = true
if previous, exists := byName[ref.Name]; exists {
warnings = append(warnings, fmt.Sprintf("agent name %q uses %s; %s is shadowed", ref.Name, RelativeRoot(p.Root, previous), RelativeRoot(p.Root, ref.Path)))
continue
}
byName[ref.Name] = ref.Path
filtered = append(filtered, ref)
}
slices.SortStableFunc(filtered, func(a, b AgentRef) int { return strings.Compare(a.Name, b.Name) })
return filtered, warnings
}
+178
View File
@@ -0,0 +1,178 @@
package pluginpkg_test
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/ext/pluginpkg"
"reasonix/internal/ext/skill"
)
func TestPackageAgentNamesMatchRuntime(t *testing.T) {
for _, tc := range []struct{ label, stem, frontName, want string }{
{"Chinese", "审查", "", "审查"},
{"Japanese", "点検", "点検", "点検"},
{"decomposed stem", "cafe\u0301", "", "café"},
{"Unicode override", "审查", "点検", "点検"},
{"decomposed override", "审查", "re\u0301vision", "révision"},
{"ASCII stem compatibility", "review", "审查", "review"},
{"ASCII override", "review", "inspect", "inspect"},
{"64 runes", strings.Repeat("审", 64), "", strings.Repeat("审", 64)},
{"65 runes", strings.Repeat("审", 65), "", ""},
{"leading mark", "\u0301review", "", ""},
{"invisible stem", "rev\u200diew", "", ""},
{"variation selector", "审\ufe0f", "", ""},
{"invalid override", "审查", "bad/name", "审查"},
{"invalid stem with override", "bad name", "inspect", ""},
} {
t.Run(tc.label, func(t *testing.T) {
root, home := testenv.TempDir(t), testenv.TempDir(t)
write := func(rel, body string) {
t.Helper()
path := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
write(pluginpkg.NativeManifest, `{"apiVersion":"reasonix.io/plugin/v2","name":"names-kit","contributes":{"agents":["agents"]}}`)
write(filepath.Join("agents", tc.stem+".md"), "---\nname: "+tc.frontName+"\ndescription: Agent name fixture\n---\nBODY")
pkg, warnings, err := pluginpkg.ParseDir(root)
if err != nil || len(warnings) != 0 {
t.Fatalf("ParseDir: warnings=%v err=%v", warnings, err)
}
owners := map[string][]string{pkg.AgentRoots()[0]: {"names-kit"}}
runtime := skill.New(skill.Options{HomeDir: home, CustomPaths: pkg.AgentRoots(), PluginPaths: owners, PluginAgentPaths: owners, DisableBuiltins: true}).List()
inventory := pkg.Inventory().Agents
if tc.want == "" {
if len(runtime) != 0 || len(inventory) != 0 || pkg.AgentCount() != 0 {
t.Fatalf("invalid stem accepted: runtime=%+v inventory=%+v", runtime, inventory)
}
return
}
if len(runtime) != 1 || runtime[0].Name != tc.want || runtime[0].RunAs != skill.RunSubagent || runtime[0].SlashName() != "names-kit:agent:"+tc.want {
t.Fatalf("runtime=%+v, want names-kit:agent:%s", runtime, tc.want)
}
if len(inventory) != 1 || inventory[0].Name != tc.want || inventory[0].Invocation != "/"+tc.want || pkg.AgentCount() != 1 {
t.Fatalf("inventory=%+v, want /%s", inventory, tc.want)
}
if inventory[0].Path != runtime[0].Path {
t.Fatalf("inventory path=%q runtime path=%q", inventory[0].Path, runtime[0].Path)
}
})
}
}
func TestPackageCanonicalAgentCollisionMatchesRuntime(t *testing.T) {
for _, tc := range []struct {
label, roots, first, second string
frontNames bool
}{
{"frontmatter names", `["agents"]`, "agents/第一.md", "agents/第二.md", true},
{"composed first across roots", `["second","first"]`, "first/café.md", "second/cafe\u0301.md", false},
{"decomposed first across roots", `["second","first"]`, "first/cafe\u0301.md", "second/café.md", false},
{"nested before flat", `["agents"]`, "agents/a-group/café.md", "agents/cafe\u0301.md", false},
{"directory before flat", `["agents"]`, "agents/第一/SKILL.md", "agents/第二.md", true},
} {
t.Run(tc.label, func(t *testing.T) {
root, home := testenv.TempDir(t), testenv.TempDir(t)
write := func(rel, body string) {
t.Helper()
path := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
write(pluginpkg.NativeManifest, fmt.Sprintf(`{"apiVersion":"reasonix.io/plugin/v2","name":"names-kit","contributes":{"agents":%s}}`, tc.roots))
firstBody, secondBody := "---\ndescription: First fixture\n---\nFIRST", "---\ndescription: Second fixture\n---\nSECOND"
if tc.frontNames {
firstBody = "---\nname: café\ndescription: First fixture\n---\nFIRST"
secondBody = "---\nname: cafe\u0301\ndescription: Second fixture\n---\nSECOND"
}
write(tc.first, firstBody)
write(tc.second, secondBody)
pkg, warnings, err := pluginpkg.ParseDir(root)
if err != nil {
t.Fatal(err)
}
owners := map[string][]string{}
for _, path := range pkg.AgentRoots() {
owners[path] = []string{"names-kit"}
}
store := skill.New(skill.Options{HomeDir: home, ReasonixHomeDir: home, CustomPaths: pkg.AgentRoots(), PluginPaths: owners, PluginAgentPaths: owners, DisableBuiltins: true, Stderr: io.Discard})
winner, ok := store.ReadSlash("names-kit:agent:café")
if !ok || winner.Body != "FIRST" {
t.Fatalf("actual runtime winner=%+v found=%t", winner, ok)
}
t.Logf("actual runtime selected %s: %s", pluginpkg.RelativeRoot(root, winner.Path), winner.Body)
inventory := pkg.Inventory().Agents
if len(inventory) != 1 || pkg.AgentCount() != 1 || inventory[0].Path != winner.Path || inventory[0].Name != winner.Name || inventory[0].Description != winner.Description {
t.Fatalf("inventory=%+v, actual runtime winner=%+v", inventory, winner)
}
loserDir := filepath.Join(root, filepath.Dir(tc.second))
entries, err := os.ReadDir(loserDir)
if err != nil {
t.Fatal(err)
}
loserInfo, err := os.Stat(filepath.Join(root, tc.second))
if err != nil {
t.Fatal(err)
}
var loser string
for _, entry := range entries {
info, err := entry.Info()
if err == nil && os.SameFile(info, loserInfo) {
loser = filepath.Join(loserDir, entry.Name())
}
}
if loser == "" {
t.Fatal("fixture loser not found")
}
want := fmt.Sprintf("agent name %q uses %s; %s is shadowed", winner.Name, pluginpkg.RelativeRoot(root, winner.Path), pluginpkg.RelativeRoot(root, loser))
if len(warnings) != 1 || warnings[0] != want {
t.Fatalf("warnings=%v, want %q", warnings, want)
}
})
}
}
func TestPackageAllowsRepeatedAgentPathAndSeparateSkillName(t *testing.T) {
root := testenv.TempDir(t)
for _, rel := range []string{"agents/review.md", "skills/review/SKILL.md"} {
path := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("---\ndescription: Separate namespace fixture\n---\nBODY"), 0o600); err != nil {
t.Fatal(err)
}
}
manifest := `{"apiVersion":"reasonix.io/plugin/v2","name":"names-kit","contributes":{"agents":["agents","agents"],"skills":["skills"]}}`
if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
pkg, warnings, err := pluginpkg.ParseDir(root)
if err != nil || len(warnings) != 0 {
t.Fatalf("repeated path and separate namespace: warnings=%v err=%v", warnings, err)
}
inv := pkg.Inventory()
if len(inv.Skills) != 1 || inv.Skills[0].Name != "review" || len(inv.Agents) != 1 {
t.Fatalf("separate namespaces inventory = %+v", inv)
}
for _, ref := range inv.Agents {
if ref.Name != "review" || ref.Path != filepath.Join(root, "agents", "review.md") {
t.Fatalf("repeated declaration changed its source: %+v", ref)
}
}
}
+8 -2
View File
@@ -17,8 +17,14 @@ func applyClaudeAgentDirs(path string, manifest *Manifest) {
}
defer root.Close()
for _, rel := range claudeConventionAgentDirs {
if len(loadAgentRefs(root, filepath.FromSlash(rel))) > 0 && !containsPathEntry(manifest.Agents, rel) {
manifest.Agents = append(manifest.Agents, rel)
if containsPathEntry(manifest.Agents, rel) {
continue
}
for _, ref := range loadAgentRefs(root, filepath.FromSlash(rel)) {
if filepath.Dir(ref.Path) == filepath.Join(path, filepath.FromSlash(rel)) {
manifest.Agents = append(manifest.Agents, rel)
break
}
}
}
}
@@ -3,6 +3,7 @@
package pluginpkg
import (
"os"
"path/filepath"
"syscall"
"testing"
@@ -17,6 +18,12 @@ func TestAgentInventorySkipsNamedPipeSources(t *testing.T) {
if err := syscall.Mkfifo(filepath.Join(root, "agents", "pipe.md"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "agents", "directory-pipe"), 0o755); err != nil {
t.Fatal(err)
}
if err := syscall.Mkfifo(filepath.Join(root, "agents", "directory-pipe", "SKILL.md"), 0o600); err != nil {
t.Fatal(err)
}
pkg, _, err := ParseDir(root)
if err != nil {
t.Fatal(err)
+30 -44
View File
@@ -9,7 +9,6 @@ import (
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
fileencoding "reasonix/internal/base/fileutil/encoding"
@@ -479,10 +478,10 @@ func compatibilityFor(pkg Package, issues []CompatibilityIssue) Compatibility {
return Compatibility{Status: status, Mapped: mapped, Skipped: issues}
}
func (p Package) agentRefs() []AgentRef {
func (p Package) agentRefsWithWarnings() ([]AgentRef, []string) {
root, err := os.OpenRoot(p.Root)
if err != nil {
return nil
return nil, nil
}
defer root.Close()
var dirs []string
@@ -498,57 +497,44 @@ func (p Package) agentRefs() []AgentRef {
for _, dir := range dirs {
out = append(out, loadAgentRefs(root, dir)...)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
return p.selectAgentRefs(out)
}
func loadAgentRefs(root *os.Root, dir string) []AgentRef {
if !filepath.IsLocal(dir) {
return nil
}
info, source := agentPathInfo(root, dir)
if source == nil {
return nil
}
defer source.Close()
if !info.IsDir() {
return nil
}
entries, err := source.ReadDir(-1)
if err != nil {
return nil
}
slices.SortFunc(entries, func(a, b os.DirEntry) int { return strings.Compare(a.Name(), b.Name()) })
var out []AgentRef
for _, entry := range entries {
if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".md") {
continue
}
path := filepath.Join(root.Name(), dir, entry.Name())
body := agentSourceBody(root, filepath.Join(dir, entry.Name()))
if body == nil {
continue
}
fm, _ := frontmatter.SplitLegacy(string(body))
name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
if declared := strings.TrimSpace(fm["name"]); IsValidName(declared) {
name = declared
}
if !IsValidName(name) {
continue
}
out = append(out, AgentRef{
Name: name,
Description: strings.TrimSpace(fm["description"]),
Path: path,
Invocation: "/" + name,
Model: strings.TrimSpace(fm["model"]),
AllowedTools: splitCSV(fm["tools"]),
})
}
var seen []os.FileInfo
scanAgentRefs(root, dir, 1, &seen, &out)
return out
}
func parseAgentRef(root *os.Root, path, stem string) (AgentRef, bool) {
if !config.IsValidSkillName(stem) {
return AgentRef{}, false
}
body := agentSourceBody(root, path)
if body == nil {
return AgentRef{}, false
}
content := strings.TrimPrefix(strings.ReplaceAll(string(body), "\r\n", "\n"), "\uFEFF")
fm, _ := frontmatter.SplitLegacy(content)
name := config.ResolveSkillName(stem, fm["name"])
tools := fm["allowed-tools"]
if strings.TrimSpace(tools) == "" {
tools = fm["tools"]
}
return AgentRef{
Name: name,
Description: strings.TrimSpace(fm["description"]),
Path: filepath.Join(root.Name(), path),
Invocation: "/" + name,
Model: strings.TrimSpace(fm["model"]),
AllowedTools: splitCSV(tools),
}, true
}
func splitCSV(raw string) []string {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "[") && strings.HasSuffix(raw, "]") {
+6 -3
View File
@@ -396,17 +396,20 @@ func ParseDir(root string) (Package, []string, error) {
// error (a v1 typo names its field path); only a missing file falls
// through to the next manifest kind.
if pkg, warnings, err := parseNative(filepath.Join(root, NativeManifest), root); err == nil {
return pkg, append(warnings, pkg.skillNameWarnings()...), nil
warnings = append(warnings, pkg.skillNameWarnings()...)
return pkg, append(warnings, pkg.agentNameWarnings()...), nil
} else if !errors.Is(err, os.ErrNotExist) {
return Package{}, nil, err
}
if pkg, warnings, err := parseCodex(filepath.Join(root, CodexManifest), root); err == nil {
return pkg, append(warnings, pkg.skillNameWarnings()...), nil
warnings = append(warnings, pkg.skillNameWarnings()...)
return pkg, append(warnings, pkg.agentNameWarnings()...), nil
} else if !errors.Is(err, os.ErrNotExist) {
return Package{}, nil, err
}
if pkg, warnings, err := parseClaudePlugin(filepath.Join(root, ClaudeManifest), root); err == nil {
return pkg, append(warnings, pkg.skillNameWarnings()...), nil
warnings = append(warnings, pkg.skillNameWarnings()...)
return pkg, append(warnings, pkg.agentNameWarnings()...), nil
} else if !errors.Is(err, os.ErrNotExist) {
return Package{}, nil, err
}
+2 -1
View File
@@ -16,6 +16,7 @@ func TestPluginShowAgentOnlyPackage(t *testing.T) {
writePluginTestFile(t, filepath.Join(root, pluginpkg.NativeManifest), `{"apiVersion":"reasonix.io/plugin/v2","name":"orientation","contributes":{"agents":["agents"]}}`)
writePluginTestFile(t, filepath.Join(root, "agents", "map.md"), "---\nname: map\ndescription: Map the\n selected files\n---\nRead the files.")
writePluginTestFile(t, filepath.Join(root, "agents", "check.md"), "---\nname: check\n---\nCheck the files.")
writePluginTestFile(t, filepath.Join(root, "agents", "group", "nested", "SKILL.md"), "---\ndescription: Inspect nested files\n---\nCheck the files.")
for _, enabled := range []bool{true, false} {
if err := pluginpkg.Upsert(home, pluginpkg.InstalledPlugin{Name: "orientation", Root: "plugins/orientation", ManifestKind: "reasonix", Enabled: enabled}); err != nil {
t.Fatal(err)
@@ -25,7 +26,7 @@ func TestPluginShowAgentOnlyPackage(t *testing.T) {
t.Fatalf("plugin show rc = %d", rc)
}
})
for _, want := range []string{"agents: 2", "agents:\n", "/orientation:agent:map\tMap the selected files", "/orientation:agent:check\t(no description)"} {
for _, want := range []string{"agents: 3", "agents:\n", "/orientation:agent:map\tMap the selected files", "/orientation:agent:check\t(no description)", "/orientation:agent:nested\tInspect nested files"} {
if !strings.Contains(out, want) {
t.Errorf("enabled=%t: show missing %q:\n%s", enabled, want, out)
}
@@ -0,0 +1,116 @@
package serve
import (
"io"
"net/http"
"path/filepath"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/ext/pluginpkg"
"reasonix/internal/ext/skill"
)
func TestPluginInstalledAgentDirectoryMatchesRuntime(t *testing.T) {
home, _, base := pluginHome(t)
source := testenv.TempDir(t)
writePluginFile(t, filepath.Join(source, "reasonix-plugin.json"), `{"apiVersion":"reasonix.io/plugin/v2","name":"directory-agents","contributes":{"agents":["agents"]}}`)
writePluginFile(t, filepath.Join(source, "agents", "group", "review", "SKILL.md"), "---\nname: inspect\ndescription: Inspect selected files\nmodel: custom-model\ntools: [Read, Grep]\n---\nInspect the selected files.")
resp := postJSON(t, base+"/plugins/plan", map[string]any{"source": source})
plan := decodeInstallSource(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("plan status=%d: %v", resp.StatusCode, plan)
}
resp = postJSON(t, base+"/plugins/install", map[string]any{"source": source, "planId": plan["planId"]})
result := decodeInstallSource(t, resp)
if resp.StatusCode != http.StatusOK || result["status"] != "done" {
t.Fatalf("install status=%d: %v", resp.StatusCode, result)
}
store := func() *skill.Store {
t.Helper()
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
return skill.New(skill.Options{
HomeDir: home, ReasonixHomeDir: home, CustomPaths: cfg.SkillCustomPaths(),
PluginPaths: cfg.PluginPackageSkillOwners(), PluginAgentPaths: cfg.PluginPackageAgentOwners(),
DisableBuiltins: true, Stderr: io.Discard,
})
}
profile, ok := store().ReadSlash("directory-agents:agent:inspect")
if !ok || profile.RunAs != skill.RunSubagent || profile.Invocation != "manual" {
t.Fatalf("runtime profile=%+v, found=%t", profile, ok)
}
if profile.Path != filepath.Join(pluginpkg.InstallRoot(home, "directory-agents"), "agents", "group", "review", "SKILL.md") {
t.Fatalf("runtime profile path=%q", profile.Path)
}
checkInventory := func(enabled bool) {
t.Helper()
plugins := getPlugins(t, base)
if len(plugins) != 1 || plugins[0].Enabled != enabled || len(plugins[0].Agents) != 1 {
t.Fatalf("plugins=%+v", plugins)
}
agent := plugins[0].Agents[0]
if agent.Name != profile.Name || agent.Description != profile.Description || agent.Invocation != "/"+profile.SlashName() {
t.Fatalf("inventory agent=%+v, runtime profile=%+v", agent, profile)
}
}
checkInventory(true)
resp = postJSON(t, base+"/plugins/enabled", map[string]any{"name": "directory-agents", "enabled": false})
if resp.StatusCode != http.StatusOK {
t.Fatalf("disable status=%d", resp.StatusCode)
}
checkInventory(false)
if profile, ok := store().ReadSlash("directory-agents:agent:inspect"); ok {
t.Fatalf("disabled profile remains available: %+v", profile)
}
}
func TestPluginInstalledUnicodeDirectoryAgentMatchesRuntime(t *testing.T) {
home, _, base := pluginHome(t)
source := testenv.TempDir(t)
writePluginFile(t, filepath.Join(source, "reasonix-plugin.json"), `{"apiVersion":"reasonix.io/plugin/v2","name":"unicode-directory","contributes":{"agents":["agents"]}}`)
for _, name := range []string{"中文", "cafe\u0301"} {
writePluginFile(t, filepath.Join(source, "agents", "group", name, "SKILL.md"), "---\ndescription: Directory fixture\n---\nReview.")
}
resp := postJSON(t, base+"/plugins/plan", map[string]any{"source": source})
plan := decodeInstallSource(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("plan status=%d: %v", resp.StatusCode, plan)
}
resp = postJSON(t, base+"/plugins/install", map[string]any{"source": source, "planId": plan["planId"]})
result := decodeInstallSource(t, resp)
if resp.StatusCode != http.StatusOK || result["status"] != "done" {
t.Fatalf("install status=%d: %v", resp.StatusCode, result)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
store := skill.New(skill.Options{HomeDir: home, ReasonixHomeDir: home, CustomPaths: cfg.SkillCustomPaths(),
PluginPaths: cfg.PluginPackageSkillOwners(), PluginAgentPaths: cfg.PluginPackageAgentOwners(), DisableBuiltins: true, Stderr: io.Discard})
profiles := map[string]skill.Skill{}
for _, name := range []string{"中文", "café"} {
profile, ok := store.ReadSlash("unicode-directory:agent:" + name)
if !ok || profile.RunAs != skill.RunSubagent || profile.Invocation != "manual" {
t.Fatalf("runtime profile=%+v, found=%t", profile, ok)
}
profiles[name] = profile
}
plugins := getPlugins(t, base)
if len(plugins) != 1 || len(plugins[0].Agents) != 2 {
t.Fatalf("plugins=%+v", plugins)
}
for _, agent := range plugins[0].Agents {
profile, ok := profiles[agent.Name]
if !ok || agent.Invocation != "/"+profile.SlashName() || agent.Description != profile.Description {
t.Fatalf("inventory agent=%+v, runtime=%+v", agent, profile)
}
delete(profiles, agent.Name)
}
if len(profiles) != 0 {
t.Fatalf("profiles omitted from inventory: %v", profiles)
}
}
@@ -28,6 +28,17 @@ func TestLinkedPackageAgentInventorySkipsNonresidentSources(t *testing.T) {
if err := os.Symlink(filepath.Join(outside, "profile.md"), filepath.Join(source, "agents", "outside.md")); err != nil {
t.Fatal(err)
}
directorySource := filepath.Join(source, "agents", "outside-directory", "SKILL.md")
if err := os.MkdirAll(filepath.Dir(directorySource), 0o755); err != nil {
t.Fatal(err)
}
target, err := filepath.Rel(filepath.Dir(directorySource), filepath.Join(outside, "profile.md"))
if err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, directorySource); err != nil {
t.Fatal(err)
}
pkg, _, err := pluginpkg.ParseDir(source)
if err != nil {
t.Fatal(err)
@@ -0,0 +1,184 @@
package serve
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"reasonix/internal/assembly/boot"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/contract/provider"
"reasonix/internal/ext/pluginpkg"
"reasonix/internal/ext/skill"
"reasonix/internal/runtime/agent/testutil"
"reasonix/internal/session/control"
)
var unicodeAgentProviderSeq atomic.Uint64
func TestInstalledUnicodeAgentsMatchDisplayedInvocations(t *testing.T) {
for _, format := range []struct{ kind, manifest, body string }{
{"native", pluginpkg.NativeManifest, `{"apiVersion":"reasonix.io/plugin/v2","name":"unicode-agents","contributes":{"agents":["agents"]}}`},
{"Claude", pluginpkg.ClaudeManifest, `{"name":"unicode-agents"}`},
} {
t.Run(format.kind, func(t *testing.T) {
home, root, source := testenv.TempDir(t), testenv.TempDir(t), testenv.TempDir(t)
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_STATE_HOME", home)
t.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
t.Chdir(root)
kind := fmt.Sprintf("unicode-plugin-agents-%d", unicodeAgentProviderSeq.Add(1))
rec := testutil.NewMock(kind)
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return rec, nil })
writePluginFile(t, filepath.Join(root, "reasonix.toml"), `
default_model = "fixture"
[environment]
enabled = false
[codegraph]
enabled = false
[[providers]]
name = "fixture"
kind = "`+kind+`"
model = "x"
`)
writePluginFile(t, filepath.Join(source, format.manifest), format.body)
for _, stem := range []string{"审查", "点検", "cafe\u0301"} {
writePluginFile(t, filepath.Join(source, "agents", stem+".md"), "---\ndescription: Unicode agent fixture\n---\nAGENT BODY")
}
if _, err := (config.Roots{}).ApproveWorkspacePrograms(root); err != nil {
t.Fatal(err)
}
bc := NewBroadcaster()
initial, err := boot.BuildRuntime(t.Context(), boot.Options{Sink: bc, WorkspaceRoot: root})
if err != nil {
t.Fatal(err)
}
s := New(initial.Controller, bc, config.ServeConfig{})
s.AdoptRuntime(initial)
leases := control.NewSessionLeaseKeeper()
s.SetSessionLeases(leases)
t.Cleanup(leases.Release)
t.Cleanup(func() { s.ctl().Close() })
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
args := map[string]any{"source": source, "kind": "plugin", "mode": "copy"}
for _, endpoint := range []string{"/plugins/plan", "/plugins/install"} {
resp := postJSON(t, srv.URL+endpoint, args)
out := decodeInstallSource(t, resp)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || out["ok"] != true || out["reloadError"] != nil {
t.Fatalf("%s = %d: %v", endpoint, resp.StatusCode, out)
}
args["planId"] = out["planId"]
if endpoint == "/plugins/plan" && len(getPlugins(t, srv.URL)) != 0 {
t.Fatal("preview installed the package")
}
}
plugins := getPlugins(t, srv.URL)
if len(plugins) != 1 || !plugins[0].Enabled {
t.Fatalf("installed packages=%+v", plugins)
}
if len(plugins[0].Agents) != 3 {
t.Errorf("displayed agents=%+v, want three", plugins[0].Agents)
}
ctrl := s.ctl().(*control.Controller)
for _, name := range []string{"café", "审查", "点検"} {
invocation := "/unicode-agents:agent:" + name
shown := false
for _, agent := range plugins[0].Agents {
shown = shown || agent.Name == name && agent.Invocation == invocation
}
if !shown {
t.Errorf("inventory missing %q", invocation)
}
loaded := false
for _, sk := range ctrl.Skills() {
loaded = loaded || sk.SlashName() == strings.TrimPrefix(invocation, "/") && sk.RunAs == skill.RunSubagent && sk.Invocation == "manual"
}
input, found := ctrl.RunSkill(invocation)
if !loaded || !found || !strings.Contains(input, "AGENT BODY") {
t.Errorf("runtime profile %q: loaded=%t found=%t input=%q", invocation, loaded, found, input)
}
}
if len(rec.Requests()) != 0 {
t.Fatal("profile discovery started a provider request")
}
})
}
}
func TestPluginCanonicalAgentCollisionMatchesInstalledRuntime(t *testing.T) {
for _, format := range []struct{ kind, manifest, body, firstRoot, secondRoot string }{
{"native", pluginpkg.NativeManifest, `{"apiVersion":"reasonix.io/plugin/v2","name":"collision-agents","contributes":{"agents":["second","first"]}}`, "first", "second"},
{"Claude", pluginpkg.ClaudeManifest, `{"name":"collision-agents"}`, "agents/a-first", "agents"},
} {
for _, shape := range []struct{ label, first, second string }{
{"composed first", "café.md", "cafe\u0301.md"},
{"decomposed first", "cafe\u0301.md", "café.md"},
{"directory first", "café/SKILL.md", "cafe\u0301.md"},
} {
t.Run(format.kind+"/"+shape.label, func(t *testing.T) {
home, ctl, base := pluginHome(t)
t.Setenv("REASONIX_STATE_HOME", home)
t.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
t.Chdir(ctl.root)
source := testenv.TempDir(t)
writePluginFile(t, filepath.Join(source, format.manifest), format.body)
writePluginFile(t, filepath.Join(source, format.firstRoot, shape.first), "---\ndescription: First fixture\n---\nFIRST")
writePluginFile(t, filepath.Join(source, format.secondRoot, shape.second), "---\ndescription: Second fixture\n---\nSECOND")
args := map[string]any{"source": source, "kind": "plugin", "mode": "copy"}
resp := postJSON(t, base+"/plugins/plan", args)
out := decodeInstallSource(t, resp)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || out["ok"] != true {
t.Fatalf("preview=%d: %v", resp.StatusCode, out)
}
if got := getPlugins(t, base); len(got) != 0 {
t.Fatalf("preview installed a package: %+v", got)
}
diagnostic := fmt.Sprint(out)
if !strings.Contains(diagnostic, "agent name") || !strings.Contains(diagnostic, "shadowed") {
t.Fatalf("preview warning absent: %v", out)
}
args["planId"] = out["planId"]
resp = postJSON(t, base+"/plugins/install", args)
installed := decodeInstallSource(t, resp)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || installed["status"] != "done" {
t.Fatalf("install=%d: %v", resp.StatusCode, installed)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
store := skill.New(skill.Options{HomeDir: home, ReasonixHomeDir: home, CustomPaths: cfg.SkillCustomPaths(), PluginPaths: cfg.PluginPackageSkillOwners(), PluginAgentPaths: cfg.PluginPackageAgentOwners(), DisableBuiltins: true, Stderr: io.Discard})
winner, ok := store.ReadSlash("collision-agents:agent:café")
if !ok || winner.Body != "FIRST" || winner.RunAs != skill.RunSubagent || winner.Invocation != "manual" {
t.Fatalf("actual runtime winner=%+v found=%t", winner, ok)
}
pkg, warnings, err := pluginpkg.ParseDir(pluginpkg.InstallRoot(home, "collision-agents"))
if err != nil {
t.Fatal(err)
}
inv := pkg.Inventory().Agents
if len(inv) != 1 || inv[0].Path != winner.Path || inv[0].Name != winner.Name {
t.Fatalf("inventory=%+v actual winner=%+v", inv, winner)
}
plugins := getPlugins(t, base)
if len(plugins) != 1 || len(plugins[0].Agents) != 1 || plugins[0].Agents[0].Name != winner.Name || plugins[0].Agents[0].Description != winner.Description || plugins[0].Agents[0].Invocation != "/"+winner.SlashName() {
t.Fatalf("HTTP inventory=%+v actual winner=%+v", plugins, winner)
}
if len(warnings) != 1 || !strings.Contains(warnings[0], pluginpkg.RelativeRoot(pkg.Root, winner.Path)) || !strings.Contains(warnings[0], "; "+format.secondRoot+"/") {
t.Fatalf("winner/loser warnings=%v", warnings)
}
t.Logf("actual installed runtime selected %s: %s", pluginpkg.RelativeRoot(pkg.Root, winner.Path), winner.Body)
})
}
}
}