mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(studio): match package agent inventory to runtime names
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "]") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user