Files
BlackBean/pigo/internal/runtime/subagent_process_test.go
2026-08-14 23:41:57 +08:00

329 lines
12 KiB
Go

package runtime
// Tests for process-isolated sub-agents (US-019, #135): the Isolation field,
// the process-mode Execute path (params shaping, crash-as-tool-error), the
// shared RunSubAgentOnce core, and the real defaultProcessCall transport
// (exec + stdio JSON-RPC + crash handling) driven through a tiny compiled
// helper binary - no provider or network, mirroring the plugin tests.
import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
goruntime "runtime"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/provider"
)
// errorTurn scripts a turn whose final message stops on StopReasonError, so a
// RunSubAgentOnce run surfaces as a failure (matching the goroutine-mode
// "failed run -> tool error" contract).
func errorTurn(msg string) fauxTurn {
partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
final := partial
final.StopReason = agentcore.StopReasonError
final.ErrorMessage = msg
return fauxTurn{
provider.StreamStartEvent{Partial: partial},
provider.StreamDoneEvent{Message: final},
}
}
// TestSubAgentGoroutineModeUnchanged verifies the default (zero-value) isolation
// still runs in-process: the existing parent->child->parent tests cover the
// full path, but this pins that Isolation==goroutine is the default and reaches
// NewRunConfig (not the process path) so the regression is caught locally too.
func TestSubAgentGoroutineModeUnchanged(t *testing.T) {
child := &fauxProvider{
name: "faux-child",
models: []provider.Model{{Provider: "faux-child", ID: "child"}},
turns: []fauxTurn{textTurn("child answer")},
}
sub := NewSubAgentTool(SubAgentSpec{
Name: "researcher",
SystemPrompt: "you are a researcher",
NewRunConfig: func() RunConfig { return newFauxRunCfg(child) },
})
if sub.spec.Isolation != SubAgentIsolationGoroutine {
t.Errorf("default Isolation = %v, want goroutine", sub.spec.Isolation)
}
res, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"go"}`), nil)
if err != nil {
t.Fatalf("goroutine Execute err = %v", err)
}
if got := agentcore.ContentToText(res.Content); got != "child answer" {
t.Errorf("goroutine result = %q, want 'child answer'", got)
}
if child.callCount() != 1 {
t.Errorf("child provider calls = %d, want 1", child.callCount())
}
}
// TestSubAgentGoroutineNilRunConfigErrors verifies goroutine mode reports a
// missing NewRunConfig, and - preserving the original precedence - reports it
// even when the prompt is empty (so a nil config is not masked by "empty
// prompt"). This pins the L3 regression: the NewRunConfig check stays before
// the empty-prompt check on the goroutine path.
func TestSubAgentGoroutineNilRunConfigErrors(t *testing.T) {
sub := NewSubAgentTool(SubAgentSpec{Name: "x"}) // no NewRunConfig, default goroutine
_, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"go"}`), nil)
if err == nil || !strings.Contains(err.Error(), "no run configuration") {
t.Errorf("err = %v, want 'no run configuration'", err)
}
// Precedence: nil NewRunConfig is reported even when the prompt is empty.
_, err = sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":""}`), nil)
if err == nil || !strings.Contains(err.Error(), "no run configuration") {
t.Errorf("empty-prompt err = %v, want 'no run configuration' (nil config takes precedence)", err)
}
}
// TestSubAgentProcessModeFake drives process mode through an injectable
// processCall (the test seam) to verify params shaping and crash-as-tool-error
// without a real subprocess. The real transport is covered separately by
// TestSubAgentProcessDefaultCall.
func TestSubAgentProcessModeFake(t *testing.T) {
t.Run("happy", func(t *testing.T) {
sub := NewSubAgentTool(SubAgentSpec{
Name: "proc",
Isolation: SubAgentIsolationProcess,
Process: SubAgentProcessConfig{Model: "faux"},
SystemPrompt: "you are a subprocess child",
})
var got SubAgentRunParams
sub.processCall = func(ctx context.Context, cfg SubAgentProcessConfig, params SubAgentRunParams) (string, error) {
got = params
return "process result: 99", nil
}
res, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"find it"}`), nil)
if err != nil {
t.Fatalf("Execute err = %v", err)
}
if got := agentcore.ContentToText(res.Content); got != "process result: 99" {
t.Errorf("result = %q, want 'process result: 99'", got)
}
// The prompt, system prompt, and model are forwarded to the subprocess;
// NewRunConfig is NOT called (the subprocess resolves its own provider).
if got.Prompt != "find it" || got.Model != "faux" || got.SystemPrompt != "you are a subprocess child" {
t.Errorf("forwarded params = %+v", got)
}
})
t.Run("crash is tool error", func(t *testing.T) {
sub := NewSubAgentTool(SubAgentSpec{
Name: "proc",
Isolation: SubAgentIsolationProcess,
Process: SubAgentProcessConfig{Model: "faux"},
})
sub.processCall = func(context.Context, SubAgentProcessConfig, SubAgentRunParams) (string, error) {
return "", errors.New("subprocess exited: signal: killed")
}
_, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"x"}`), nil)
if err == nil {
t.Fatal("expected tool error on subprocess crash, got nil")
}
})
t.Run("missing model errors", func(t *testing.T) {
sub := NewSubAgentTool(SubAgentSpec{
Name: "proc",
Isolation: SubAgentIsolationProcess,
// Process.Model intentionally empty.
})
_, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"x"}`), nil)
if err == nil {
t.Fatal("expected error when Process.Model is missing")
}
})
t.Run("forwards tool names", func(t *testing.T) {
// spec.Tools is in-process; process mode forwards only their names so
// the subprocess can rebuild builtins by name.
sub := NewSubAgentTool(SubAgentSpec{
Name: "proc",
Isolation: SubAgentIsolationProcess,
Process: SubAgentProcessConfig{Model: "faux"},
Tools: []agentcore.AgentTool{nameOnlyTool("read"), nameOnlyTool("grep")},
})
var got SubAgentRunParams
sub.processCall = func(_ context.Context, _ SubAgentProcessConfig, params SubAgentRunParams) (string, error) {
got = params
return "ok", nil
}
if _, err := sub.Execute(context.Background(), "id", json.RawMessage(`{"prompt":"x"}`), nil); err != nil {
t.Fatalf("Execute err = %v", err)
}
if len(got.Tools) != 2 || got.Tools[0] != "read" || got.Tools[1] != "grep" {
t.Errorf("forwarded tool names = %v, want [read grep]", got.Tools)
}
})
}
// nameOnlyTool is a minimal AgentTool whose only meaningful attribute is its
// Name, used to verify process mode forwards tool names without needing real
// tool implementations.
func nameOnlyTool(name string) agentcore.AgentTool { return nameOnly{name: name} }
type nameOnly struct{ name string }
func (t nameOnly) Name() string { return t.name }
func (t nameOnly) Description() string { return "" }
func (t nameOnly) Schema() json.RawMessage { return json.RawMessage(`{}`) }
func (t nameOnly) ExecutionMode() agentcore.ToolExecutionMode {
return agentcore.ToolExecutionParallel
}
func (t nameOnly) Execute(context.Context, string, json.RawMessage, agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
return agentcore.AgentToolResult{}, nil
}
// TestRunSubAgentOnce verifies the shared subprocess-side agent core: a normal
// run returns the child's final text, and a run whose final turn stopped on
// error is reported as an error (so the subprocess surfaces it as an RPC error
// and the parent marks the tool result IsError).
func TestRunSubAgentOnce(t *testing.T) {
t.Run("success", func(t *testing.T) {
p := &fauxProvider{
name: "faux",
models: []provider.Model{{Provider: "faux", ID: "faux"}},
turns: []fauxTurn{textTurn("hello from child")},
}
text, err := RunSubAgentOnce(context.Background(), "sys", "do it", nil, newFauxRunCfg(p))
if err != nil {
t.Fatalf("err = %v", err)
}
if text != "hello from child" {
t.Errorf("text = %q, want 'hello from child'", text)
}
if p.callCount() != 1 {
t.Errorf("provider calls = %d, want 1", p.callCount())
}
})
t.Run("failed run errors", func(t *testing.T) {
p := &fauxProvider{
name: "faux",
models: []provider.Model{{Provider: "faux", ID: "faux"}},
turns: []fauxTurn{errorTurn("boom")},
}
_, err := RunSubAgentOnce(context.Background(), "sys", "do it", nil, newFauxRunCfg(p))
if err == nil {
t.Fatal("expected error for failed child run, got nil")
}
// The diagnostic is in ErrorMessage (errorTurn sets no Content); the
// subprocess must surface it rather than a bare "error" stop reason.
if !strings.Contains(err.Error(), "boom") {
t.Errorf("error %q does not contain the 'boom' diagnostic", err.Error())
}
})
}
// TestSubAgentProcessDefaultCall exercises the real defaultProcessCall transport
// (exec + stdio JSON-RPC) against a tiny compiled helper binary. It verifies the
// happy round-trip (prompt forwarded, result decoded) and that a crashing
// subprocess is surfaced as a Go error (the AC: "a subprocess crash is caught by the parent as a tool error").
func TestSubAgentProcessDefaultCall(t *testing.T) {
bin := buildSubAgentHelper(t)
cfg := SubAgentProcessConfig{Command: bin}
t.Run("happy round-trip", func(t *testing.T) {
text, err := defaultProcessCall(context.Background(), cfg, SubAgentRunParams{
Prompt: "hello", Model: "faux", SystemPrompt: "sys",
})
if err != nil {
t.Fatalf("defaultProcessCall err = %v", err)
}
if text != "echo: hello" {
t.Errorf("text = %q, want 'echo: hello'", text)
}
})
t.Run("crash is error", func(t *testing.T) {
_, err := defaultProcessCall(context.Background(), cfg, SubAgentRunParams{
Prompt: "CRASH", Model: "faux",
})
if err == nil {
t.Fatal("expected error for crashing subprocess, got nil")
}
})
}
// buildSubAgentHelper compiles the sub-agent helper binary into a temp dir and
// returns its path. The helper speaks the same JSON-RPC "subagent/run" wire
// format as pigo --subagent-rpc (decoded with plain encoding/json, no internal
// imports, so it stays a standalone main package).
func buildSubAgentHelper(t *testing.T) string {
t.Helper()
dir := t.TempDir()
srcPath := filepath.Join(dir, "subagent_helper.go")
if err := os.WriteFile(srcPath, []byte(subAgentHelperSrc), 0o644); err != nil {
t.Fatalf("write helper source: %v", err)
}
bin := filepath.Join(dir, "subagent_helper")
if goruntime.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 sub-agent helper: %v\n%s", err, out)
}
return bin
}
// subAgentHelperSrc is a tiny JSON-RPC server mirroring pigo --subagent-rpc:
// read a "subagent/run" request per line, and either respond with
// {text:"echo: <prompt>"} or, when the prompt is "CRASH", exit without
// responding to simulate a subprocess crash.
const subAgentHelperSrc = `package main
import (
"bufio"
"encoding/json"
"os"
)
type params struct {
Prompt string ` + "`json:\"prompt\"`" + `
}
type request struct {
ID *json.RawMessage ` + "`json:\"id\"`" + `
Method string ` + "`json:\"method\"`" + `
Params params ` + "`json:\"params\"`" + `
}
func main() {
sc := bufio.NewScanner(os.Stdin)
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
enc := json.NewEncoder(os.Stdout)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var r request
if err := json.Unmarshal(line, &r); err != nil {
continue
}
if r.Params.Prompt == "CRASH" {
// Simulate a subprocess crash: exit without writing a response so
// the parent's JSON-RPC reader sees EOF and fails the call.
os.Exit(1)
}
result, _ := json.Marshal(map[string]string{"text": "echo: " + r.Params.Prompt})
resp := map[string]any{
"jsonrpc": "2.0",
"id": r.ID,
"result": json.RawMessage(result),
}
_ = enc.Encode(resp)
}
}
`