Files
multica/server/pkg/agent/pi_test.go
92d0b6666d MUL-7812: fix(agent): deliver MCP servers to Pi tasks (#9063)
* fix(agent): deliver MCP servers to Pi tasks

Pi skipped untrusted project config in JSON mode, and Multica never wrote .pi/mcp.json, so composed MCP servers never reached the runtime.

Co-authored-by: Cursor <[email protected]>

* fix(agent): hand Pi its MCP servers through a temporary extension

Writing .pi/mcp.json and passing --approve trusted the whole project directory and failed when that file already existed. An explicit --extension loads the servers without project trust.

Co-authored-by: Cursor <[email protected]>

* docs: mark Pi as reading agent MCP configuration

The provider comparison still said Pi skipped the MCP field. Pi now receives that configuration.

Co-authored-by: Cursor <[email protected]>

* fix(agent): harden Pi MCP registration

Co-authored-by: multica-agent <[email protected]>

* fix(agent): allow empty Pi MCP configuration

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: Yushen <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-12 13:26:00 +08:00

1291 lines
44 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func TestBuildPiArgsNoToolAllowlist(t *testing.T) {
// Extension tools registered via Pi's registerTool() must not be
// filtered out by a hardcoded --tools allowlist. Omitting --tools
// lets Pi use its full tool registry. See #2379.
args := buildPiArgs("/tmp/session.jsonl", ExecOptions{}, slog.Default())
for i, arg := range args {
if arg == "--tools" {
t.Errorf("buildPiArgs emits --tools %q; should not restrict tool registry (see #2379)", args[i+1])
}
}
}
func TestBuildPiArgsBasicFlags(t *testing.T) {
args := buildPiArgs("/tmp/s.jsonl", ExecOptions{
Model: "anthropic/claude-sonnet-4-20250514",
ThinkingLevel: "high",
}, slog.Default())
joined := strings.Join(args, " ")
for _, want := range []string{"-p", "--mode json", "--session /tmp/s.jsonl", "--model anthropic/claude-sonnet-4-20250514", "--thinking high"} {
if !strings.Contains(joined, want) {
t.Errorf("expected %q in args, got: %v", want, args)
}
}
if got := countArg(args, "--approve"); got != 0 {
t.Errorf("--approve count = %d, want 0 in %v", got, args)
}
if countArg(args, "--extension") != 0 {
t.Errorf("extension flag without a managed config: %v", args)
}
for _, arg := range args {
if arg == "hello world" {
t.Fatalf("prompt leaked into argv: %v", args)
}
}
}
// Pi reads the per-task AGENTS.md the daemon writes into the workdir, so the
// daemon never populates SystemPrompt for it (providerNeedsInlineSystemPrompt).
// Forwarding it anyway would duplicate the whole runtime brief on every turn.
func TestBuildPiArgsPreservesProjectTrustFlags(t *testing.T) {
args := buildPiArgs("/tmp/s.jsonl", ExecOptions{
CustomArgs: []string{"--no-approve", "-a", "--approve", "-na", "--offline"},
}, slog.Default())
for _, kept := range []string{"--no-approve", "-a", "--approve", "-na", "--offline"} {
if countArg(args, kept) != 1 {
t.Errorf("custom %s missing from %v", kept, args)
}
}
}
func TestBuildPiArgsAppendsMcpExtension(t *testing.T) {
extensionPath := "/tmp/multica-pi-mcp-test/mcp-extension.mjs"
args := buildPiArgs("/tmp/s.jsonl", ExecOptions{
CustomArgs: []string{"--offline", "--approve"},
piMcpExtensionPath: extensionPath,
}, slog.Default())
if countArg(args, "--approve") != 1 {
t.Fatalf("custom --approve missing from %v", args)
}
if len(args) < 2 || args[len(args)-2] != "--extension" || args[len(args)-1] != extensionPath {
t.Fatalf("args = %v, want --extension %s at the end", args, extensionPath)
}
if countArg(args, "--offline") != 1 {
t.Fatalf("custom --offline missing from %v", args)
}
}
func countArg(args []string, want string) int {
n := 0
for _, arg := range args {
if arg == want {
n++
}
}
return n
}
func TestBuildPiArgsIgnoresSystemPrompt(t *testing.T) {
args := buildPiArgs("/tmp/s.jsonl", ExecOptions{
SystemPrompt: "the entire multica runtime brief",
}, slog.Default())
for _, a := range args {
if a == "--append-system-prompt" {
t.Fatalf("unexpected --append-system-prompt in args: %v", args)
}
if a == "the entire multica runtime brief" {
t.Fatalf("SystemPrompt leaked into args: %v", args)
}
}
}
func TestBuildPiArgsCustomArgsAppended(t *testing.T) {
// Users can still restrict tools via custom_args if desired.
args := buildPiArgs("/tmp/s.jsonl", ExecOptions{
CustomArgs: []string{"--tools", "read,bash"},
}, slog.Default())
found := false
for i, arg := range args {
if arg == "--tools" && i+1 < len(args) && args[i+1] == "read,bash" {
found = true
}
}
if !found {
t.Errorf("custom --tools should pass through via custom_args, got: %v", args)
}
}
func TestBuildPiArgsFiltersCustomInputButKeepsOptionValues(t *testing.T) {
t.Parallel()
args := buildPiArgs("/tmp/s.jsonl", ExecOptions{
CustomArgs: []string{
"--tools", "read,bash",
"positional-input",
"@prompt.md",
"--verbose",
"after-boolean",
"--extension-option", "extension-value",
"--thinking", "low",
"--thinking=medium",
"--offline",
"trailing-input",
},
}, slog.Default())
joined := strings.Join(args, "\x00")
for _, unwanted := range []string{"positional-input", "@prompt.md", "after-boolean", "trailing-input"} {
if strings.Contains(joined, unwanted) {
t.Errorf("custom input %q should be filtered, got %v", unwanted, args)
}
}
for _, pair := range [][2]string{
{"--tools", "read,bash"},
{"--extension-option", "extension-value"},
} {
found := false
for i := 0; i+1 < len(args); i++ {
if args[i] == pair[0] && args[i+1] == pair[1] {
found = true
break
}
}
if !found {
t.Errorf("option/value %q %q missing from %v", pair[0], pair[1], args)
}
}
for _, arg := range args {
if arg == "--thinking" || strings.HasPrefix(arg, "--thinking=") || arg == "low" || arg == "medium" {
t.Errorf("custom --thinking must be owned by thinking_level and filtered, got %v", args)
}
}
}
func TestBuildPiArgsThinkingLevelOverridesCustomArgs(t *testing.T) {
t.Parallel()
args := buildPiArgs("/tmp/s.jsonl", ExecOptions{
ThinkingLevel: "max",
CustomArgs: []string{"--thinking", "low", "--thinking=medium"},
}, slog.Default())
count := 0
for i, arg := range args {
if arg == "--thinking" {
count++
if i+1 >= len(args) || args[i+1] != "max" {
t.Fatalf("selected thinking level missing from args: %v", args)
}
}
if strings.HasPrefix(arg, "--thinking=") {
t.Fatalf("custom inline thinking flag leaked through: %v", args)
}
}
if count != 1 {
t.Fatalf("expected exactly one daemon-owned --thinking flag, got %d in %v", count, args)
}
}
func TestPiExecuteRejectsEmptyPrompt(t *testing.T) {
t.Parallel()
backend, err := New("pi", Config{ExecutablePath: "/does/not/need/to/exist", Logger: slog.Default()})
if err != nil {
t.Fatalf("New(pi): %v", err)
}
if _, err := backend.Execute(t.Context(), " \n\t ", ExecOptions{}); err == nil || !strings.Contains(err.Error(), "prompt must not be empty") {
t.Fatalf("Execute error = %v, want empty-prompt error", err)
}
}
func TestPiExecuteRejectsConcurrentSessionWriter(t *testing.T) {
t.Parallel()
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
if err := os.WriteFile(sessionPath, []byte("{}\n"), 0o644); err != nil {
t.Fatalf("create session file: %v", err)
}
claim, locked, err := tryLockPiSessionFile(sessionPath)
if err != nil {
t.Fatalf("lock session file: %v", err)
}
if !locked {
t.Fatal("first session-file lock was unexpectedly busy")
}
defer releasePiSessionFileLock(claim)
executable, err := os.Executable()
if err != nil {
t.Fatalf("locate test executable: %v", err)
}
backend, err := New("pi", Config{ExecutablePath: executable, Logger: slog.Default()})
if err != nil {
t.Fatalf("New(pi): %v", err)
}
session, err := backend.Execute(t.Context(), "follow-up", ExecOptions{ResumeSessionID: sessionPath})
if err != nil {
t.Fatalf("Execute: %v", err)
}
for range session.Messages {
}
result, ok := <-session.Result
if !ok {
t.Fatal("result channel closed without a value")
}
if result.Status != "failed" || !result.ResumeRejectedTransient {
t.Fatalf("result = %+v, want failed ResumeRejectedTransient", result)
}
if result.ResumeRejected {
t.Fatal("a busy session is still healthy and must not be permanently rejected")
}
if result.SessionID != "" {
t.Fatalf("SessionID = %q, want empty so the live transcript is not republished", result.SessionID)
}
if !strings.Contains(result.Error, "already in use") {
t.Fatalf("error = %q, want session-in-use diagnostic", result.Error)
}
}
// TestPiExecuteAttachesStdinPipe verifies that the Pi backend spawns the child
// with an explicit stdin pipe, writes the task prompt, and closes it. Closing
// delivers both the end-of-prompt signal and the EOF that keeps Pi from
// blocking under systemd (#2188).
//
// The probe is structural rather than behavioral: a shell script in
// place of `pi` inspects /proc/self/fd/0, drains it to EOF, and only emits a
// valid event stream when both the pipe type and prompt are correct.
func TestPiExecuteAttachesStdinPipe(t *testing.T) {
t.Parallel()
if runtime.GOOS != "linux" {
// /proc/self/fd/0 is Linux-specific; skipping elsewhere keeps
// the assertion portable without losing CI coverage.
t.Skip("stdin fd inspection relies on /proc/self/fd/0")
}
fakePath := filepath.Join(t.TempDir(), "pi")
script := "#!/bin/sh\n" +
"kind=$(stat -c '%F' -L /proc/self/fd/0 2>/dev/null || echo unknown)\n" +
"payload=$(cat)\n" +
"case \"$kind\" in\n" +
" fifo|*pipe*)\n" +
" if [ \"$payload\" = 'prompt-over-stdin' ]; then\n" +
" printf '%s\\n' '{\"type\":\"agent_start\"}'\n" +
" printf '%s\\n' '{\"type\":\"turn_end\",\"message\":{\"role\":\"assistant\",\"model\":\"test\",\"usage\":{\"input\":1,\"output\":1,\"cacheRead\":0,\"cacheWrite\":0,\"totalTokens\":2}}}'\n" +
" exit 0\n" +
" fi\n" +
" ;;\n" +
"esac\n" +
"printf 'stdin was %s with payload %s; expected fifo and prompt\\n' \"$kind\" \"$payload\" >&2\n" +
"exit 1\n"
writeTestExecutable(t, fakePath, []byte(script))
backend, err := New("pi", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new pi backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
session, err := backend.Execute(ctx, "prompt-over-stdin", ExecOptions{
Timeout: 5 * time.Second,
ResumeSessionID: sessionPath,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result, ok := <-session.Result:
if !ok {
t.Fatal("result channel closed without a value")
}
if result.Status != "completed" {
t.Fatalf("expected status=completed (stdin attached as fifo), got %q (error=%q)", result.Status, result.Error)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
claim, locked, err := tryLockPiSessionFile(sessionPath)
if err != nil {
t.Fatalf("lock completed session: %v", err)
}
if !locked {
t.Fatal("Pi backend returned its result before releasing the session-file lock")
}
releasePiSessionFileLock(claim)
}
// piEventStreamScript builds a sh script that prints each JSON event on
// its own stdout line. Fixtures must not contain single quotes.
func piEventStreamScript(events []string) string {
return piEventStreamScriptWithExit(events, 0)
}
// piEventStreamScriptWithExit is piEventStreamScript plus an explicit
// process exit code. Real Pi (and pi-print-clean-exit) exits 1 after a
// turn whose last assistant stopReason is "error".
func piEventStreamScriptWithExit(events []string, exitCode int) string {
var b strings.Builder
b.WriteString("#!/bin/sh\n")
// Real Pi reads the piped prompt to EOF before emitting events, so the fake
// must drain stdin too. A fake that exits without reading closes the read end
// while the backend is still writing the prompt, and the resulting EPIPE is
// reported as "pi prompt write failed" — a load-dependent flake that has
// nothing to do with what these tests assert.
b.WriteString("cat > /dev/null\n")
for _, e := range events {
b.WriteString("printf '%s\\n' '")
b.WriteString(e)
b.WriteString("'\n")
}
if exitCode != 0 {
b.WriteString(fmt.Sprintf("exit %d\n", exitCode))
}
return b.String()
}
func newPiTestBackend(t *testing.T, script string, turnErrorGrace time.Duration) *piBackend {
t.Helper()
fakePath := filepath.Join(t.TempDir(), "pi")
writeTestExecutable(t, fakePath, []byte(script))
backend, err := New("pi", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new pi backend: %v", err)
}
pi, ok := backend.(*piBackend)
if !ok {
t.Fatalf("New(pi) returned %T, want *piBackend", backend)
}
pi.turnErrorGrace = turnErrorGrace
return pi
}
func waitPiResult(t *testing.T, session *Session, timeout time.Duration) Result {
t.Helper()
select {
case result, ok := <-session.Result:
if !ok {
t.Fatal("result channel closed without a value")
}
return result
case <-time.After(timeout):
t.Fatal("timeout waiting for Pi result")
return Result{}
}
}
func TestPiExecutePreservesTurnErrorWhenCancelled(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
const providerError = "OpenAI API error (413): Failed to buffer the request body: length limit exceeded"
script := piEventStreamScript([]string{
`{"type":"agent_start"}`,
`{"type":"turn_start"}`,
`{"type":"turn_end","message":{"role":"assistant","model":"test","stopReason":"error","errorMessage":"` + providerError + `"}}`,
`{"type":"message_update","assistantMessageEvent":{"type":"thinking_delta","delta":"post-error activity"}}`,
}) + "exec sleep 300\n"
backend := newPiTestBackend(t, script, time.Minute)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
ResumeSessionID: filepath.Join(t.TempDir(), "session.jsonl"),
})
if err != nil {
t.Fatalf("execute: %v", err)
}
for msg := range session.Messages {
if msg.Type == MessageThinking && msg.Content == "post-error activity" {
cancel()
break
}
}
result := waitPiResult(t, session, 5*time.Second)
if result.Status != "failed" {
t.Fatalf("status = %q, want failed (error=%q)", result.Status, result.Error)
}
if result.Error != providerError {
t.Fatalf("error = %q, want original provider error %q", result.Error, providerError)
}
}
func TestPiExecuteCancellationWithoutTurnErrorKeepsAbortedResult(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
script := piEventStreamScript([]string{`{"type":"agent_start"}`}) + "exec sleep 300\n"
backend := newPiTestBackend(t, script, 50*time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
ResumeSessionID: filepath.Join(t.TempDir(), "session.jsonl"),
})
if err != nil {
t.Fatalf("execute: %v", err)
}
for msg := range session.Messages {
if msg.Type == MessageStatus {
cancel()
break
}
}
result := waitPiResult(t, session, 5*time.Second)
if result.Status != "aborted" || result.Error != "execution cancelled" {
t.Fatalf("result = %+v, want the existing no-error cancellation result", result)
}
}
func TestPiExecuteEndsSilentTurnErrorAfterGraceOnce(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
const providerError = "OpenAI API error (413): request body too large"
const grace = 80 * time.Millisecond
script := piEventStreamScript([]string{
`{"type":"agent_start"}`,
`{"type":"turn_start"}`,
`{"type":"turn_end","message":{"role":"assistant","model":"test","stopReason":"error","errorMessage":"` + providerError + `"}}`,
`{"type":"agent_end","messages":[],"willRetry":false}`,
}) + "exec sleep 300\n"
backend := newPiTestBackend(t, script, grace)
started := time.Now()
session, err := backend.Execute(context.Background(), "prompt-ignored", ExecOptions{
Timeout: 15 * time.Second,
ResumeSessionID: filepath.Join(t.TempDir(), "session.jsonl"),
})
if err != nil {
t.Fatalf("execute: %v", err)
}
result := waitPiResult(t, session, 20*time.Second)
if result.Status != "failed" || result.Error != providerError {
t.Fatalf("result = %+v, want one failed result with the provider error", result)
}
for msg := range session.Messages {
if msg.Type == MessageError {
t.Fatalf("turn-error grace emitted an error message and would refresh the daemon watchdog: %+v", msg)
}
}
if elapsed := time.Since(started); elapsed < grace/2 || elapsed > 10*time.Second {
t.Fatalf("error grace ended after %s, want approximately %s", elapsed, grace)
}
if _, ok := <-session.Result; ok {
t.Fatal("result channel produced more than one terminal result")
}
}
func TestPiExecuteTurnErrorActivityAndRetryRecoveryCancelTimer(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
const grace = 100 * time.Millisecond
script := "#!/bin/sh\n" +
"cat > /dev/null\n" +
`printf '%s\n' '{"type":"agent_start"}'` + "\n" +
`printf '%s\n' '{"type":"turn_start"}'` + "\n" +
`printf '%s\n' '{"type":"turn_end","message":{"role":"assistant","model":"test","stopReason":"error","errorMessage":"temporary provider error"}}'` + "\n" +
"sleep 0.06\n" +
`printf '%s\n' '{"type":"message_update","assistantMessageEvent":{"type":"thinking_delta","delta":"retrying"}}'` + "\n" +
"sleep 0.06\n" +
`printf '%s\n' '{"type":"auto_retry_start","attempt":1,"maxAttempts":3,"delayMs":1}'` + "\n" +
"sleep 0.15\n" +
`printf '%s\n' '{"type":"turn_start"}'` + "\n" +
`printf '%s\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"recovered"}}'` + "\n" +
`printf '%s\n' '{"type":"turn_end","message":{"role":"assistant","model":"test"}}'` + "\n"
backend := newPiTestBackend(t, script, grace)
session, err := backend.Execute(context.Background(), "prompt-ignored", ExecOptions{
Timeout: 15 * time.Second,
ResumeSessionID: filepath.Join(t.TempDir(), "session.jsonl"),
})
if err != nil {
t.Fatalf("execute: %v", err)
}
result := waitPiResult(t, session, 20*time.Second)
if result.Status != "completed" || result.Output != "recovered" || result.Error != "" {
t.Fatalf("result = %+v, want successful recovered turn", result)
}
}
func TestPiExecuteTurnErrorGraceWaitsForInFlightTool(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
const grace = 60 * time.Millisecond
script := "#!/bin/sh\n" +
"cat > /dev/null\n" +
`printf '%s\n' '{"type":"turn_start"}'` + "\n" +
`printf '%s\n' '{"type":"tool_execution_start","toolCallId":"call-1","toolName":"bash","args":{}}'` + "\n" +
`printf '%s\n' '{"type":"turn_end","message":{"role":"assistant","model":"test","stopReason":"error","errorMessage":"temporary provider error"}}'` + "\n" +
"sleep 0.15\n" +
`printf '%s\n' '{"type":"tool_execution_end","toolCallId":"call-1","toolName":"bash","result":"ok"}'` + "\n" +
`printf '%s\n' '{"type":"turn_start"}'` + "\n" +
`printf '%s\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"done"}}'` + "\n" +
`printf '%s\n' '{"type":"turn_end","message":{"role":"assistant","model":"test"}}'` + "\n"
backend := newPiTestBackend(t, script, grace)
session, err := backend.Execute(context.Background(), "prompt-ignored", ExecOptions{
Timeout: 15 * time.Second,
ResumeSessionID: filepath.Join(t.TempDir(), "session.jsonl"),
})
if err != nil {
t.Fatalf("execute: %v", err)
}
result := waitPiResult(t, session, 20*time.Second)
if result.Status != "completed" || result.Output != "done" {
t.Fatalf("result = %+v, want tool completion and recovered turn", result)
}
}
func TestPiExecuteNormalSilenceDoesNotArmTurnErrorGrace(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
const grace = 50 * time.Millisecond
script := "#!/bin/sh\n" +
"cat > /dev/null\n" +
`printf '%s\n' '{"type":"agent_start"}'` + "\n" +
"sleep 0.15\n" +
`printf '%s\n' '{"type":"turn_start"}'` + "\n" +
`printf '%s\n' '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"healthy"}}'` + "\n" +
`printf '%s\n' '{"type":"turn_end","message":{"role":"assistant","model":"test"}}'` + "\n"
backend := newPiTestBackend(t, script, grace)
session, err := backend.Execute(context.Background(), "prompt-ignored", ExecOptions{
Timeout: 15 * time.Second,
ResumeSessionID: filepath.Join(t.TempDir(), "session.jsonl"),
})
if err != nil {
t.Fatalf("execute: %v", err)
}
result := waitPiResult(t, session, 20*time.Second)
if result.Status != "completed" || result.Output != "healthy" {
t.Fatalf("result = %+v, want normal silent run to complete", result)
}
}
// TestPiExecuteRetainsOnlyLastTurnOutput verifies turn_start resets the
// output buffer so Result.Output keeps only the final turn's text.
func TestPiExecuteRetainsOnlyLastTurnOutput(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
events := []string{
`{"type":"agent_start"}`,
`{"type":"turn_start"}`,
`{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"intermediate"}}`,
`{"type":"tool_execution_start","toolCallId":"call_1","toolName":"bash","args":{"command":"echo hi"}}`,
`{"type":"tool_execution_end","toolCallId":"call_1","toolName":"bash","result":{"content":[{"type":"text","text":"hi"}]},"isError":false}`,
`{"type":"turn_end","message":{"role":"assistant","model":"test","usage":{"input":1,"output":1}}}`,
`{"type":"turn_start"}`,
`{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"final"}}`,
`{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":" "}}`,
`{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"answer"}}`,
`{"type":"turn_end","message":{"role":"assistant","model":"test","usage":{"input":2,"output":2}}}`,
}
fakePath := filepath.Join(t.TempDir(), "pi")
writeTestExecutable(t, fakePath, []byte(piEventStreamScript(events)))
backend, err := New("pi", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new pi backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result := <-session.Result:
if result.Status != "completed" {
t.Fatalf("expected status=completed, got %q (error=%q)", result.Status, result.Error)
}
if result.Output != "final answer" {
t.Fatalf("Output: got %q, want %q", result.Output, "final answer")
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
func TestPiExecuteFailsOnUnretriedTurnError(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
// Pi ends the turn with stopReason=error and declines to retry (401, 429
// and friends). It emits no `error` event and no auto_retry_end, and exits
// 0, so the terminal state lives only on turn_end.
events := []string{
`{"type":"agent_start"}`,
`{"type":"turn_start"}`,
`{"type":"turn_end","message":{"role":"assistant","content":[],"model":"test","usage":{"input":0,"output":0},"stopReason":"error","errorMessage":"OpenAI API error (401): invalid token"}}`,
`{"type":"agent_end","messages":[],"willRetry":false}`,
}
fakePath := filepath.Join(t.TempDir(), "pi")
writeTestExecutable(t, fakePath, []byte(piEventStreamScript(events)))
backend, err := New("pi", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new pi backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result := <-session.Result:
if result.Status != "failed" {
t.Fatalf("expected status=failed, got %q (error=%q)", result.Status, result.Error)
}
if !strings.Contains(result.Error, "invalid token") {
t.Fatalf("Error: got %q, want it to carry the provider message", result.Error)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
func TestPiExecuteSucceedsWhenRetryFollowsTurnError(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
// The same stopReason=error precedes an automatic retry, on turn_end and
// again on agent_end. The retry succeeds, so the run must not inherit the
// first turn's failure.
events := []string{
`{"type":"agent_start"}`,
`{"type":"turn_start"}`,
`{"type":"turn_end","message":{"role":"assistant","content":[],"model":"test","usage":{"input":0,"output":0},"stopReason":"error","errorMessage":"OpenAI API error (503): no available channel"}}`,
`{"type":"agent_end","messages":[{"role":"assistant","content":[],"model":"test","usage":{"input":0,"output":0},"stopReason":"error","errorMessage":"OpenAI API error (503): no available channel"}],"willRetry":true}`,
`{"type":"auto_retry_start","attempt":1,"maxAttempts":3,"delayMs":1}`,
`{"type":"agent_start"}`,
`{"type":"turn_start"}`,
`{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"recovered"}}`,
`{"type":"turn_end","message":{"role":"assistant","model":"test","usage":{"input":2,"output":2}}}`,
}
fakePath := filepath.Join(t.TempDir(), "pi")
writeTestExecutable(t, fakePath, []byte(piEventStreamScript(events)))
backend, err := New("pi", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new pi backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result := <-session.Result:
if result.Status != "completed" {
t.Fatalf("expected status=completed, got %q (error=%q)", result.Status, result.Error)
}
if result.Output != "recovered" {
t.Fatalf("Output: got %q, want %q", result.Output, "recovered")
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
func TestPiExecuteKeepsTurnErrorWhenProcessExitsNonZero(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
// Real Pi (and pi-print-clean-exit) exits 1 after stopReason=error.
// Wait() used to win the completed-branch and drop lastTurnError, so
// the classifier only saw "exit status 1" (non-retryable
// process_failure). The provider message must survive so
// "Connection error." / "Request timed out." classify as
// provider_network.
events := []string{
`{"type":"agent_start"}`,
`{"type":"turn_start"}`,
`{"type":"turn_end","message":{"role":"assistant","content":[],"model":"test","usage":{"input":0,"output":0},"stopReason":"error","errorMessage":"Connection error."}}`,
`{"type":"agent_end","messages":[],"willRetry":false}`,
}
fakePath := filepath.Join(t.TempDir(), "pi")
writeTestExecutable(t, fakePath, []byte(piEventStreamScriptWithExit(events, 1)))
backend, err := New("pi", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new pi backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result := <-session.Result:
if result.Status != "failed" {
t.Fatalf("expected status=failed, got %q (error=%q)", result.Status, result.Error)
}
if !strings.Contains(result.Error, "Connection error.") {
t.Fatalf("Error: got %q, want it to carry the provider message", result.Error)
}
if !strings.Contains(result.Error, "exited with error") {
t.Fatalf("Error: got %q, want the process exit still visible as a suffix", result.Error)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
// piKeyErrorAgentEnd is the agent_end Pi 0.74.0 emits when the provider's API
// key command fails: one empty assistant message, and nothing else reports it.
const piKeyErrorAgentEnd = `{"type":"agent_end","messages":[{"role":"assistant","content":[{"type":"text","text":""}],"model":"test","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0},"stopReason":"error","errorMessage":"Failed to resolve API key for provider \"gateway\" from shell command: key-helper"}]}`
func TestPiExecuteFailsOnAgentEndOnlyError(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
// A failure raised before the first model call reaches the stream only on
// agent_end: no turn_end, no error event, no auto_retry_end, exit 0.
tests := []struct {
name string
agentEnd string
want string
}{
{
name: "provider message",
agentEnd: piKeyErrorAgentEnd,
want: `Failed to resolve API key for provider "gateway" from shell command: key-helper`,
},
{
name: "empty message",
agentEnd: `{"type":"agent_end","messages":[{"role":"assistant","content":[],"model":"test","stopReason":"error"}]}`,
want: "pi ended the run with an error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
script := piEventStreamScript([]string{
`{"type":"agent_start"}`,
`{"type":"turn_start"}`,
`{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"prompt"}]}}`,
`{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"prompt"}]}}`,
tt.agentEnd,
})
backend := newPiTestBackend(t, script, 0)
session, err := backend.Execute(context.Background(), "prompt-ignored", ExecOptions{
Timeout: 5 * time.Second,
ResumeSessionID: filepath.Join(t.TempDir(), "session.jsonl"),
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
result := waitPiResult(t, session, 10*time.Second)
if result.Status != "failed" || result.Error != tt.want {
t.Fatalf("result = {Status:%q Error:%q}, want failed with %q", result.Status, result.Error, tt.want)
}
})
}
}
func TestPiExecuteFailsOnAgentEndErrorAfterToolWork(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
// Pi resolves the API key again for every model request, so a key that
// stops resolving mid-run fails the next turn after real work. That turn
// starts and then only agent_end reports the failure.
script := piEventStreamScript([]string{
`{"type":"agent_start"}`,
`{"type":"turn_start"}`,
`{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Checking."}}`,
`{"type":"tool_execution_start","toolCallId":"call_1","toolName":"bash","args":{"command":"echo hi"}}`,
`{"type":"tool_execution_end","toolCallId":"call_1","toolName":"bash","result":{"content":[{"type":"text","text":"hi"}]},"isError":false}`,
`{"type":"turn_end","message":{"role":"assistant","content":[],"model":"test","usage":{"input":5,"output":3,"cacheRead":0,"cacheWrite":0,"totalTokens":8},"stopReason":"toolUse"}}`,
`{"type":"turn_start"}`,
piKeyErrorAgentEnd,
})
backend := newPiTestBackend(t, script, 0)
session, err := backend.Execute(context.Background(), "prompt-ignored", ExecOptions{
Timeout: 5 * time.Second,
ResumeSessionID: filepath.Join(t.TempDir(), "session.jsonl"),
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
result := waitPiResult(t, session, 10*time.Second)
want := `Failed to resolve API key for provider "gateway" from shell command: key-helper`
if result.Status != "failed" || result.Error != want {
t.Fatalf("result = {Status:%q Error:%q}, want failed with %q", result.Status, result.Error, want)
}
if got := result.Usage["test"]; got.InputTokens != 5 || got.OutputTokens != 3 {
t.Fatalf("Usage[test] = %+v, want the completed turn's usage kept", got)
}
}
func TestPiExecuteFailsOnScannerOverflow(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
// agent_end repeats every message of the run, so on a long run it is the
// line most likely to outgrow the shared bound. Once the scanner stops, the
// run's terminal state is unknown and must not read as success. The line is
// sized from agentStreamMaxLineBytes so raising the cap keeps this test on
// the overflow branch.
script := piEventStreamScript([]string{
`{"type":"agent_start"}`,
`{"type":"turn_start"}`,
`{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"done"}}`,
`{"type":"turn_end","message":{"role":"assistant","model":"test","usage":{"input":1,"output":1}}}`,
}) + fmt.Sprintf("dd if=/dev/zero bs=1048576 count=%d 2>/dev/null | tr '\\000' x\nprintf '\\n'\n",
agentStreamMaxLineBytes/(1024*1024)+1)
backend := newPiTestBackend(t, script, 0)
// A generous timeout: without the fix this run hangs until it expires, and
// a loaded host must not turn the fixed run into a timeout either.
session, err := backend.Execute(context.Background(), "prompt-ignored", ExecOptions{
Timeout: 30 * time.Second,
ResumeSessionID: filepath.Join(t.TempDir(), "session.jsonl"),
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
result := waitPiResult(t, session, 40*time.Second)
if result.Status != "failed" {
t.Fatalf("result = {Status:%q Error:%q}, want failed", result.Status, result.Error)
}
for _, want := range []string{"pi stdout read error", "token too long"} {
if !strings.Contains(result.Error, want) {
t.Errorf("Error = %q, want substring %q", result.Error, want)
}
}
}
func TestStripPiToolCallMarkup(t *testing.T) {
tests := map[string]string{
`before call:bash{command:<|"|>cd repo/path && ls -F<|"|>}<tool_call|> after`: "before after",
`before call:read{path:<|"|>repo/path/roles/example/verify.yml<|"|>} after`: "before after",
`before response:bash{command:<|"|>multica issue comment list issue-id --all --output json<|"|>} after`: "before after",
`before call:bash{command:<|"|>printf '{"key":"value"}'<|"|>} after`: "before after",
`before <|turn>model after`: "before after",
}
for in, want := range tests {
got := stripPiToolCallMarkup(in)
if got != want {
t.Fatalf("unexpected stripped text: %q, want %q", got, want)
}
}
}
func TestDrainPiTextBufferSplitToolCall(t *testing.T) {
chunks := []string{
"before ca",
`ll:bash{command:<|"|>ls -R repo/path`,
`/roles/example<|"|>}`,
" after",
}
var buf strings.Builder
var got strings.Builder
for _, chunk := range chunks {
got.WriteString(drainPiTextBuffer(&buf, chunk))
}
got.WriteString(flushPiTextBuffer(&buf))
if got.String() != "before after" {
t.Fatalf("unexpected streamed text: %q", got.String())
}
}
func TestDrainPiTextBufferSplitControlToken(t *testing.T) {
chunks := []string{"before <|tu", "rn>model after"}
var buf strings.Builder
var got strings.Builder
for _, chunk := range chunks {
got.WriteString(drainPiTextBuffer(&buf, chunk))
}
got.WriteString(flushPiTextBuffer(&buf))
if got.String() != "before after" {
t.Fatalf("unexpected streamed text: %q", got.String())
}
}
func TestFlushPiTextBufferKeepsUnmatchedToolPrefixes(t *testing.T) {
tests := []string{
"plain response: see below",
"plain call: see below",
`plain call:bash{command:<|"|>unterminated`,
}
for _, want := range tests {
var buf strings.Builder
got := drainPiTextBuffer(&buf, want)
got += flushPiTextBuffer(&buf)
if got != want {
t.Fatalf("unexpected flushed text: %q, want %q", got, want)
}
}
}
// TestBuildPiArgsKeepsSlashShapedModelIDIntact is the GH #7300 regression.
// A gateway-style provider registers model ids that themselves contain a
// slash (`claude/claude-opus-5` under provider `multica-anthropic`). Splitting
// on that slash to fill --provider produced `--provider claude --model
// claude-opus-5`, and pi answers an unknown --provider with a hard
// `Unknown provider "claude"` before it ever looks at --model. The whole
// selector must reach --model instead, where pi's resolver matches it as a
// raw model id.
func TestBuildPiArgsKeepsSlashShapedModelIDIntact(t *testing.T) {
args := buildPiArgs("/tmp/s.jsonl", ExecOptions{Model: "claude/claude-opus-5"}, slog.Default())
joined := strings.Join(args, " ")
if !strings.Contains(joined, "--model claude/claude-opus-5") {
t.Errorf("model id was not passed through intact: %v", args)
}
for _, arg := range args {
if arg == "--provider" {
t.Errorf("buildPiArgs synthesized --provider from a model id: %v", args)
}
}
}
// A fully-qualified selector is passed through the same way: pi infers the
// provider from the prefix itself, so the daemon never has to take it apart.
func TestBuildPiArgsPassesQualifiedSelectorWhole(t *testing.T) {
args := buildPiArgs("/tmp/s.jsonl", ExecOptions{Model: "multica-anthropic/claude/claude-opus-5"}, slog.Default())
joined := strings.Join(args, " ")
if !strings.Contains(joined, "--model multica-anthropic/claude/claude-opus-5") {
t.Errorf("qualified selector was not passed through intact: %v", args)
}
if strings.Contains(joined, "--provider") {
t.Errorf("buildPiArgs synthesized --provider: %v", args)
}
}
// A bare model id stays bare, and an empty model still omits --model entirely
// so pi resolves its own default.
func TestBuildPiArgsBareAndEmptyModels(t *testing.T) {
bare := buildPiArgs("", ExecOptions{Model: " claude-opus-5 "}, slog.Default())
if joined := strings.Join(bare, " "); !strings.Contains(joined, "--model claude-opus-5") {
t.Errorf("bare model not forwarded (or not trimmed): %v", bare)
}
empty := buildPiArgs("", ExecOptions{Model: " "}, slog.Default())
for _, arg := range empty {
if arg == "--model" {
t.Errorf("blank model should omit --model so pi picks its default: %v", empty)
}
}
}
func TestWritePiMcpExtensionIsolatesRegistrationErrorsAndLeavesProjectFile(t *testing.T) {
workDir := t.TempDir()
projectPath := filepath.Join(workDir, ".pi", "mcp.json")
if err := os.MkdirAll(filepath.Dir(projectPath), 0o755); err != nil {
t.Fatal(err)
}
existing := []byte(`{"mcpServers":{"user":{"command":"echo"}}}`)
if err := os.WriteFile(projectPath, existing, 0o644); err != nil {
t.Fatal(err)
}
raw := json.RawMessage(`{
"mcpServers": {
"probe": {"command": "echo", "args": ["hi"]},
"remote": {"type": "http", "url": "http://127.0.0.1:12345/mcp"},
"bad name": {"command": "nope"},
"legacy": {"type": "sse", "url": "http://example.invalid"},
"bad-shape": "not-an-object"
}
}`)
path, err := writePiMcpExtension(raw)
if err != nil {
t.Fatal(err)
}
if path == "" {
t.Fatal("expected an extension file")
}
t.Cleanup(func() { removePiMcpExtension(path) })
if !strings.HasPrefix(filepath.Base(filepath.Dir(path)), piMcpExtensionDirPrefix) {
t.Fatalf("extension dir = %s, want %s prefix", path, piMcpExtensionDirPrefix)
}
if filepath.Base(path) != "mcp-extension.mjs" {
t.Fatalf("extension file = %s", path)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("extension mode = %o, want 600", info.Mode().Perm())
}
script, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
text := string(script)
for _, want := range []string{
"registerMcpServer", "probe", "echo", "remote", `\"type\":\"http\"`,
"127.0.0.1:12345/mcp", "bad name", "legacy", "bad-shape", "try {", "catch (error)",
} {
if !strings.Contains(text, want) {
t.Fatalf("script missing %q:\n%s", want, text)
}
}
if !strings.Contains(text, "could not register MCP server") {
t.Fatalf("script does not report isolated registration failures:\n%s", text)
}
data, err := os.ReadFile(projectPath)
if err != nil {
t.Fatal(err)
}
if string(data) != string(existing) {
t.Fatalf("project mcp.json changed to %s", data)
}
args := buildPiArgs("/tmp/s.jsonl", ExecOptions{piMcpExtensionPath: path}, slog.Default())
if len(args) < 2 || args[len(args)-2] != "--extension" || args[len(args)-1] != path {
t.Fatalf("args = %v, want extension %s", args, path)
}
if countArg(args, "--approve") != 0 {
t.Fatalf("--approve present in %v", args)
}
}
func TestWritePiMcpExtensionSkipsUnmanagedAndEmpty(t *testing.T) {
for _, raw := range []json.RawMessage{
nil,
json.RawMessage("null"),
json.RawMessage(`{}`),
json.RawMessage(`{"mcpServers":{}}`),
json.RawMessage(`{"mcpServers":null}`),
} {
path, err := writePiMcpExtension(raw)
if err != nil {
t.Fatalf("writePiMcpExtension(%s): %v", raw, err)
}
if path != "" {
removePiMcpExtension(path)
t.Fatalf("writePiMcpExtension(%s) wrote %s", raw, path)
}
}
}
func TestPreparePiMcpExtensionRejectsOldBuiltinOnlyWhenServersPresent(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
raw := json.RawMessage(`{"mcpServers":{"probe":{"command":"echo"}}}`)
backend := &piBackend{
providerLabel: "pi",
cfg: Config{
BuiltinRuntime: true,
CLIVersion: "pi 0.87.1",
},
}
for _, empty := range []json.RawMessage{
nil,
json.RawMessage("null"),
json.RawMessage(`{}`),
json.RawMessage(`{"mcpServers":{}}`),
json.RawMessage(`{"mcpServers":null}`),
} {
path, err := backend.preparePiMcpExtension(empty)
if err != nil || path != "" {
t.Fatalf("empty config %s on old Pi = (%q, %v), want no extension and no error", empty, path, err)
}
}
path, err := backend.preparePiMcpExtension(raw)
if err == nil {
removePiMcpExtension(path)
t.Fatal("managed MCP config was accepted by Pi below the MCP minimum")
}
for _, want := range []string{"0.87.1", piMcpMinVersion, "upgrade"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("old Pi error %q missing %q", err, want)
}
}
matches, globErr := filepath.Glob(filepath.Join(os.Getenv("TMPDIR"), piMcpExtensionDirPrefix+"*"))
if globErr != nil {
t.Fatal(globErr)
}
if len(matches) != 0 {
t.Fatalf("old Pi rejection leaked extension directories: %v", matches)
}
}
func TestPreparePiMcpExtensionRejectsUnverifiableBuiltinVersion(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
raw := json.RawMessage(`{"mcpServers":{"probe":{"command":"echo"}}}`)
for _, version := range []string{"", "development-build"} {
backend := &piBackend{
providerLabel: "pi",
cfg: Config{
BuiltinRuntime: true,
CLIVersion: version,
},
}
path, err := backend.preparePiMcpExtension(raw)
if err == nil {
removePiMcpExtension(path)
t.Fatalf("builtin Pi version %q accepted managed MCP config", version)
}
for _, want := range []string{piMcpMinVersion, "could not verify", "upgrade"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("unverifiable Pi error %q missing %q", err, want)
}
}
}
matches, err := filepath.Glob(filepath.Join(os.Getenv("TMPDIR"), piMcpExtensionDirPrefix+"*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("unverifiable Pi rejection leaked extension directories: %v", matches)
}
}
func TestPreparePiMcpExtensionAllowsSupportedBuiltinAndCustomProtocol(t *testing.T) {
raw := json.RawMessage(`{"mcpServers":{"probe":{"command":"echo"}}}`)
for _, tc := range []struct {
name string
backend piBackend
}{
{
name: "supported builtin",
backend: piBackend{providerLabel: "pi", cfg: Config{
BuiltinRuntime: true,
CLIVersion: piMcpMinVersion,
}},
},
{
name: "custom protocol implementation",
backend: piBackend{providerLabel: "pi", cfg: Config{
BuiltinRuntime: false,
CLIVersion: "0.1.0",
}},
},
} {
t.Run(tc.name, func(t *testing.T) {
path, err := tc.backend.preparePiMcpExtension(raw)
if err != nil {
t.Fatal(err)
}
if path == "" {
t.Fatal("expected MCP extension path")
}
removePiMcpExtension(path)
})
}
}
func TestRemovePiMcpExtensionIgnoresOtherPaths(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mcp-extension.mjs")
if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil {
t.Fatal(err)
}
removePiMcpExtension(path)
if _, err := os.Stat(path); err != nil {
t.Fatalf("unrelated extension file was removed: %v", err)
}
}
func TestPreparePiMcpExtensionSkipsOmp(t *testing.T) {
backend := &piBackend{providerLabel: "omp"}
path, err := backend.preparePiMcpExtension(json.RawMessage(`{"mcpServers":{"probe":{"command":"echo"}}}`))
if err != nil {
t.Fatal(err)
}
if path != "" {
removePiMcpExtension(path)
t.Fatalf("omp wrote a pi extension at %s", path)
}
}