package runtime // Tests for sub-agent orchestration, skills, and slash-commands (US-027/028/029, // #45). The sub-agent integration test drives the flagship parent→child→parent // path through the faux provider seam (mirrors faux_provider_test.go): the parent // loop calls a sub-agent tool, the child runs its own scripted loop, and the // child's final text is fed back as the parent's tool result. Skills and // slash-commands get focused load/parse unit tests. import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/agenttool" "github.com/smallnest/pigo/internal/provider" ) // TestSubAgentParentChildParent is acceptance-critical: a parent agent loop // delegates to a sub-agent via a tool call, the child runs an independent loop // with its own context and provider, and the child's final assistant text is // returned to the parent as the tool result (parent->child->parent). Both loops are driven by // faux providers over the real StreamFnFromProvider seam. func TestSubAgentParentChildParent(t *testing.T) { // Child provider: a single scripted turn producing the delegated answer. child := &fauxProvider{ name: "faux-child", models: []provider.Model{{Provider: "faux-child", ID: "child"}}, turns: []fauxTurn{textTurn("child result: 42")}, } // The sub-agent tool spawns a child loop over its own context + provider. sub := NewSubAgentTool(SubAgentSpec{ Name: "researcher", Description: "delegate research to a fresh sub-agent", SystemPrompt: "you are a researcher", Tools: nil, NewRunConfig: func() RunConfig { return RunConfig{ LoopConfig: LoopConfig{Model: "child", Stream: provider.StreamFnFromProvider(child)}, Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: agenttool.NewToolRegistry()}}, } }, }) // Parent provider: turn 1 calls the sub-agent, turn 2 (after the tool result // is fed back) produces the final answer. parent := &fauxProvider{ name: "faux-parent", models: []provider.Model{{Provider: "faux-parent", ID: "parent"}}, turns: []fauxTurn{ toolCallTurn("call-sub", "researcher", `{"prompt":"find the answer"}`), textTurn("final: incorporated child result"), }, } cfg := newFauxRunCfg(parent, sub) agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{ agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("delegate this")}}, }} kinds, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg)) // The sub-agent tool must have executed exactly once in the parent loop. if got := countKind(kinds, agentcore.EventToolExecutionStart); got != 1 { t.Errorf("expected 1 sub-agent tool execution, got %d in %v", got, kinds) } // The child provider must have been driven independently. if child.callCount() != 1 { t.Errorf("child provider called %d times, want 1", child.callCount()) } // The tool result fed back to the parent must carry the child's final text. var toolResult *agentcore.ToolResultMessage for i := range msgs { if tr, ok := msgs[i].(agentcore.ToolResultMessage); ok { toolResult = &tr break } } if toolResult == nil { t.Fatalf("no tool result message in parent transcript: %+v", msgs) } if got := textContentOf(toolResult.Content); got != "child result: 42" { t.Errorf("sub-agent result = %q, want %q (child final text fed to parent)", got, "child result: 42") } if toolResult.IsError { t.Errorf("sub-agent tool result should not be an error") } // The parent's final message incorporates the delegation. final := agentcore.LastAssistantOf(msgs) if final == nil || textContentOf(final.Content) != "final: incorporated child result" { t.Errorf("parent final text = %+v, want the post-delegation answer", final) } } // TestSubAgentConcurrent verifies multiple sub-agents can run concurrently: // the parent issues two parallel sub-agent tool calls in one turn, each spawning // an independent child loop, and both results are fed back. func TestSubAgentConcurrent(t *testing.T) { mkChild := func(answer string) *SubAgentTool { cp := &fauxProvider{ name: "faux-child-" + answer, models: []provider.Model{{Provider: "c", ID: "c"}}, turns: []fauxTurn{textTurn(answer)}, } return NewSubAgentTool(SubAgentSpec{ Name: "agent-" + answer, Description: "child " + answer, NewRunConfig: func() RunConfig { return RunConfig{ LoopConfig: LoopConfig{Model: "c", Stream: provider.StreamFnFromProvider(cp)}, Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: agenttool.NewToolRegistry()}}, } }, }) } a, b := mkChild("alpha"), mkChild("beta") // A single parent turn emitting two tool calls → both run in the same batch. twoCall := fauxTurn{ provider.StreamStartEvent{Partial: agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}}, provider.StreamToolCallEvent{Partial: agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{ agentcore.NewToolCallContent("c1", "agent-alpha", json.RawMessage(`{"prompt":"go"}`)), agentcore.NewToolCallContent("c2", "agent-beta", json.RawMessage(`{"prompt":"go"}`)), }}}, provider.StreamDoneEvent{Message: agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonToolUse, Content: agentcore.ContentList{ agentcore.NewToolCallContent("c1", "agent-alpha", json.RawMessage(`{"prompt":"go"}`)), agentcore.NewToolCallContent("c2", "agent-beta", json.RawMessage(`{"prompt":"go"}`)), }}}, } parent := &fauxProvider{ name: "faux-parent", models: []provider.Model{{Provider: "p", ID: "p"}}, turns: []fauxTurn{twoCall, textTurn("done")}, } cfg := newFauxRunCfg(parent, a, b) agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{ agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("delegate both")}}, }} _, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg)) got := map[string]string{} for _, m := range msgs { if tr, ok := m.(agentcore.ToolResultMessage); ok { got[tr.ToolCallID] = textContentOf(tr.Content) } } if got["c1"] != "alpha" || got["c2"] != "beta" { t.Errorf("concurrent sub-agent results = %v, want c1=alpha c2=beta", got) } } // TestSubAgentEmptyPromptErrors verifies a sub-agent invoked with no prompt // fails cleanly rather than spawning an empty child. func TestSubAgentEmptyPromptErrors(t *testing.T) { sub := NewSubAgentTool(SubAgentSpec{ Name: "x", Description: "x", NewRunConfig: func() RunConfig { return RunConfig{} }, }) if _, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":""}`), nil); err == nil { t.Error("empty prompt must error") } } // TestSubAgentFailedChildErrors verifies a child whose final turn stopped on // error/aborted is surfaced to the parent as a tool error (not a silent // success), so the parent model learns the delegation failed. func TestSubAgentFailedChildErrors(t *testing.T) { // A child turn that ends with StopReason=error carrying diagnostic text. errTurn := func(text string) fauxTurn { partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant} withText := partial withText.Content = agentcore.ContentList{agentcore.NewTextContent(text)} final := withText final.StopReason = agentcore.StopReasonError return fauxTurn{ provider.StreamStartEvent{Partial: partial}, provider.StreamTextEvent{Partial: withText}, provider.StreamDoneEvent{Message: final}, } } child := &fauxProvider{ name: "faux-child", models: []provider.Model{{Provider: "faux-child", ID: "child"}}, turns: []fauxTurn{errTurn("provider blew up")}, } sub := NewSubAgentTool(SubAgentSpec{ Name: "researcher", Description: "delegate", NewRunConfig: func() RunConfig { return RunConfig{ LoopConfig: LoopConfig{Model: "child", Stream: provider.StreamFnFromProvider(child)}, Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: agenttool.NewToolRegistry()}}, } }, }) _, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"go"}`), nil) if err == nil { t.Fatal("a child that stopped on error must surface as a tool error") } if !strings.Contains(err.Error(), "provider blew up") { t.Errorf("error should carry the child's diagnostic text, got %v", err) } } // --- Skills ----------------------------------------------------------------- // TestParseSkill verifies frontmatter + body parsing, including the name/body // split and the required-description guard. func TestParseSkill(t *testing.T) { content := []byte("---\nname: summarize\ndescription: summarize a file\nallowed-tools:\n - read\n---\nYou summarize files.\nBe concise.\n") sk, err := ParseSkill("summarize.md", content) if err != nil { t.Fatalf("ParseSkill: %v", err) } if sk.Frontmatter.Name != "summarize" { t.Errorf("name = %q, want summarize", sk.Frontmatter.Name) } if sk.Frontmatter.Description != "summarize a file" { t.Errorf("description = %q", sk.Frontmatter.Description) } if len(sk.Frontmatter.AllowedTools) != 1 || sk.Frontmatter.AllowedTools[0] != "read" { t.Errorf("allowed-tools = %v, want [read]", sk.Frontmatter.AllowedTools) } if !strings.Contains(sk.Body, "You summarize files.") { t.Errorf("body missing instructions: %q", sk.Body) } } // TestParseSkillDefaultsNameToFile verifies a skill without an explicit name // defaults to its file base name. func TestParseSkillDefaultsNameToFile(t *testing.T) { sk, err := ParseSkill("/skills/deploy.md", []byte("---\ndescription: deploys\n---\nbody")) if err != nil { t.Fatalf("ParseSkill: %v", err) } if sk.Frontmatter.Name != "deploy" { t.Errorf("name defaulted to %q, want deploy", sk.Frontmatter.Name) } } // TestParseSkillRequiresDescription verifies the description guard. func TestParseSkillRequiresDescription(t *testing.T) { if _, err := ParseSkill("x.md", []byte("---\nname: x\n---\nbody")); err == nil { t.Error("skill without description must error") } if _, err := ParseSkill("x.md", []byte("no frontmatter here")); err == nil { t.Error("skill without frontmatter must error") } } // TestParseSkillDisableModelInvocation verifies the disable-model-invocation // frontmatter key parses into the three expected states. func TestParseSkillDisableModelInvocation(t *testing.T) { cases := []struct { name string yaml string want bool }{ {"absent defaults false", "---\nname: a\ndescription: d\n---\nbody", false}, {"explicit true", "---\nname: a\ndescription: d\ndisable-model-invocation: true\n---\nbody", true}, {"explicit false", "---\nname: a\ndescription: d\ndisable-model-invocation: false\n---\nbody", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { sk, err := ParseSkill("a.md", []byte(tc.yaml)) if err != nil { t.Fatalf("ParseSkill: %v", err) } if sk.Frontmatter.DisableModelInvocation != tc.want { t.Errorf("DisableModelInvocation = %v, want %v", sk.Frontmatter.DisableModelInvocation, tc.want) } }) } } // TestValidateSkillName verifies the Agent Skills name rules: lowercase // a-z/0-9/hyphen only, at most 64 chars, no leading/trailing/consecutive // hyphens. func TestValidateSkillName(t *testing.T) { valid := []string{"weather", "chao-go-sync", "a", "a1-b2"} for _, n := range valid { if err := validateSkillName(n); err != nil { t.Errorf("validateSkillName(%q) = %v, want nil", n, err) } } invalid := []string{ "Weather", // uppercase "my_skill", // underscore "has space", // space "plugin:skill", // colon "-lead", // leading hyphen "trail-", // trailing hyphen "double--hyphen", // consecutive hyphens strings.Repeat("a", 65), // too long } for _, n := range invalid { if err := validateSkillName(n); err == nil { t.Errorf("validateSkillName(%q) = nil, want error", n) } } } // TestValidateSkillDescription verifies description is required and bounded. func TestValidateSkillDescription(t *testing.T) { if err := validateSkillDescription("does a thing"); err != nil { t.Errorf("valid description rejected: %v", err) } if err := validateSkillDescription(" "); err == nil { t.Error("blank description must error") } if err := validateSkillDescription(strings.Repeat("x", 1025)); err == nil { t.Error("over-long description must error") } if err := validateSkillDescription(strings.Repeat("x", 1024)); err != nil { t.Errorf("1024-char description rejected: %v", err) } } // TestParseSkillRejectsInvalidName verifies an invalid name (including one // derived from the file base name) makes ParseSkill fail, so LoadSkillsDir // skips it and accumulates the reason rather than surfacing a bad skill. func TestParseSkillRejectsInvalidName(t *testing.T) { if _, err := ParseSkill("x.md", []byte("---\nname: Bad_Name\ndescription: d\n---\nbody")); err == nil { t.Error("invalid explicit name must error") } if _, err := ParseSkill("/skills/My_Skill.md", []byte("---\ndescription: d\n---\nbody")); err == nil { t.Error("invalid file-derived name must error") } } // TestFormatSkillsForPrompt verifies the block lists visible // skills with name/description/location and excludes disabled ones. func TestFormatSkillsForPrompt(t *testing.T) { skills := []*Skill{ {Frontmatter: SkillFrontmatter{Name: "weather", Description: "get weather"}, Path: "/skills/weather.md"}, {Frontmatter: SkillFrontmatter{Name: "secret", Description: "hidden", DisableModelInvocation: true}, Path: "/skills/secret.md"}, } out := FormatSkillsForPrompt(skills) if !strings.Contains(out, "") || !strings.Contains(out, "") { t.Fatalf("missing block wrapper:\n%s", out) } if !strings.Contains(out, "weather") { t.Errorf("visible skill name missing:\n%s", out) } if !strings.Contains(out, "get weather") { t.Errorf("visible skill description missing:\n%s", out) } if !strings.Contains(out, "/skills/weather.md") { t.Errorf("visible skill location missing:\n%s", out) } if strings.Contains(out, "secret") { t.Errorf("disabled skill must be excluded:\n%s", out) } if !strings.Contains(out, "Use the read tool to load a skill's file") { t.Errorf("guidance preamble missing:\n%s", out) } } // TestFormatSkillsForPromptEmpty verifies an empty or all-disabled list yields // the empty string so callers can append unconditionally. func TestFormatSkillsForPromptEmpty(t *testing.T) { if got := FormatSkillsForPrompt(nil); got != "" { t.Errorf("nil skills = %q, want empty", got) } disabled := []*Skill{{Frontmatter: SkillFrontmatter{Name: "x", Description: "d", DisableModelInvocation: true}, Path: "x.md"}} if got := FormatSkillsForPrompt(disabled); got != "" { t.Errorf("all-disabled skills = %q, want empty", got) } } // TestFormatSkillsForPromptEscapesXML verifies XML special characters in name // and description are escaped. func TestFormatSkillsForPromptEscapesXML(t *testing.T) { skills := []*Skill{ {Frontmatter: SkillFrontmatter{Name: "a", Description: `x & y < z > "q" 'r'`}, Path: "/s/a.md"}, } out := FormatSkillsForPrompt(skills) if !strings.Contains(out, "x & y < z > "q" 'r'") { t.Errorf("XML not escaped:\n%s", out) } if strings.Contains(out, "& y") || strings.Contains(out, "< z") { t.Errorf("raw XML chars leaked:\n%s", out) } } // TestFormatSkillsForPromptAbsoluteLocation verifies a relative skill path is // rendered as an absolute location so the model can read it from any cwd. func TestFormatSkillsForPromptAbsoluteLocation(t *testing.T) { skills := []*Skill{ {Frontmatter: SkillFrontmatter{Name: "rel", Description: "d"}, Path: "sub/rel.md"}, } out := FormatSkillsForPrompt(skills) if !strings.Contains(out, ""+string(filepath.Separator)) && !strings.Contains(out, "/") { t.Errorf("location should be absolute:\n%s", out) } } // TestLoadSkillsDir verifies loading flat *.md skills and nested /SKILL.md, // sorted by name; a missing dir is not an error. func TestLoadSkillsDir(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "beta.md"), []byte("---\ndescription: beta skill\n---\nbeta body"), 0o644); err != nil { t.Fatal(err) } nested := filepath.Join(dir, "alpha") if err := os.MkdirAll(nested, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(nested, "SKILL.md"), []byte("---\nname: alpha\ndescription: alpha skill\n---\nalpha body"), 0o644); err != nil { t.Fatal(err) } skills, err := LoadSkillsDir(dir) if err != nil { t.Fatalf("LoadSkillsDir: %v", err) } if len(skills) != 2 { t.Fatalf("loaded %d skills, want 2", len(skills)) } if skills[0].Frontmatter.Name != "alpha" || skills[1].Frontmatter.Name != "beta" { t.Errorf("skills not sorted by name: %q, %q", skills[0].Frontmatter.Name, skills[1].Frontmatter.Name) } // Missing directory → no skills, no error. empty, err := LoadSkillsDir(filepath.Join(dir, "does-not-exist")) if err != nil || empty != nil { t.Errorf("missing dir should yield (nil, nil), got (%v, %v)", empty, err) } } // TestSkillSubAgentSpec verifies a skill materializes as a sub-agent whose // system prompt is the body, whose tools are filtered by allowed-tools, and // whose description is surfaced. func TestSkillSubAgentSpec(t *testing.T) { sk := &Skill{ Frontmatter: SkillFrontmatter{Name: "reader", Description: "reads", AllowedTools: []string{"read"}}, Body: "you read files", } tools := []agentcore.AgentTool{execTool{name: "read"}, execTool{name: "write"}, execTool{name: "bash"}} var gotTools []agentcore.AgentTool spec := sk.SubAgentSpec(tools, func(resolved []agentcore.AgentTool) RunConfig { gotTools = resolved return RunConfig{} }) if spec.Name != "reader" || spec.Description != "reads" { t.Errorf("spec identity = %q/%q", spec.Name, spec.Description) } if spec.SystemPrompt != "you read files" { t.Errorf("spec system prompt = %q", spec.SystemPrompt) } if len(spec.Tools) != 1 || spec.Tools[0].Name() != "read" { t.Errorf("allowed-tools filter failed, spec tools = %v", spec.Tools) } // NewRunConfig passes the resolved (filtered) tool set to the factory. spec.NewRunConfig() if len(gotTools) != 1 || gotTools[0].Name() != "read" { t.Errorf("factory received %v, want [read]", gotTools) } } // --- Slash-commands --------------------------------------------------------- // TestSlashBuiltinWinsOverUser is acceptance-critical: the conflict priority // rule keeps a built-in over a same-named user command, recording the shadow. func TestSlashBuiltinWinsOverUser(t *testing.T) { // Register a built-in under a unique name to avoid cross-test pollution. name := "compact-test-builtin" if _, exists := builtinCommands[name]; !exists { RegisterBuiltin(SlashCommand{Name: name, Description: "builtin", Expand: func(string) string { return "BUILTIN" }}) } r := NewSlashRegistry() r.AddUser(SlashCommand{Name: name, Expand: func(string) string { return "USER" }}) cmd, ok := r.Lookup(name) if !ok { t.Fatalf("command %q not found", name) } if cmd.Source != SourceBuiltin { t.Errorf("built-in must win, got source %v", cmd.Source) } if got := cmd.Expand(""); got != "BUILTIN" { t.Errorf("expanded %q, want BUILTIN (built-in wins)", got) } shadowed := r.Shadowed() if len(shadowed) != 1 || shadowed[0].Name != name { t.Errorf("shadowed = %v, want [%s]", shadowed, name) } } // TestSlashResolve verifies "/name args" parsing, non-command passthrough, and // the unknown-command error. func TestSlashResolve(t *testing.T) { r := NewSlashRegistry() r.AddUser(SlashCommand{Name: "greet", Expand: func(args string) string { return "hello " + args }}) // Slash command with args → expanded. prompt, handled, err := r.Resolve("/greet world") if err != nil || !handled || prompt != "hello world" { t.Errorf("Resolve(/greet world) = (%q, %v, %v)", prompt, handled, err) } // Non-command passthrough. prompt, handled, err = r.Resolve("just a normal prompt") if err != nil || handled || prompt != "just a normal prompt" { t.Errorf("non-command passthrough failed: (%q, %v, %v)", prompt, handled, err) } // Unknown command errors. if _, _, err := r.Resolve("/nope"); err == nil { t.Error("unknown command must error") } } // TestSlashActionCommand verifies an action command runs its side effect via // ResolveOutcome and reports SlashAction with its status message (no prompt), // while a prompt command reports SlashPrompt with expanded text. func TestSlashActionCommand(t *testing.T) { r := NewSlashRegistry() var ran string r.AddBuiltin(SlashCommand{ Name: "model", Description: "switch model", Action: func(args string) string { ran = args; return "switched to " + args }, }) r.AddUser(SlashCommand{Name: "greet", Expand: func(args string) string { return "hello " + args }}) // Action command: runs the side effect and returns a status message. out, err := r.ResolveOutcome("/model gpt-5") if err != nil { t.Fatalf("ResolveOutcome(/model) error: %v", err) } if !out.Handled || out.Kind != SlashAction { t.Errorf("action command: got handled=%v kind=%v, want true/SlashAction", out.Handled, out.Kind) } if ran != "gpt-5" { t.Errorf("action side effect not run with args: got %q", ran) } if out.Message != "switched to gpt-5" || out.Prompt != "" { t.Errorf("action outcome = {msg:%q prompt:%q}, want status message and empty prompt", out.Message, out.Prompt) } // Prompt command: expands, no action. out, err = r.ResolveOutcome("/greet world") if err != nil { t.Fatalf("ResolveOutcome(/greet) error: %v", err) } if !out.Handled || out.Kind != SlashPrompt || out.Prompt != "hello world" { t.Errorf("prompt outcome = {kind:%v prompt:%q}, want SlashPrompt/hello world", out.Kind, out.Prompt) } // Non-command passthrough. out, err = r.ResolveOutcome("plain text") if err != nil || out.Handled || out.Prompt != "plain text" { t.Errorf("passthrough = {%v %q %v}, want unhandled verbatim", out.Handled, out.Prompt, err) } } // TestAddBuiltinDuplicatePanics verifies AddBuiltin rejects a duplicate // built-in name (a programming error), matching RegisterBuiltin semantics. func TestAddBuiltinDuplicatePanics(t *testing.T) { r := NewSlashRegistry() r.AddBuiltin(SlashCommand{Name: "dup", Action: func(string) string { return "" }}) defer func() { if recover() == nil { t.Error("duplicate AddBuiltin must panic") } }() r.AddBuiltin(SlashCommand{Name: "dup", Action: func(string) string { return "" }}) } // TestAddBuiltinWinsOverUser verifies an instance built-in (AddBuiltin) shadows // a same-named user command, just like a globally registered built-in. func TestAddBuiltinWinsOverUser(t *testing.T) { r := NewSlashRegistry() r.AddBuiltin(SlashCommand{Name: "x", Action: func(string) string { return "builtin" }}) r.AddUser(SlashCommand{Name: "x", Expand: func(string) string { return "user" }}) cmd, ok := r.Lookup("x") if !ok || cmd.Source != SourceBuiltin { t.Errorf("instance built-in must win, got ok=%v source=%v", ok, cmd.Source) } if len(r.Shadowed()) != 1 || r.Shadowed()[0].Name != "x" { t.Errorf("shadowed = %v, want [x]", r.Shadowed()) } } // TestParseUserCommand verifies $ARGUMENTS substitution, frontmatter description, // and the append fallback when no placeholder is present. func TestParseUserCommand(t *testing.T) { // With frontmatter + placeholder. cmd, err := ParseUserCommand("review", []byte("---\ndescription: review code\n---\nReview this: $ARGUMENTS please")) if err != nil { t.Fatalf("ParseUserCommand: %v", err) } if cmd.Description != "review code" { t.Errorf("description = %q", cmd.Description) } if got := cmd.Expand("main.go"); got != "Review this: main.go please" { t.Errorf("expand = %q", got) } // No placeholder: args appended. bare, _ := ParseUserCommand("note", []byte("Take a note")) if got := bare.Expand("buy milk"); got != "Take a note\n\nbuy milk" { t.Errorf("bare expand = %q", got) } if got := bare.Expand(""); got != "Take a note" { t.Errorf("bare expand no args = %q", got) } } // TestLoadUserCommandsDir verifies loading *.md command templates from a dir, // sorted by name; a missing dir is not an error. func TestLoadUserCommandsDir(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "deploy.md"), []byte("Deploy $ARGUMENTS now"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "test.md"), []byte("---\ndescription: run tests\n---\nRun tests"), 0o644); err != nil { t.Fatal(err) } cmds, err := LoadUserCommandsDir(dir) if err != nil { t.Fatalf("LoadUserCommandsDir: %v", err) } if len(cmds) != 2 || cmds[0].Name != "deploy" || cmds[1].Name != "test" { t.Fatalf("loaded %d commands (want deploy,test sorted): %+v", len(cmds), cmds) } if got := cmds[0].Expand("prod"); got != "Deploy prod now" { t.Errorf("deploy expand = %q", got) } empty, err := LoadUserCommandsDir(filepath.Join(dir, "missing")) if err != nil || empty != nil { t.Errorf("missing dir should yield (nil, nil), got (%v, %v)", empty, err) } }