Files
DeepSeek-Reasonix/internal/plugin/example_test.go
T
YHHandSivan 963fe55216 feat(plugin): defer prompts/resources to a background phase (#2701)
* feat(plugin): defer prompts/resources to a background phase

Split the MCP handshake into phase A (initialize + listTools, blocking)
and phase B (listPrompts + listResources, deferred). Boot now blocks
only on the tools that the agent needs in its first turn; the slower
auxiliary surfaces stream in afterwards and fan out an MCPSurfaceReady
event so UIs can refresh /mcp status without polling.

addConnected (the hot-add path) gets the same split so /mcp add doesn't
block on auxiliary listings either; phase B fires from a goroutine bound
to the session ctx, not the per-turn one.

Reuses the existing event sink wiring; the new MCPSurfaceReady Kind is
appended last to preserve wire-stable Kind values.

* fix(cli/acp): drive phase B for ACP-session MCP hosts too

Codex review caught that the ACP session-bootstrap path in internal/cli/acp.go
still only called plugin.StartAvailable; after PR #2677 made prompts and
resources phase-B-only, ACP sessions saw Host.Prompts()/Resources() stay
empty for the whole session — Controller.MCPPrompt and any @-ref consumer
would never resolve.

Mirror boot.Build by spawning host.StartPhaseB(ctx, sink) right after the
host is constructed.

Reported-by: chatgpt-codex-connector[bot] (review on #2677).

* fix(plugin): isolate phase-b surface listing

---------

Co-authored-by: Sivan <[email protected]>
2026-06-02 01:08:41 -07:00

147 lines
4.6 KiB
Go

package plugin
import (
"context"
"encoding/json"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/event"
"reasonix/internal/tool"
)
// buildExamplePlugin compiles cmd/reasonix-plugin-example into a temp binary and
// returns its path. Building from inside the module lets `go build` resolve the
// import path regardless of the test's working directory.
func buildExamplePlugin(t *testing.T) string {
t.Helper()
bin := filepath.Join(t.TempDir(), "reasonix-plugin-example")
if runtime.GOOS == "windows" {
bin += ".exe"
}
out, err := exec.Command("go", "build", "-o", bin, "reasonix/cmd/reasonix-plugin-example").CombinedOutput()
if err != nil {
t.Fatalf("build example plugin: %v\n%s", err, out)
}
return bin
}
// TestExamplePluginEndToEnd builds the real reference plugin and drives it
// through StartAll over actual stdio pipes — the genuine end-to-end contract,
// not a mock. It also asserts the readOnlyHint annotation flows through to
// ReadOnly(), which is what lets plugin tools join parallel batches and the
// permission reader-default.
func TestExamplePluginEndToEnd(t *testing.T) {
if testing.Short() {
t.Skip("compiles a subprocess; skipped under -short")
}
bin := buildExamplePlugin(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
host, tools, err := StartAll(ctx, []Spec{{Name: "example", Command: bin}})
if err != nil {
t.Fatalf("StartAll: %v", err)
}
defer host.Close()
byName := map[string]tool.Tool{}
for _, tl := range tools {
byName[tl.Name()] = tl
}
if len(tools) != 2 {
t.Fatalf("want 2 tools, got %d (%v)", len(tools), byName)
}
echo, ok := byName["mcp__example__echo"]
if !ok {
t.Fatal("mcp__example__echo not listed")
}
wc, ok := byName["mcp__example__wordcount"]
if !ok {
t.Fatal("mcp__example__wordcount not listed")
}
// readOnlyHint: true must surface as ReadOnly() == true.
if !echo.ReadOnly() || !wc.ReadOnly() {
t.Errorf("read-only annotation not honoured: echo=%v wordcount=%v", echo.ReadOnly(), wc.ReadOnly())
}
// echo round-trips its argument.
if got, err := echo.Execute(ctx, json.RawMessage(`{"text":"hi there"}`)); err != nil || got != "hi there" {
t.Errorf("echo = (%q, %v), want (\"hi there\", nil)", got, err)
}
// wordcount produces a structured count.
got, err := wc.Execute(ctx, json.RawMessage(`{"text":"alpha beta gamma"}`))
if err != nil {
t.Fatalf("wordcount: %v", err)
}
if !strings.Contains(got, "words: 3") {
t.Errorf("wordcount = %q, want it to contain \"words: 3\"", got)
}
// A handler-level failure (wrong arg type) comes back as an isError result,
// which the adapter surfaces as a Go error the model can read.
if _, err := echo.Execute(ctx, json.RawMessage(`{"text":123}`)); err == nil {
t.Error("echo with non-string text should return an error (isError result)")
}
// Prompts and resources stream in on phase B (post-startup), so the test
// must drive it and wait for both surfaces before asserting. A WaitGroup
// completes once both MCPSurfaceReady events fire.
var wg sync.WaitGroup
wg.Add(2) // prompts + resources
host.StartPhaseB(ctx, event.FuncSink(func(e event.Event) {
if e.Kind == event.MCPSurfaceReady {
wg.Done()
}
}))
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("phase B did not finish in time")
}
// Prompts: the server advertises the capability, so the host discovers the
// "review" prompt and can render it with arguments.
prompts := host.Prompts()
if len(prompts) != 1 || prompts[0].Name != "mcp__example__review" {
t.Fatalf("prompts = %+v, want one mcp__example__review", prompts)
}
if len(prompts[0].Args) != 1 || prompts[0].Args[0].Name != "path" {
t.Errorf("prompt args = %+v, want one 'path'", prompts[0].Args)
}
rendered, err := prompts[0].Get(ctx, map[string]string{"path": "main.go"})
if err != nil {
t.Fatalf("prompt Get: %v", err)
}
if !strings.Contains(rendered, "main.go") {
t.Errorf("rendered prompt = %q, want it to mention main.go", rendered)
}
// Resources: discovered via the advertised capability, read by uri.
res := host.Resources()
if len(res) != 1 || res[0].URI != "doc://style-guide" {
t.Fatalf("resources = %+v, want one doc://style-guide", res)
}
content, err := host.ReadResource(ctx, "example", "doc://style-guide")
if err != nil {
t.Fatalf("ReadResource: %v", err)
}
if !strings.Contains(content, "style") {
t.Errorf("resource content = %q, want it to mention style", content)
}
if _, err := host.ReadResource(ctx, "example", "doc://missing"); err == nil {
t.Error("reading an unknown resource uri should error")
}
}