
DispatchTest.pudu
Pudu199 lines17.5 KB
1/** @Test.App.DispatchTest.Suite — the protocol core end to end, scripted toolchain */2module PuduLangMcp.App.DispatchTest34import Std.Env as Env5import Std.Fs as Fs6import Std.Io as Io7import Std.Json as Json8import Std.List as List9import Std.Option as Option10import Std.Result as Result11import Std.Test as Test12import PuduLangMcp.App.Context as Context13import PuduLangMcp.App.Dispatch as Dispatch14import PuduLangMcp.Constants.Protocol as Protocol15import PuduLangMcp.Constants.Server as Server16import PuduLangMcp.Domain.Protocol.Negotiation as Negotiation17import PuduLangMcp.Generated.Docs as Docs18import PuduLangMcp.Services.Process.Bounded as Bounded19import PuduLangMcp.Services.Toolchain as Toolchain20import PuduLangMcp.Services.Workspace as Workspace21import PuduLangMcp.Utils.JsonAccess as Access222324fn answer(arguments: Array[Str], input: Str, directory: Str) -> Bounded.Finished {25 let command = Option.unwrapOr(List.first(&arguments), "")26 if command == "api" {27 return Toolchain.finished(0, Json.encode(&Json.object(&[("exports", Json.list(&[Json.object(&[("module", Json.Text("Std.Io")), ("name", Json.Text("read"))])]))])))28 }29 if command == "doc" {30 return Toolchain.finished(0, Json.encode(&Json.object(&[("entries", Json.list(&[Json.object(&[31 ("name", Json.Text("read")), ("kind", Json.Text("fn")), ("module", Json.Text("Std.Io")),32 ("signature", Json.Text("Str -> Result[Str, Str]")), ("doc", Json.list(&[Json.Text("A file's text.")]))33 ])]))])))34 }35 Toolchain.finished(0, "ok")36}373839fn modern(fields: &Array[(Str, Json.Json)]) -> Json.Json {40 Json.object(&fields.push((Protocol.META_KEY, Json.object(&[41 (Protocol.META_PROTOCOL_VERSION, Json.Text(Protocol.MODERN_VERSION)),42 (Protocol.META_CLIENT_CAPABILITIES, Json.object(&[]))43 ]))))44}454647fn request(id: Int, method: Str, params: Json.Json) -> Str {48 Json.encode(&Json.object(&[("jsonrpc", Json.Text("2.0")), ("id", Json.Number(id)), ("method", Json.Text(method)), ("params", params)]))49}505152fn send(context: &Context.Context, state: &Dispatch.State, line: Str) -> (Dispatch.State, Array[Json.Json]) {53 let answered = Dispatch.respond(context, state, line)54 (answered[0], answered[1].map(|reply: Str| Result.unwrapOr(Json.decode(reply), Json.Null)))55}565758fn only(context: &Context.Context, line: Str) -> Json.Json {59 let replies = send(context, &Dispatch.initial(), line)[1]60 if replies.length() == 1 { replies[0] } else { Json.Null }61}626364fn errorCode(reply: &Json.Json) -> Int {65 Option.unwrapOr(Access.pathInt(reply, &["error", "code"]), 0)66}676869fn main() -> Int {70 let library = Result.unwrapOr(Fs.temporaryDirectoryIn(Env.temporaryDirectory(), "pudu-mcp-lib-"), "/nonexistent")71 let _made = Io.makeDirectory(library + "/Std")72 let _written = Io.write(library + "/Std/Io.pudu", "module Std.Io\n")73 var extra = 074 while extra < 101 {75 let _module = Io.write(library + "/Std/M" + show(extra) + ".pudu", "module Std.M" + show(extra) + "\n")76 extra = extra + 177 }78 let context = Context.create(Toolchain.scripted("0.1.1", Some(library), answer), Workspace.at(library), Docs.CHAPTERS, "test")79 let discover = only(&context, request(1, "server/discover", modern(&[])))80 let unopened = only(&context, request(2, "tools/list", Json.object(&[])))81 let opened = send(&context, &Dispatch.initial(), request(3, "initialize", Json.object(&[("protocolVersion", Json.Text("2025-06-18")), ("capabilities", Json.object(&[]))])))82 let legacy = opened[0]83 let legacyList = send(&context, &legacy, request(4, "tools/list", Json.object(&[])))[1]84 let legacyPing = send(&context, &legacy, request(5, "ping", Json.object(&[])))[1]85 let reopened = send(&context, &legacy, request(6, "initialize", Json.object(&[("protocolVersion", Json.Text("2024-11-05"))])))[0]86 let modernInitialize = only(&context, request(7, "initialize", modern(&[])))87 let modernPing = only(&context, request(8, "ping", modern(&[])))88 let unknownMethod = only(&context, request(9, "tools/destroy", modern(&[])))89 let unsupported = only(&context, Json.encode(&Json.object(&[("jsonrpc", Json.Text("2.0")), ("id", Json.Number(10)), ("method", Json.Text("tools/list")), ("params", Json.object(&[(Protocol.META_KEY, Json.object(&[(Protocol.META_PROTOCOL_VERSION, Json.Text("1900-01-01")), (Protocol.META_CLIENT_CAPABILITIES, Json.object(&[]))]))]))])))90 let notification = send(&context, &Dispatch.initial(), Json.encode(&Json.object(&[("jsonrpc", Json.Text("2.0")), ("method", Json.Text("notifications/cancelled")), ("params", Json.object(&[("requestId", Json.Number(1))]))])))[1]91 let clientResponse = send(&context, &Dispatch.initial(), "\{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\{\}\}")[1]92 let blank = send(&context, &Dispatch.initial(), " ")[1]93 let oversized = only(&context, "\"" + "x".repeat(Server.MAX_LINE_BYTES) + "\"")94 let prefix = "\{\"jsonrpc\":\"2.0\",\"id\":40,\"method\":\"tools/list\",\"pad\":\""95 let exact = only(&context, prefix + "x".repeat(Server.MAX_LINE_BYTES - prefix.length() - 2) + "\"\}")96 let resourceWrongUri = only(&context, request(41, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/resource")), ("uri", Json.Text("pudu://docs/\{slug\}"))])), ("argument", Json.object(&[("name", Json.Text("module")), ("value", Json.Text("Std"))]))])))97 let resourceWrongArgument = only(&context, request(42, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/resource")), ("uri", Json.Text("pudu://reference/\{module\}"))])), ("argument", Json.object(&[("name", Json.Text("topic")), ("value", Json.Text("Std"))]))])))98 let promptWrongArgument = only(&context, request(43, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/prompt")), ("name", Json.Text("pudu_explain"))])), ("argument", Json.object(&[("name", Json.Text("module")), ("value", Json.Text("o"))]))])))99 let promptAsResource = only(&context, request(44, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/resource")), ("name", Json.Text("pudu_explain"))])), ("argument", Json.object(&[("name", Json.Text("topic")), ("value", Json.Text("o"))]))])))100 let manyModules = only(&context, request(45, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/resource")), ("uri", Json.Text("pudu://reference/\{module\}"))])), ("argument", Json.object(&[("name", Json.Text("module")), ("value", Json.Text(""))]))])))101 let rootless = only(&context, request(46, "resources/read", modern(&[("uri", Json.Text("pudu:///errors"))])))102 let noSlash = only(&context, request(48, "resources/read", modern(&[("uri", Json.Text("pudu://noslash"))])))103 let examples = only(&context, request(47, "tools/call", modern(&[("name", Json.Text("pudu_docs_search")), ("arguments", Json.object(&[("query", Json.Text("hello")), ("group", Json.Text("examples"))]))])))104 let unknownTool = only(&context, request(11, "tools/call", modern(&[("name", Json.Text("pudu_nope"))])))105 let nameless = only(&context, request(12, "tools/call", modern(&[("name", Json.Number(1))])))106 let badArguments = only(&context, request(13, "tools/call", modern(&[("name", Json.Text("pudu_docs_search")), ("arguments", Json.object(&[("limit", Json.Number(3))]))])))107 let searched = only(&context, request(14, "tools/call", modern(&[("name", Json.Text("pudu_docs_search")), ("arguments", Json.object(&[("query", Json.Text("ownership"))]))])))108 let firstPage = only(&context, request(15, "resources/list", modern(&[])))109 let cursor = Option.unwrapOr(Access.pathText(&firstPage, &["result", "nextCursor"]), "")110 let secondPage = only(&context, request(16, "resources/list", modern(&[("cursor", Json.Text(cursor))])))111 let badCursor = only(&context, request(17, "resources/list", modern(&[("cursor", Json.Text("offset:9999"))])))112 let index = only(&context, request(18, "resources/read", modern(&[("uri", Json.Text("pudu://docs/index"))])))113 let chapter = only(&context, request(19, "resources/read", modern(&[("uri", Json.Text("pudu://docs/errors"))])))114 let missing = only(&context, request(20, "resources/read", modern(&[("uri", Json.Text("pudu://docs/nope"))])))115 let foreign = only(&context, request(21, "resources/read", modern(&[("uri", Json.Text("file:///etc/passwd"))])))116 let reference = only(&context, request(22, "resources/read", modern(&[("uri", Json.Text("pudu://reference/Std.Io"))])))117 let unknownReference = only(&context, request(23, "resources/read", modern(&[("uri", Json.Text("pudu://reference/Std.Nope"))])))118 let templates = only(&context, request(24, "resources/templates/list", modern(&[])))119 let prompts = only(&context, request(25, "prompts/list", modern(&[])))120 let prompt = only(&context, request(26, "prompts/get", modern(&[("name", Json.Text("pudu_explain")), ("arguments", Json.object(&[("topic", Json.Text("traits"))]))])))121 let promptMissing = only(&context, request(27, "prompts/get", modern(&[("name", Json.Text("pudu_explain"))])))122 let completeModule = only(&context, request(28, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/resource")), ("uri", Json.Text("pudu://reference/\{module\}"))])), ("argument", Json.object(&[("name", Json.Text("module")), ("value", Json.Text("std.i"))]))])))123 let completeTopic = only(&context, request(29, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/prompt")), ("name", Json.Text("pudu_explain"))])), ("argument", Json.object(&[("name", Json.Text("topic")), ("value", Json.Text("own"))]))])))124 let completeNothing = only(&context, request(30, "completion/complete", modern(&[("ref", Json.object(&[("type", Json.Text("ref/prompt")), ("name", Json.Text("pudu_review"))])), ("argument", Json.object(&[("name", Json.Text("source")), ("value", Json.Text(""))]))])))125 let completeBad = only(&context, request(31, "completion/complete", modern(&[("ref", Json.object(&[]))])))126 let referenceSearch = only(&context, request(32, "tools/call", modern(&[("name", Json.Text("pudu_reference_search")), ("arguments", Json.object(&[("query", Json.Text("read"))]))])))127 let _cleaned = Fs.removeTree(library)128 let checks = Test.suite("App.Dispatch", &[129 Test.equals("discovery lists the supported versions", &Json.path(&discover, &["result", "supportedVersions"]), &Some(Json.list(&Protocol.supportedVersions().map(|v: Str| Json.Text(v))))),130 Test.equals("discovery is cacheable", &Access.pathText(&discover, &["result", "cacheScope"]), &Some("public")),131 Test.equals("discovery names the server", &Access.pathText(&discover, &["result", "_meta", Protocol.META_SERVER_INFO, "name"]), &Some(Server.SERVER_NAME)),132 Test.equals("a request before initialize without metadata is refused", &errorCode(&unopened), &Protocol.INVALID_PARAMS),133 Test.equals("initialize answers the negotiated version", &Access.pathText(&opened[1][0], &["result", "protocolVersion"]), &Some("2025-06-18")),134 Test.equals("initialize answers server info", &Access.pathText(&opened[1][0], &["result", "serverInfo", "version"]), &Some(Server.SERVER_VERSION)),135 Test.equals("initialize opens a legacy session", &legacy.session, &Negotiation.Legacy("2025-06-18")),136 Test.that("a legacy session serves requests without metadata", Json.path(&legacyList[0], &["result", "tools"]) != None),137 Test.equals("a legacy ping answers an empty result", &Access.pathText(&legacyPing[0], &["result", "resultType"]), &Some("complete")),138 Test.equals("a second initialize renegotiates", &reopened.session, &Negotiation.Legacy("2024-11-05")),139 Test.equals("a modern initialize is not a method", &errorCode(&modernInitialize), &Protocol.METHOD_NOT_FOUND),140 Test.equals("a modern ping is not a method", &errorCode(&modernPing), &Protocol.METHOD_NOT_FOUND),141 Test.equals("an unknown method is not found", &errorCode(&unknownMethod), &Protocol.METHOD_NOT_FOUND),142 Test.equals("an unsupported version is refused with its code", &errorCode(&unsupported), &Protocol.UNSUPPORTED_PROTOCOL_VERSION),143 Test.equals("a notification is never answered", ¬ification.length(), &0),144 Test.equals("a client response is never answered", &clientResponse.length(), &0),145 Test.equals("a blank line is never answered", &blank.length(), &0),146 Test.equals("an oversized line is refused unparsed", &errorCode(&oversized), &Protocol.INVALID_REQUEST),147 Test.equals("an oversized line is answered with a null id", &Access.member(&oversized, "id"), &Some(Json.Null)),148 Test.equals("a line of exactly the limit is parsed", &errorCode(&exact), &Protocol.INVALID_PARAMS),149 Test.equals("a completion for another template answers none", &Access.pathInt(&resourceWrongUri, &["result", "completion", "total"]), &Some(0)),150 Test.equals("a completion for another template argument answers none", &Access.pathInt(&resourceWrongArgument, &["result", "completion", "total"]), &Some(0)),151 Test.equals("a completion for another prompt argument answers none", &Access.pathInt(&promptWrongArgument, &["result", "completion", "total"]), &Some(0)),152 Test.equals("a prompt name under a resource reference answers none", &Access.pathInt(&promptAsResource, &["result", "completion", "total"]), &Some(0)),153 Test.equals("completion answers at most one hundred values", &Access.pathInt(&manyModules, &["result", "completion", "total"]), &Some(102)),154 Test.equals("completion says more values exist", &Json.path(&manyModules, &["result", "completion", "hasMore"]), &Some(Json.Boolean(true))),155 Test.equals("completion says when every value was answered", &Json.path(&completeTopic, &["result", "completion", "hasMore"]), &Some(Json.Boolean(false))),156 Test.equals("a document address needs a group", &errorCode(&rootless), &Protocol.INVALID_PARAMS),157 Test.equals("a document address needs a slash", &errorCode(&noSlash), &Protocol.INVALID_PARAMS),158 Test.that("a group limits the search to its documents", Json.encode(&examples).contains("1. examples/") && !Json.encode(&examples).contains(". docs/")),159 Test.that("search hits are numbered in order", Json.encode(&searched).contains("\\n2. docs/")),160 Test.equals("an unknown tool is invalid params", &errorCode(&unknownTool), &Protocol.INVALID_PARAMS),161 Test.equals("a non-string tool name is invalid params", &errorCode(&nameless), &Protocol.INVALID_PARAMS),162 Test.equals("invalid arguments are a tool error, not a protocol error", &Json.path(&badArguments, &["result", "isError"]), &Some(Json.Boolean(true))),163 Test.that("a tool error names the missing argument", Json.encode(&badArguments).contains("Missing required argument 'query'")),164 Test.equals("a working tool is not an error", &Json.path(&searched, &["result", "isError"]), &Some(Json.Boolean(false))),165 Test.that("resources are paged", !cursor.isEmpty()),166 Test.equals("the next page has no further cursor", &Access.pathText(&secondPage, &["result", "nextCursor"]), &None),167 Test.equals("a cursor past the end is invalid params", &errorCode(&badCursor), &Protocol.INVALID_PARAMS),168 Test.that("the index resource lists documents", Option.unwrapOr(textOfContents(&index), "").contains("docs/errors")),169 Test.that("a document resource is its Markdown", Option.unwrapOr(textOfContents(&chapter), "").startsWith("# ")),170 Test.equals("an unknown document is invalid params", &errorCode(&missing), &Protocol.INVALID_PARAMS),171 Test.equals("an unknown document names its uri", &Access.pathText(&missing, &["error", "data", "uri"]), &Some("pudu://docs/nope")),172 Test.equals("a foreign scheme is not a resource", &errorCode(&foreign), &Protocol.INVALID_PARAMS),173 Test.that("a module reference comes from the toolchain", Option.unwrapOr(textOfContents(&reference), "").contains("fn Std.Io.read :: Str -> Result[Str, Str]")),174 Test.equals("a module reference is private to this machine", &Access.pathText(&reference, &["result", "cacheScope"]), &Some("private")),175 Test.equals("an unknown module reference is not found", &errorCode(&unknownReference), &Protocol.INVALID_PARAMS),176 Test.that("the reference template is listed", Json.encode(&templates).contains("pudu://reference/\{module\}")),177 Test.that("prompts are listed", Json.encode(&prompts).contains("pudu_write_code")),178 Test.that("a prompt renders its topic", Json.encode(&prompt).contains("traits")),179 Test.equals("a prompt missing its argument is invalid params", &errorCode(&promptMissing), &Protocol.INVALID_PARAMS),180 Test.equals("module names complete by prefix", &Json.path(&completeModule, &["result", "completion", "values"]), &Some(Json.list(&[Json.Text("Std.Io")]))),181 Test.equals("topics complete from the guide", &Json.path(&completeTopic, &["result", "completion", "values"]), &Some(Json.list(&[Json.Text("ownership")]))),182 Test.equals("an argument without completions answers none", &Access.pathInt(&completeNothing, &["result", "completion", "total"]), &Some(0)),183 Test.equals("a malformed completion is invalid params", &errorCode(&completeBad), &Protocol.INVALID_PARAMS),184 Test.that("the reference index answers searches", Json.encode(&referenceSearch).contains("Std.Io.read"))185 ])186 let ran = Test.run(&checks)187 for failure in Test.failuresOf(&ran) {188 let _reported = Io.writeErrorLine(failure)189 }190 Test.report(&ran)191}192193194fn textOfContents(reply: &Json.Json) -> Option[Str] {195 let contents = Json.path(reply, &["result", "contents"]) ?196 let first = Json.at(&contents, 0) ?197 Access.text(&first, "text")198}199