返回 DeepSeek-Reasonix
example_test.go
根目录 / internal / plugin / example_test.go
1 package plugin
2
3 import (
4 "context"
5 "encoding/json"
6 "os/exec"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "reasonix/internal/event"
15 "reasonix/internal/tool"
16 )
17
18 // buildExamplePlugin compiles cmd/reasonix-plugin-example into a temp binary and
19 // returns its path. Building from inside the module lets `go build` resolve the
20 // import path regardless of the test's working directory.
21 func buildExamplePlugin(t *testing.T) string {
22 t.Helper()
23 bin := filepath.Join(t.TempDir(), "reasonix-plugin-example")
24 if runtime.GOOS == "windows" {
25 bin += ".exe"
26 }
27 out, err := exec.Command("go", "build", "-o", bin, "reasonix/cmd/reasonix-plugin-example").CombinedOutput()
28 if err != nil {
29 t.Fatalf("build example plugin: %v\n%s", err, out)
30 }
31 return bin
32 }
33
34 // TestExamplePluginEndToEnd builds the real reference plugin and drives it
35 // through StartAll over actual stdio pipes — the genuine end-to-end contract,
36 // not a mock. It also asserts the readOnlyHint annotation flows through to
37 // ReadOnly(), which is what lets plugin tools join parallel batches and the
38 // permission reader-default.
39 func TestExamplePluginEndToEnd(t *testing.T) {
40 if testing.Short() {
41 t.Skip("compiles a subprocess; skipped under -short")
42 }
43 bin := buildExamplePlugin(t)
44
45 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
46 defer cancel()
47
48 host, tools, err := StartAll(ctx, []Spec{{Name: "example", Command: bin}})
49 if err != nil {
50 t.Fatalf("StartAll: %v", err)
51 }
52 defer host.Close()
53
54 byName := map[string]tool.Tool{}
55 for _, tl := range tools {
56 byName[tl.Name()] = tl
57 }
58 if len(tools) != 2 {
59 t.Fatalf("want 2 tools, got %d (%v)", len(tools), byName)
60 }
61
62 echo, ok := byName["mcp__example__echo"]
63 if !ok {
64 t.Fatal("mcp__example__echo not listed")
65 }
66 wc, ok := byName["mcp__example__wordcount"]
67 if !ok {
68 t.Fatal("mcp__example__wordcount not listed")
69 }
70
71 // readOnlyHint: true must surface as ReadOnly() == true.
72 if !echo.ReadOnly() || !wc.ReadOnly() {
73 t.Errorf("read-only annotation not honoured: echo=%v wordcount=%v", echo.ReadOnly(), wc.ReadOnly())
74 }
75
76 // echo round-trips its argument.
77 if got, err := echo.Execute(ctx, json.RawMessage(`{"text":"hi there"}`)); err != nil || got != "hi there" {
78 t.Errorf("echo = (%q, %v), want (\"hi there\", nil)", got, err)
79 }
80
81 // wordcount produces a structured count.
82 got, err := wc.Execute(ctx, json.RawMessage(`{"text":"alpha beta gamma"}`))
83 if err != nil {
84 t.Fatalf("wordcount: %v", err)
85 }
86 if !strings.Contains(got, "words: 3") {
87 t.Errorf("wordcount = %q, want it to contain \"words: 3\"", got)
88 }
89
90 // A handler-level failure (wrong arg type) comes back as an isError result,
91 // which the adapter surfaces as a Go error the model can read.
92 if _, err := echo.Execute(ctx, json.RawMessage(`{"text":123}`)); err == nil {
93 t.Error("echo with non-string text should return an error (isError result)")
94 }
95
96 // Prompts and resources stream in on phase B (post-startup), so the test
97 // must drive it and wait for both surfaces before asserting. A WaitGroup
98 // completes once both MCPSurfaceReady events fire.
99 var wg sync.WaitGroup
100 wg.Add(2) // prompts + resources
101 host.StartPhaseB(ctx, event.FuncSink(func(e event.Event) {
102 if e.Kind == event.MCPSurfaceReady {
103 wg.Done()
104 }
105 }))
106 done := make(chan struct{})
107 go func() { wg.Wait(); close(done) }()
108 select {
109 case <-done:
110 case <-time.After(5 * time.Second):
111 t.Fatal("phase B did not finish in time")
112 }
113
114 // Prompts: the server advertises the capability, so the host discovers the
115 // "review" prompt and can render it with arguments.
116 prompts := host.Prompts()
117 if len(prompts) != 1 || prompts[0].Name != "mcp__example__review" {
118 t.Fatalf("prompts = %+v, want one mcp__example__review", prompts)
119 }
120 if len(prompts[0].Args) != 1 || prompts[0].Args[0].Name != "path" {
121 t.Errorf("prompt args = %+v, want one 'path'", prompts[0].Args)
122 }
123 rendered, err := prompts[0].Get(ctx, map[string]string{"path": "main.go"})
124 if err != nil {
125 t.Fatalf("prompt Get: %v", err)
126 }
127 if !strings.Contains(rendered, "main.go") {
128 t.Errorf("rendered prompt = %q, want it to mention main.go", rendered)
129 }
130
131 // Resources: discovered via the advertised capability, read by uri.
132 res := host.Resources()
133 if len(res) != 1 || res[0].URI != "doc://style-guide" {
134 t.Fatalf("resources = %+v, want one doc://style-guide", res)
135 }
136 content, err := host.ReadResource(ctx, "example", "doc://style-guide")
137 if err != nil {
138 t.Fatalf("ReadResource: %v", err)
139 }
140 if !strings.Contains(content, "style") {
141 t.Errorf("resource content = %q, want it to mention style", content)
142 }
143 if _, err := host.ReadResource(ctx, "example", "doc://missing"); err == nil {
144 t.Error("reading an unknown resource uri should error")
145 }
146 }
147
147 lines GO