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