412 lines
11 KiB
Go
412 lines
11 KiB
Go
package mcp_test
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"aego/app/agent"
|
|
"aego/app/agent/mcp"
|
|
"aego/app/docstool"
|
|
"aego/app/run"
|
|
"aego/asset/pipeline"
|
|
"aego/core/log"
|
|
"aego/engine/docs"
|
|
"aego/project"
|
|
"aego/sdk"
|
|
"aego/tests/fixture"
|
|
)
|
|
|
|
type message struct {
|
|
ID json.RawMessage `json:"id"`
|
|
Method string `json:"method"`
|
|
Result json.RawMessage `json:"result"`
|
|
Error *struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
|
|
type client struct {
|
|
t *testing.T
|
|
in *io.PipeWriter
|
|
lines chan string
|
|
done chan error
|
|
mu sync.Mutex
|
|
notes []message
|
|
seen []string
|
|
next int
|
|
dir string
|
|
p *project.Project
|
|
}
|
|
|
|
func start(t *testing.T, watch bool) *client {
|
|
t.Helper()
|
|
dir := fixture.Copy(t, "platformer")
|
|
p, err := project.Load(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mods := []sdk.Module{fixture.Platformer{}}
|
|
kit, err := agent.NewKit(p, agent.Options{Mods: mods, Run: run.AgentRunner(mods...)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inR, inW := io.Pipe()
|
|
outR, outW := io.Pipe()
|
|
c := &client{t: t, in: inW, lines: make(chan string, 256), done: make(chan error, 1), dir: dir, p: p}
|
|
go func() {
|
|
err := mcp.Serve(inR, outW, mcp.Options{
|
|
Kit: kit,
|
|
Docs: func() (*docs.Registry, error) { return docstool.Registry(p, sdk.AdaptAll(mods)) },
|
|
Watch: watch,
|
|
Interval: 50 * time.Millisecond,
|
|
Log: io.Discard,
|
|
Version: "test",
|
|
})
|
|
outW.Close()
|
|
c.done <- err
|
|
}()
|
|
go func() {
|
|
sc := bufio.NewScanner(outR)
|
|
sc.Buffer(make([]byte, 1<<20), 64<<20)
|
|
for sc.Scan() {
|
|
c.lines <- sc.Text()
|
|
}
|
|
close(c.lines)
|
|
}()
|
|
t.Cleanup(func() { inW.Close() })
|
|
return c
|
|
}
|
|
|
|
func (c *client) send(v any) {
|
|
c.t.Helper()
|
|
b, _ := json.Marshal(v)
|
|
if _, err := c.in.Write(append(b, '\n')); err != nil {
|
|
c.t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func (c *client) rawLine(text string) {
|
|
c.t.Helper()
|
|
if _, err := c.in.Write([]byte(text + "\n")); err != nil {
|
|
c.t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func (c *client) read(timeout time.Duration) (message, bool) {
|
|
c.t.Helper()
|
|
select {
|
|
case line, ok := <-c.lines:
|
|
if !ok {
|
|
return message{}, false
|
|
}
|
|
c.seen = append(c.seen, line)
|
|
var m message
|
|
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
|
c.t.Fatalf("not JSON on stdout: %q", line)
|
|
}
|
|
return m, true
|
|
case <-time.After(timeout):
|
|
return message{}, false
|
|
}
|
|
}
|
|
|
|
func (c *client) call(method string, params any) message {
|
|
c.t.Helper()
|
|
c.next++
|
|
id := c.next
|
|
c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
|
|
for {
|
|
m, ok := c.read(120 * time.Second)
|
|
if !ok {
|
|
c.t.Fatalf("no reply to %s", method)
|
|
}
|
|
if len(m.ID) == 0 || string(m.ID) == "null" && m.Method != "" {
|
|
c.mu.Lock()
|
|
c.notes = append(c.notes, m)
|
|
c.mu.Unlock()
|
|
continue
|
|
}
|
|
var got int
|
|
if json.Unmarshal(m.ID, &got) == nil && got == id {
|
|
return m
|
|
}
|
|
if string(m.ID) == "null" {
|
|
return m
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *client) notify(method string) {
|
|
c.send(map[string]any{"jsonrpc": "2.0", "method": method})
|
|
}
|
|
|
|
func (c *client) init() {
|
|
c.t.Helper()
|
|
c.call("initialize", map[string]any{"protocolVersion": "2025-06-18", "capabilities": map[string]any{}, "clientInfo": map[string]any{"name": "test"}})
|
|
c.notify("notifications/initialized")
|
|
}
|
|
|
|
type toolResult struct {
|
|
Content []struct {
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
IsError bool `json:"isError"`
|
|
Structured json.RawMessage `json:"structuredContent"`
|
|
}
|
|
|
|
func (c *client) tool(name string, args map[string]any) toolResult {
|
|
c.t.Helper()
|
|
m := c.call("tools/call", map[string]any{"name": name, "arguments": args})
|
|
if m.Error != nil {
|
|
c.t.Fatalf("%s: rpc error %d %s", name, m.Error.Code, m.Error.Message)
|
|
}
|
|
var r toolResult
|
|
if err := json.Unmarshal(m.Result, &r); err != nil {
|
|
c.t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func (c *client) ok(name string, args map[string]any) agent.Output {
|
|
c.t.Helper()
|
|
r := c.tool(name, args)
|
|
if r.IsError {
|
|
c.t.Fatalf("%s %v: %s", name, args, r.Content[0].Text)
|
|
}
|
|
var out agent.Output
|
|
if err := json.Unmarshal(r.Structured, &out); err != nil {
|
|
c.t.Fatalf("%s: %v", name, err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestProtocol(t *testing.T) {
|
|
c := start(t, false)
|
|
m := c.call("initialize", map[string]any{"protocolVersion": "2025-03-26"})
|
|
var init struct {
|
|
ProtocolVersion string `json:"protocolVersion"`
|
|
Capabilities struct {
|
|
Resources struct {
|
|
Subscribe bool `json:"subscribe"`
|
|
} `json:"resources"`
|
|
} `json:"capabilities"`
|
|
}
|
|
json.Unmarshal(m.Result, &init)
|
|
if init.ProtocolVersion != "2025-03-26" || !init.Capabilities.Resources.Subscribe {
|
|
t.Fatalf("initialize %s", m.Result)
|
|
}
|
|
m = c.call("initialize", map[string]any{"protocolVersion": "1999-01-01"})
|
|
json.Unmarshal(m.Result, &init)
|
|
if init.ProtocolVersion != "2025-06-18" {
|
|
t.Fatalf("unknown version answered %s", init.ProtocolVersion)
|
|
}
|
|
if m := c.call("ping", nil); string(m.Result) != "{}" {
|
|
t.Fatalf("ping %s", m.Result)
|
|
}
|
|
if m := c.call("no/such", nil); m.Error == nil || m.Error.Code != -32601 {
|
|
t.Fatalf("unknown method %+v", m)
|
|
}
|
|
c.rawLine(`[{"jsonrpc":"2.0","id":99,"method":"ping"}]`)
|
|
if m, _ := c.read(5 * time.Second); m.Error == nil || m.Error.Code != -32600 {
|
|
t.Fatalf("batch %+v", m)
|
|
}
|
|
m = c.call("tools/list", nil)
|
|
var list struct {
|
|
Tools []struct {
|
|
Name string `json:"name"`
|
|
InputSchema map[string]any `json:"inputSchema"`
|
|
} `json:"tools"`
|
|
}
|
|
json.Unmarshal(m.Result, &list)
|
|
names := map[string]bool{}
|
|
for _, tl := range list.Tools {
|
|
if names[tl.Name] || tl.InputSchema["type"] != "object" {
|
|
t.Fatalf("tool %s", tl.Name)
|
|
}
|
|
names[tl.Name] = true
|
|
}
|
|
for _, cmd := range agent.Commands() {
|
|
if !names[cmd.Tool()] {
|
|
t.Fatalf("no tool %s", cmd.Tool())
|
|
}
|
|
}
|
|
if !names["docs_search"] || !names["docs_read"] || len(names) != len(agent.Commands())+2 {
|
|
t.Fatalf("%d tools", len(names))
|
|
}
|
|
c.rawLine(`{not json`)
|
|
if m, _ := c.read(5 * time.Second); m.Error == nil || m.Error.Code != -32700 {
|
|
t.Fatalf("parse error %+v", m)
|
|
}
|
|
if err := <-c.done; err == nil {
|
|
t.Fatal("Serve did not fail on broken JSON")
|
|
}
|
|
}
|
|
|
|
func typeNamed(t *testing.T, c *client, suffix string) string {
|
|
t.Helper()
|
|
out := c.ok("schema_list", map[string]any{"kind": "component"})
|
|
var list []agent.TypeView
|
|
b, _ := json.Marshal(out.Value)
|
|
json.Unmarshal(b, &list)
|
|
for _, tv := range list {
|
|
if strings.HasSuffix(tv.Name, suffix) {
|
|
return tv.Name
|
|
}
|
|
}
|
|
t.Fatalf("no component %s", suffix)
|
|
return ""
|
|
}
|
|
|
|
func runReport(t *testing.T, c *client, file string) agent.RunReport {
|
|
t.Helper()
|
|
out := c.ok("scene_run", map[string]any{"file": file, "frames": 120})
|
|
var rep agent.RunReport
|
|
b, _ := json.Marshal(out.Value)
|
|
json.Unmarshal(b, &rep)
|
|
return rep
|
|
}
|
|
|
|
func TestLevelFromScratch(t *testing.T) {
|
|
c := start(t, false)
|
|
c.init()
|
|
const level = "scenes/agent.aescene"
|
|
camera, box, body := typeNamed(t, c, ".Camera2D"), typeNamed(t, c, ".BoxCollider2D"), typeNamed(t, c, ".Body2D")
|
|
c.ok("scene_new", map[string]any{"file": level})
|
|
c.ok("scene_add_node", map[string]any{"file": level, "name": "Camera"})
|
|
c.ok("scene_add_comp", map[string]any{"file": level, "node": "Camera", "comp": camera})
|
|
c.ok("scene_add_node", map[string]any{"file": level, "name": "Ground"})
|
|
c.ok("scene_add_comp", map[string]any{"file": level, "node": "Ground", "comp": body})
|
|
c.ok("scene_set", map[string]any{"file": level, "node": "Ground", "path": body[strings.LastIndexByte(body, '.')+1:] + ".type", "value": "static"})
|
|
c.ok("scene_add_comp", map[string]any{"file": level, "node": "Ground", "comp": box})
|
|
c.ok("scene_set", map[string]any{"file": level, "node": "Ground", "path": box + ".size", "value": "[10, 1]"})
|
|
c.ok("prefab_instantiate", map[string]any{"file": level, "prefab": "prefabs/enemy.aeprefab"})
|
|
v := c.ok("scene_validate", map[string]any{"file": level})
|
|
for _, p := range v.Problems {
|
|
if p.Error {
|
|
t.Fatalf("validate: %+v", v.Problems)
|
|
}
|
|
}
|
|
a, b := runReport(t, c, level), runReport(t, c, level)
|
|
if a.Error != "" || a.Frames != 120 || a.Hash == "" || a.Hash != b.Hash {
|
|
t.Fatalf("runs %+v / %+v", a, b)
|
|
}
|
|
if r := c.tool("scene_set", map[string]any{"file": level, "node": "Ground", "path": box + ".size", "value": "abc"}); !r.IsError || r.Content[0].Text == "" {
|
|
t.Fatalf("a bad value did not fail: %+v", r)
|
|
}
|
|
if m := c.call("tools/call", map[string]any{"name": "no_such_tool"}); m.Error == nil || m.Error.Code != -32602 {
|
|
t.Fatalf("unknown tool %+v", m)
|
|
}
|
|
m := c.call("resources/list", nil)
|
|
if !strings.Contains(string(m.Result), "aego://file/"+level) {
|
|
t.Fatalf("resources %s", m.Result)
|
|
}
|
|
read := func(uri string) (string, *message) {
|
|
m := c.call("resources/read", map[string]any{"uri": uri})
|
|
if m.Error != nil {
|
|
return "", &m
|
|
}
|
|
var r struct {
|
|
Contents []struct {
|
|
Text string `json:"text"`
|
|
} `json:"contents"`
|
|
}
|
|
json.Unmarshal(m.Result, &r)
|
|
return r.Contents[0].Text, nil
|
|
}
|
|
if text, _ := read("aego://file/" + level); !strings.Contains(text, `name="Ground"`) {
|
|
t.Fatalf("scene text %q", text)
|
|
}
|
|
for _, uri := range []string{"aego://schema/" + box, "aego://project/problems"} {
|
|
text, e := read(uri)
|
|
if e != nil || !json.Valid([]byte(text)) {
|
|
t.Fatalf("%s: %v", uri, e)
|
|
}
|
|
}
|
|
if text, _ := read("aego://docs/index"); len(text) < 100 {
|
|
t.Fatalf("docs index %q", text)
|
|
}
|
|
if _, e := read("aego://nothing/here"); e == nil || e.Error.Code != -32002 {
|
|
t.Fatal("unknown resource did not fail with -32002")
|
|
}
|
|
hits := c.ok("docs_search", map[string]any{"query": "Body2D"})
|
|
var found struct {
|
|
Results []struct {
|
|
ID string `json:"id"`
|
|
} `json:"results"`
|
|
}
|
|
b2, _ := json.Marshal(hits)
|
|
json.Unmarshal(b2, &found)
|
|
r := c.tool("docs_search", map[string]any{"query": "Body2D"})
|
|
json.Unmarshal([]byte(r.Content[0].Text), &found)
|
|
if len(found.Results) == 0 {
|
|
t.Fatal("docs_search found nothing for Body2D")
|
|
}
|
|
page := c.tool("docs_read", map[string]any{"id": found.Results[0].ID})
|
|
if page.IsError || !strings.Contains(page.Content[0].Text, "#") {
|
|
t.Fatalf("docs_read %+v", page)
|
|
}
|
|
for _, line := range c.seen {
|
|
if !json.Valid([]byte(line)) {
|
|
t.Fatalf("stdout line is not JSON: %q", line)
|
|
}
|
|
}
|
|
pl, err := pipeline.Open(c.p, pipeline.NewImporterRegistry(), log.Nop())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer pl.Close()
|
|
if pl.Shared() {
|
|
t.Fatal("the server kept the asset index lock between calls")
|
|
}
|
|
}
|
|
|
|
func TestWatchNotifiesSubscribers(t *testing.T) {
|
|
c := start(t, true)
|
|
c.init()
|
|
const scene, other = "scenes/main.aescene", "prefabs/enemy.aeprefab"
|
|
c.call("resources/subscribe", map[string]any{"uri": "aego://file/" + scene})
|
|
time.Sleep(150 * time.Millisecond)
|
|
touch := func(rel string) {
|
|
abs := filepath.Join(c.dir, rel)
|
|
b, _ := os.ReadFile(abs)
|
|
os.WriteFile(abs, append(b, '\n'), 0o644)
|
|
}
|
|
touch(other)
|
|
touch(scene)
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
var got []string
|
|
for time.Now().Before(deadline) {
|
|
m, ok := c.read(100 * time.Millisecond)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if m.Method == "notifications/resources/updated" {
|
|
var p struct {
|
|
URI string `json:"uri"`
|
|
}
|
|
json.Unmarshal(m.Params, &p)
|
|
got = append(got, p.URI)
|
|
if p.URI == "aego://file/"+scene {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(got) == 0 || got[len(got)-1] != "aego://file/"+scene {
|
|
t.Fatalf("updates %v", got)
|
|
}
|
|
for _, u := range got {
|
|
if u == "aego://file/"+other {
|
|
t.Fatalf("an unsubscribed resource was notified: %v", got)
|
|
}
|
|
}
|
|
}
|