package repl // Tests for plugin slash-command wiring (#265): a plugin-declared command // (Manager.Commands()) is registered into the REPL slash registry as a hybrid // (Run) command, and invoking it calls Plugin.CallCommand, surfaces the // returned notifications, and runs the returned prompt as the next turn. A real // plugin subprocess is compiled and Discover-loaded so the JSON-RPC transport, // handshake and commands/call round-trip are exercised end to end. import ( "bytes" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/agenttool" "github.com/smallnest/pigo/internal/cli" "github.com/smallnest/pigo/internal/cli/prompts" "github.com/smallnest/pigo/internal/plugin" "github.com/smallnest/pigo/internal/provider" rt "github.com/smallnest/pigo/internal/runtime" "github.com/smallnest/pigo/internal/session" ) // cmdPluginMain is a standalone plugin that declares one "hello" slash command // and answers commands/call by echoing back a prompt built from the command's // args plus one notification. It lets the test assert registration, notification // surfacing, and prompt injection. It also records the raw arguments it received // so the test can confirm a bare invocation sends a JSON string ("") not null. const cmdPluginMain = `package main import ( "bufio" "encoding/json" "fmt" "os" ) type req struct { ID *json.RawMessage ` + "`json:\"id\"`" + ` Method string ` + "`json:\"method\"`" + ` Params json.RawMessage ` + "`json:\"params\"`" + ` } func main() { sc := bufio.NewScanner(os.Stdin) sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) w := bufio.NewWriter(os.Stdout) for sc.Scan() { var r req if err := json.Unmarshal(sc.Bytes(), &r); err != nil { continue } switch r.Method { case "initialize": reply(w, r.ID, json.RawMessage(` + "`" + `{"name":"greeter","commands":[{"name":"hello","description":"say hello"}]}` + "`" + `)) case "commands/call": var p struct { Name string ` + "`json:\"name\"`" + ` Args json.RawMessage ` + "`json:\"arguments\"`" + ` } json.Unmarshal(r.Params, &p) // args is a JSON string (never null); decode it to prove the contract. var argText string json.Unmarshal(p.Args, &argText) res, _ := json.Marshal(map[string]any{ "prompt": "please greet " + argText, "notifications": []map[string]any{ {"message": "invoked hello", "type": "info"}, }, }) reply(w, r.ID, res) case "shutdown": return } } } func reply(w *bufio.Writer, id *json.RawMessage, result json.RawMessage) { if id == nil { return } out, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "result": result}) fmt.Fprintf(w, "%s\n", out) w.Flush() } ` // buildPluginInDir compiles src into an executable named bin inside dir and // returns nothing (the executable path is dir/bin). Discover loads any // executable regular file directly under dir. func buildPluginInDir(t *testing.T, dir, bin, src string) { t.Helper() srcPath := filepath.Join(dir, "plugin_main.go") if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil { t.Fatalf("write plugin source: %v", err) } binPath := filepath.Join(dir, bin) if runtime.GOOS == "windows" { binPath += ".exe" } cmd := exec.Command("go", "build", "-o", binPath, srcPath) cmd.Env = os.Environ() if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("build plugin: %v\n%s", err, out) } // Remove the source so Discover only sees the executable (a .go file is not // executable, but keeping the dir clean avoids any ambiguity). _ = os.Remove(srcPath) } // loadTestManager compiles the greeter plugin into a fresh dir and Discover-loads // it, returning a Manager with exactly that one plugin. The caller must Close it. func loadTestManager(t *testing.T) *plugin.Manager { t.Helper() // Build in a build dir, then move only the binary into the plugins dir so // Discover (which loads every executable file in the dir) sees just the one // plugin executable. buildDir := t.TempDir() buildPluginInDir(t, buildDir, "greeter", cmdPluginMain) pluginsDir := t.TempDir() binName := "greeter" if runtime.GOOS == "windows" { binName += ".exe" } if err := os.Rename(filepath.Join(buildDir, binName), filepath.Join(pluginsDir, binName)); err != nil { t.Fatalf("move plugin binary: %v", err) } mgr, err := plugin.Discover(pluginsDir, os.Stderr, os.Stderr) if err != nil { t.Fatalf("Discover: %v", err) } if len(mgr.Commands()) != 1 || mgr.Commands()[0].Spec.Name != "hello" { t.Fatalf("expected one discovered command 'hello', got %+v", mgr.Commands()) } return mgr } // TestBuildSlashRegistryRegistersPluginCommand verifies a discovered plugin // command is registered as a resolvable slash command in the registry. func TestBuildSlashRegistryRegistersPluginCommand(t *testing.T) { t.Setenv("PIGO_HOME", t.TempDir()) mgr := loadTestManager(t) defer mgr.Close() reg, err := prompts.BuildSlashRegistry(&cli.LiveConfig{Model: "faux", ProviderName: "faux"}, nil, mgr, prompts.PromptTemplateSources{}) if err != nil { t.Fatalf("buildSlashRegistry: %v", err) } if _, ok := reg.Lookup("hello"); !ok { t.Fatalf("plugin command /hello was not registered in the slash registry") } // Resolving it must run the plugin (side effect), surface its notification, // and yield the plugin's prompt to run. out, err := reg.ResolveOutcome("/hello world") if err != nil { t.Fatalf("ResolveOutcome(/hello): %v", err) } if !out.Handled || out.Kind != rt.SlashPrompt { t.Fatalf("outcome = %+v, want handled SlashPrompt", out) } if !strings.Contains(out.Message, "invoked hello") { t.Errorf("notification not surfaced in Message: %q", out.Message) } if out.Prompt != "please greet world" { t.Errorf("Prompt = %q, want plugin-returned prompt", out.Prompt) } } // TestBuiltinWinsOverPluginCommand verifies a built-in command of the same name // wins over a plugin command (existing precedence preserved): the plugin command // is shadowed and the built-in's behavior is what resolves. func TestBuiltinWinsOverPluginCommand(t *testing.T) { t.Setenv("PIGO_HOME", t.TempDir()) mgr := loadTestManager(t) defer mgr.Close() reg := rt.NewSlashRegistry() reg.AddBuiltin(rt.SlashCommand{ Name: "hello", Action: func(string) string { return "builtin hello" }, }) prompts.RegisterPluginCommands(reg, mgr) if names := reg.Shadowed(); len(names) != 1 || names[0].Name != "hello" { t.Fatalf("plugin command should be shadowed by built-in, shadowed=%v", names) } out, err := reg.ResolveOutcome("/hello there") if err != nil { t.Fatalf("ResolveOutcome: %v", err) } if out.Kind != rt.SlashAction || out.Message != "builtin hello" { t.Errorf("built-in must win: outcome = %+v", out) } } // TestREPLPluginCommandInjectsPrompt drives the full REPL: invoking a plugin // slash command prints its notification and runs the returned prompt as the next // agent turn (so the fake provider is called once and the injected prompt lands // in the conversation history). func TestREPLPluginCommandInjectsPrompt(t *testing.T) { t.Setenv("PIGO_HOME", t.TempDir()) mgr := loadTestManager(t) defer mgr.Close() reg, err := prompts.BuildSlashRegistry(&cli.LiveConfig{Model: "faux", ProviderName: "faux"}, nil, mgr, prompts.PromptTemplateSources{}) if err != nil { t.Fatalf("buildSlashRegistry: %v", err) } store, err := session.NewStore(t.TempDir()) if err != nil { t.Fatalf("new store: %v", err) } p := &replProvider{reply: "hi there"} live := &cli.LiveConfig{Model: "faux", ProviderName: "faux", Provider: p} deps := replDeps{ store: store, header: session.SessionHeader{ID: session.NewID(time.Now().UTC()), Model: "faux", Provider: "faux"}, agentCtx: &agentcore.AgentContext{}, live: live, reg: agenttool.NewToolRegistry(), slash: reg, creds: provider.NewCredentialStore(nil), } var out bytes.Buffer if err := runREPL(strings.NewReader("/hello world\n/exit\n"), &out, deps); err != nil { t.Fatalf("runREPL: %v", err) } // The plugin's notification must have been printed. if !strings.Contains(out.String(), "invoked hello") { t.Errorf("plugin notification not printed, out=%q", out.String()) } // The returned prompt must have run exactly one turn. if p.calls != 1 { t.Fatalf("plugin command should inject and run exactly 1 turn, got %d", p.calls) } // The injected prompt must be the user message that started the turn. if len(deps.agentCtx.Messages) == 0 { t.Fatal("expected messages in context after the injected turn") } u0, ok := deps.agentCtx.Messages[0].(agentcore.UserMessage) if !ok || agentcore.ContentToText(u0.Content) != "please greet world" { t.Errorf("injected prompt not run as the turn: %+v", deps.agentCtx.Messages[0]) } }