first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
@@ -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])
}
}