first commit
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user