Files
BlackBean/pigo/internal/plugin/plugin_test.go
T
2026-08-14 23:41:57 +08:00

321 lines
8.9 KiB
Go

// Tests for the plugin system (US-016, #132). A test plugin is a tiny Go program
// compiled once per test run; it speaks the JSON-RPC protocol over stdio so the
// tests exercise the real subprocess transport, handshake, tool forwarding, and
// crash isolation — no network or mocks.
package plugin
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
)
// buildTestPlugin compiles the given Go source into an executable under a temp
// dir and returns its path. The source is a standalone main package.
func buildTestPlugin(t *testing.T, name, src string) string {
t.Helper()
dir := t.TempDir()
srcPath := filepath.Join(dir, name+".go")
if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil {
t.Fatalf("write plugin source: %v", err)
}
bin := filepath.Join(dir, name)
if runtime.GOOS == "windows" {
bin += ".exe"
}
cmd := exec.Command("go", "build", "-o", bin, srcPath)
cmd.Env = os.Environ()
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build test plugin: %v\n%s", err, out)
}
return bin
}
// echoPluginSrc is a plugin that declares one "shout" tool which uppercases its
// "text" argument.
const echoPluginSrc = `package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
)
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":"echo","version":"1.0","tools":[{"name":"shout","description":"uppercase text","schema":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}}]}` + "`" + `))
case "tools/call":
var p struct {
Name string ` + "`json:\"name\"`" + `
Arguments json.RawMessage ` + "`json:\"arguments\"`" + `
}
json.Unmarshal(r.Params, &p)
var a struct{ Text string ` + "`json:\"text\"`" + ` }
json.Unmarshal(p.Arguments, &a)
res, _ := json.Marshal(map[string]any{"content": strings.ToUpper(a.Text)})
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()
}
`
// TestPluginLoadAndCall exercises the full path: build → load (handshake) →
// adapt tool → call → result.
func TestPluginLoadAndCall(t *testing.T) {
bin := buildTestPlugin(t, "echo", echoPluginSrc)
p, err := Load(bin, nil, os.Stderr)
if err != nil {
t.Fatalf("Load: %v", err)
}
defer p.Close()
if p.Manifest.Name != "echo" {
t.Errorf("manifest name = %q, want echo", p.Manifest.Name)
}
tools := p.Tools()
if len(tools) != 1 || tools[0].Name() != "shout" {
t.Fatalf("tools = %+v, want one 'shout'", tools)
}
if tools[0].ExecutionMode() != agentcore.ToolExecutionSequential {
t.Errorf("plugin tool should be sequential")
}
res, err := tools[0].Execute(context.Background(), "c1", json.RawMessage(`{"text":"hello"}`), nil)
if err != nil {
t.Fatalf("Execute Go error: %v", err)
}
if txt := agentcore.ContentToText(res.Content); txt != "HELLO" {
t.Errorf("result = %q, want HELLO", txt)
}
}
// crashPluginSrc initializes fine but exits abruptly on the first tools/call.
const crashPluginSrc = `package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
)
type req struct {
ID *json.RawMessage ` + "`json:\"id\"`" + `
Method string ` + "`json:\"method\"`" + `
}
func main() {
sc := bufio.NewScanner(os.Stdin)
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":
out, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": r.ID, "result": json.RawMessage(` + "`" + `{"name":"crash","tools":[{"name":"boom","description":"crashes"}]}` + "`" + `)})
fmt.Fprintf(w, "%s\n", out)
w.Flush()
case "tools/call":
os.Exit(1) // crash mid-call: no response is ever sent
}
}
}
`
// TestPluginCrashIsolation checks that a plugin crashing during a tool call
// degrades to an error result, not a panic or a hang.
func TestPluginCrashIsolation(t *testing.T) {
bin := buildTestPlugin(t, "crash", crashPluginSrc)
p, err := Load(bin, nil, os.Stderr)
if err != nil {
t.Fatalf("Load: %v", err)
}
defer p.Close()
tools := p.Tools()
if len(tools) != 1 {
t.Fatalf("want one tool, got %d", len(tools))
}
res, err := tools[0].Execute(context.Background(), "c1", json.RawMessage(`{}`), nil)
if err != nil {
t.Fatalf("Execute must not return a Go error even on crash: %v", err)
}
txt := agentcore.ContentToText(res.Content)
if !strings.Contains(txt, "plugin call failed") {
t.Errorf("expected isolated error result, got %q", txt)
}
}
// cmdPluginSrc declares two slash commands and answers commands/call by echoing
// the command name back as a prompt plus one notification. Its manifest command
// order (greet, then bye) lets tests assert manifest-order aggregation.
const cmdPluginSrc = `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":"cmd","commands":[{"name":"greet","description":"greets"},{"name":"bye","description":"farewell"}]}` + "`" + `))
case "commands/call":
var p struct {
Name string ` + "`json:\"name\"`" + `
Args json.RawMessage ` + "`json:\"arguments\"`" + `
}
json.Unmarshal(r.Params, &p)
res, _ := json.Marshal(map[string]any{
"prompt": "did:" + p.Name,
"notifications": []map[string]any{
{"message": "ran " + p.Name, "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()
}
`
// TestPluginCallCommand checks CallCommand round-trips a prompt and its
// notifications from a plugin over the real subprocess transport.
func TestPluginCallCommand(t *testing.T) {
bin := buildTestPlugin(t, "cmd", cmdPluginSrc)
p, err := Load(bin, nil, os.Stderr)
if err != nil {
t.Fatalf("Load: %v", err)
}
defer p.Close()
res, err := p.CallCommand(context.Background(), "greet", json.RawMessage(`{"text":"hi"}`))
if err != nil {
t.Fatalf("CallCommand: %v", err)
}
if res.Prompt != "did:greet" {
t.Errorf("prompt = %q, want did:greet", res.Prompt)
}
if len(res.Notifications) != 1 {
t.Fatalf("notifications = %+v, want one", res.Notifications)
}
if res.Notifications[0].Message != "ran greet" || res.Notifications[0].Type != "info" {
t.Errorf("notification = %+v, want {ran greet, info}", res.Notifications[0])
}
}
// TestPluginCallCommandTransportError checks that a transport error (the plugin
// crashed mid-call) surfaces as a returned error, never a panic.
func TestPluginCallCommandTransportError(t *testing.T) {
bin := buildTestPlugin(t, "cmdcrash", cmdCrashPluginSrc)
p, err := Load(bin, nil, os.Stderr)
if err != nil {
t.Fatalf("Load: %v", err)
}
defer p.Close()
_, err = p.CallCommand(context.Background(), "greet", json.RawMessage(`{}`))
if err == nil {
t.Fatal("CallCommand must return an error when the plugin crashes mid-call")
}
}
// cmdCrashPluginSrc initializes with one command then exits abruptly on the
// first commands/call, so no response is ever sent.
const cmdCrashPluginSrc = `package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
)
type req struct {
ID *json.RawMessage ` + "`json:\"id\"`" + `
Method string ` + "`json:\"method\"`" + `
}
func main() {
sc := bufio.NewScanner(os.Stdin)
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":
out, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": r.ID, "result": json.RawMessage(` + "`" + `{"name":"cmdcrash","commands":[{"name":"greet","description":"greets"}]}` + "`" + `)})
fmt.Fprintf(w, "%s\n", out)
w.Flush()
case "commands/call":
os.Exit(1) // crash mid-call: no response is ever sent
}
}
}
`