Every tool's schema in one $SRV.INFO reply outgrew max_payload on machines serving 137-206 tools, and the refused reply was dropped silently, so search and a machine's view went empty. Module tools now announce without schema; describe and tools/list ask the module for it. An answer still too large has its descriptions cut to a line, and a failed reply is logged.
244 lines
10 KiB
Go
244 lines
10 KiB
Go
package console
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-tools/node-tools/internal/announce"
|
|
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
|
"github.com/novox/mesh-tools/node-tools/internal/runtime"
|
|
)
|
|
|
|
// text is a tool result's first text, and whether it was an error.
|
|
func text(t *testing.T, reply map[string]any) (string, bool) {
|
|
t.Helper()
|
|
result, ok := reply["result"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("no result: %v", reply)
|
|
}
|
|
content := result["content"].([]any)
|
|
isErr, _ := result["isError"].(bool)
|
|
var parts []string
|
|
for _, c := range content {
|
|
parts = append(parts, c.(map[string]any)["text"].(string))
|
|
}
|
|
return strings.Join(parts, "\n"), isErr
|
|
}
|
|
|
|
func call(t *testing.T, endpoint, tool string, args map[string]any) (string, bool) {
|
|
t.Helper()
|
|
body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 9, "method": "tools/call",
|
|
"params": map[string]any{"name": tool, "arguments": args}})
|
|
return text(t, post(t, endpoint, string(body)))
|
|
}
|
|
|
|
// novox/hq ADR 0195: the console announces five tools, and everything the mesh answers is reached
|
|
// through them by one address per layer.
|
|
func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
|
|
was := IndexKept
|
|
IndexKept = 0 // every discovery asks the mesh, so a module arriving mid-test is found
|
|
t.Cleanup(func() { IndexKept = was })
|
|
|
|
mesh := mt.New(t)
|
|
// alpha: interchangeable (the mesh issued it a plain subject); beta: state on its machine, holds
|
|
// the node-shelf seat there.
|
|
mesh.Issue(t, mt.MembershipOf("alpha", "desk", true, nil))
|
|
mesh.Issue(t, mt.MembershipOf("beta", "desk", false, map[string][]string{"node-shelf": {"list", "clear"}}))
|
|
nodeTools := connect(t, "node-tools", "desk")
|
|
stop, err := runtime.Run(nodeTools, []runtime.Served{
|
|
{Module: "alpha", Entrypoints: []string{mt.Fixture("many-alpha.serve.mjs")}},
|
|
{Module: "beta", Entrypoints: []string{mt.Fixture("many-beta.serve.mjs")}},
|
|
}, nil, (&mt.Logs{}).Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer stop()
|
|
|
|
// Nothing may be asked for a roster or a module's `tools` any more (ADR 0197): counted.
|
|
var asked atomic.Int32
|
|
watcher := connect(t, "watcher", "")
|
|
for _, subject := range []string{"mesh.mod.*.tool.tools", "mesh.mod.*.tool.tools.*", "mesh.mod.mesh-catalog.>"} {
|
|
stop, err := watcher.Raw(subject, func(string, []byte) []byte { asked.Add(1); return nil })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(stop)
|
|
}
|
|
|
|
// The controller: its records as JSON, as its seat verbs answer them, and its own seat's verbs
|
|
// announced on the bus like every runtime's.
|
|
controller := connect(t, "mesh-controller", "bench")
|
|
out := func(s string) map[string]any { return map[string]any{"output": s, "ok": true} }
|
|
serve := func(verb string, answer func() any) {
|
|
stop, err := controller.HandleSubject("mesh.seat.mesh-controller.tool."+verb, func(json.RawMessage) (any, error) {
|
|
return answer(), nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(stop)
|
|
}
|
|
serve("nodes", func() any {
|
|
return out("the bus's user list leaves out 2 user(s)\n" +
|
|
`[{"name":"bench","heard":"3m ago","mode":"converged","id":"1f2e"},{"name":"desk","heard":"here","mode":"converged","id":"9a8b"}]`)
|
|
})
|
|
var gammaOn atomic.Value
|
|
gammaOn.Store("[]")
|
|
serve("modules", func() any {
|
|
return out(fmt.Sprintf(`[{"module":"alpha","on":["desk"],"tools":true},{"module":"beta","on":["desk"],"tools":true},`+
|
|
`{"module":"gamma","on":%s,"tools":true},{"module":"delta","on":["desk"],"tools":false},`+
|
|
`{"module":"epsilon","on":["bench"],"tools":true},{"module":"mesh-controller","on":["bench"],"tools":true}]`, gammaOn.Load().(string)))
|
|
})
|
|
stopAnn, err := announce.Serve(controller, announce.Service{Name: "mesh-controller", ID: "bench"}, func() []announce.Endpoint {
|
|
return []announce.Endpoint{{Kind: announce.KindSeat, Module: "mesh-controller", Seat: "mesh-controller", Scope: "mesh",
|
|
// As the live controller announces: a mesh seat's holder names no machine.
|
|
Tool: "nodes", Node: "", Description: "Every machine the mesh knows.", Schema: json.RawMessage(`{}`),
|
|
Subject: "mesh.seat.mesh-controller.tool.nodes"}}
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(stopAnn)
|
|
controller.Flush()
|
|
watcher.Flush()
|
|
|
|
up, err := Serve(NewSurface(nodeTools, "desk.node-tools"), "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer up.Close()
|
|
endpoint := "http://" + up.Address + "/mcp"
|
|
|
|
// Five tools, nothing else.
|
|
listed := post(t, endpoint, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`)["result"].(map[string]any)
|
|
var names []string
|
|
for _, x := range listed["tools"].([]any) {
|
|
name := x.(map[string]any)["name"].(string)
|
|
names = append(names, name)
|
|
for _, r := range name {
|
|
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') || len(name) > 64 {
|
|
t.Errorf("%q is not a name the API takes", name)
|
|
}
|
|
}
|
|
}
|
|
if got := strings.Join(names, ","); got != "mesh_overview,mesh_machine,mesh_search,mesh_describe,mesh_call" {
|
|
t.Errorf("announced %s", got)
|
|
}
|
|
|
|
// The overview names the mesh's seats, the machines' seats and the machines.
|
|
overview, isErr := call(t, endpoint, "mesh_overview", nil)
|
|
if isErr || !strings.Contains(overview, "mesh-controller.nodes") || !strings.Contains(overview, "<node>/node-shelf.list") ||
|
|
!strings.Contains(overview, `"bench"`) || !strings.Contains(overview, `"desk"`) {
|
|
t.Errorf("overview: %s", overview)
|
|
}
|
|
// Silence is named only where tools should have answered: epsilon declares tools on bench and
|
|
// nothing there announced it; delta declares none and is never a name.
|
|
if strings.Contains(overview, "mesh-controller on bench") {
|
|
t.Errorf("a mesh seat's holder that announced no machine is called silent on its machine:\n%s", overview)
|
|
}
|
|
if !strings.Contains(overview, "epsilon on bench") || strings.Contains(overview, "delta") {
|
|
t.Errorf("not answering: %s", overview)
|
|
}
|
|
machine, isErr := call(t, endpoint, "mesh_machine", map[string]any{"node": "desk"})
|
|
if isErr || !strings.Contains(machine, "desk/node-shelf.list") || !strings.Contains(machine, "desk/beta.three") ||
|
|
!strings.Contains(machine, "desk/alpha.one") {
|
|
t.Errorf("machine: %s", machine)
|
|
}
|
|
|
|
// A mesh seat, a node seat, an assignment and an interchangeable module, each by address.
|
|
if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "mesh-controller.nodes"}); isErr || !strings.Contains(got, "converged") {
|
|
t.Errorf("mesh seat: %s", got)
|
|
}
|
|
if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "desk/node-shelf.list"}); isErr || !strings.Contains(got, `"a"`) {
|
|
t.Errorf("node seat: %s", got)
|
|
}
|
|
if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "desk/beta.three"}); isErr ||
|
|
!strings.Contains(got, `"beta": 3`) || !strings.Contains(got, "answered by desk") {
|
|
t.Errorf("assignment: %s", got)
|
|
}
|
|
if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "alpha.one"}); isErr || !strings.Contains(got, `"alpha": 1`) {
|
|
t.Errorf("interchangeable module: %s", got)
|
|
}
|
|
|
|
// Refused, by name, where the address does not say enough or says the wrong thing.
|
|
for address, want := range map[string]string{
|
|
"beta.three": "keeps state on each machine it runs on, so a call names the machine: write <node>/beta.three — it runs on desk",
|
|
"node-shelf.list": "held once per machine: write <node>/node-shelf.list — it is held on desk",
|
|
"desk/mesh-controller.nodes": "held once for the whole mesh",
|
|
"bench/beta.three": "beta does not run on bench; it runs on desk",
|
|
"nonsense": "is not an address",
|
|
} {
|
|
got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": address})
|
|
if !isErr || !strings.Contains(got, want) {
|
|
t.Errorf("%s: %s (want %q)", address, got, want)
|
|
}
|
|
}
|
|
|
|
// Discovery itself asked nothing but the bus.
|
|
if n := asked.Load(); n != 0 {
|
|
t.Errorf("discovery asked a roster or a module's tools %d time(s); it asks the bus", n)
|
|
}
|
|
|
|
// Described without `node`: the address carries the machine.
|
|
described, isErr := call(t, endpoint, "mesh_describe", map[string]any{"address": "desk/beta.three"})
|
|
if isErr || strings.Contains(described, `"node"`) || !strings.Contains(described, `"address": "desk/beta.three"`) {
|
|
t.Errorf("describe: %s", described)
|
|
}
|
|
// Its schema, which discovery no longer carries for a module's tool, asked of the runtime serving it.
|
|
if !strings.Contains(described, `"verbose"`) {
|
|
t.Errorf("describe lost the tool's arguments: %s", described)
|
|
}
|
|
|
|
// Search finds across the layers; a module that starts serving after the first answer is found.
|
|
if got, _ := call(t, endpoint, "mesh_search", map[string]any{"query": "shelf"}); !strings.Contains(got, "desk/node-shelf.list") {
|
|
t.Errorf("search a node seat: %s", got)
|
|
}
|
|
if got, _ := call(t, endpoint, "mesh_search", map[string]any{"query": "gamma"}); strings.Contains(got, "gamma.given") {
|
|
t.Fatalf("gamma was found before it served: %s", got)
|
|
}
|
|
mesh.Issue(t, mt.MembershipOf("gamma", "desk", false, nil))
|
|
gammaOn.Store(`["desk"]`)
|
|
late := connect(t, "node-tools", "desk")
|
|
stopLate, err := runtime.Run(late, []runtime.Served{{Module: "gamma", Entrypoints: []string{mt.Fixture("env-gamma.serve.mjs")}}},
|
|
nil, (&mt.Logs{}).Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer stopLate()
|
|
var found string
|
|
for i := 0; i < 30; i++ {
|
|
found, _ = call(t, endpoint, "mesh_search", map[string]any{"query": "gamma given"})
|
|
if strings.Contains(found, "desk/gamma.given") {
|
|
break
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
if !strings.Contains(found, "desk/gamma.given") {
|
|
t.Errorf("a module that arrived later was not found: %s", found)
|
|
}
|
|
|
|
// Only describing a module's tool asks its runtime — once, for the schema discovery no longer carries
|
|
// (2026-10-04: every tool's schema made a machine's discovery answer outgrow the bus).
|
|
if n := asked.Load(); n != 1 {
|
|
t.Errorf("asked a module's tools %d time(s); describing one tool asks once, and discovery never", n)
|
|
}
|
|
|
|
// The old names still answer, unannounced.
|
|
if got, isErr := call(t, endpoint, "alpha.one", nil); isErr || !strings.Contains(got, `"alpha": 1`) {
|
|
t.Errorf("an old name: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestTheControllersRecordsAreReadAsJSON(t *testing.T) {
|
|
if got := jsonIn("a warning printed first\n[{\"name\":\"ace\"}]"); got != `[{"name":"ace"}]` {
|
|
t.Errorf("an array after a warning: %q", got)
|
|
}
|
|
if got := jsonIn(`{"seats":[]}`); got != `{"seats":[]}` {
|
|
t.Errorf("an object: %q", got)
|
|
}
|
|
}
|