-- test.lua - The whole test suite. Runs offline, no model, no key. -- -- lua54 test.lua -- -- Everything here must stay runnable by someone who just cloned the repo and -- has no checkpoint. Tests that need a model live in eval/, not here. package.path = "./?.lua;" .. package.path local pass, fail = 0, 0 local current = "?" local function group(name) current = name end local function check(desc, ok, detail) if ok then pass = pass + 1 else fail = fail + 1 io.write(string.format("FAIL %s: %s\n", current, desc)) if detail then io.write(" " .. tostring(detail) .. "\n") end end end local function eq(desc, got, want) check(desc, got == want, string.format("got %s, want %s", tostring(got), tostring(want))) end -------------------------------------------------------------------------- group("trace/json") -------------------------------------------------------------------------- local trace = require("trace") do local cases = { { tool = "calc", args = { expr = "4871 * 209" } }, { a = {}, b = { 1, 2, 3 }, d = true, e = 1.5, f = 42 }, { s = 'quote " backslash \\ newline \n tab \t' }, { nested = { deep = { deeper = { "x" } } } }, { empty_args = {} }, } for i, v in ipairs(cases) do local enc = trace.encode(v) local dec, err = trace.decode(enc) check("round-trip " .. i .. " decodes", dec ~= nil, err) if dec then eq("round-trip " .. i .. " is stable", trace.encode(dec), enc) end end -- Key order must be deterministic or traces stop diffing cleanly eq("keys sorted", trace.encode({ z = 1, a = 2, m = 3 }), '{"a":2,"m":3,"z":1}') eq("empty table is an object", trace.encode({}), "{}") eq("integers have no decimal point", trace.encode({ n = 42 }), '{"n":42}') -- Decode must reject rather than raise for _, bad in ipairs({ "{bad}", '{"a":1} trailing', '{"a":', '"unterminated' }) do local v, e = trace.decode(bad) check("rejects " .. bad, v == nil and type(e) == "string", e) end end -------------------------------------------------------------------------- group("protocol") -------------------------------------------------------------------------- local ok_protocol, protocol = pcall(require, "protocol") if ok_protocol and protocol.parse then local p = protocol.parse('THOUGHT: I should multiply.\nCALL: {"tool":"calc","args":{"expr":"2*3"}}') if check("parses a well-formed call", p ~= nil and p.kind == "call") then end if p and p.kind == "call" then eq("extracts tool name", p.tool, "calc") eq("extracts args", p.args and p.args.expr, "2*3") eq("extracts thought", p.thought, "I should multiply.") end local a = protocol.parse('THOUGHT: done\nCALL: {"tool":"answer","args":{"text":"6"}}') check("answer is a tool call", a ~= nil and a.kind == "answer", a and a.kind) if a and a.kind == "answer" then eq("answer text", a.text, "6") end -- Malformed input must return an error string, never raise: that string -- is fed back to the model as the observation. for _, bad in ipairs({ "no call here at all", 'CALL: {"tool":"calc"', 'CALL: {"args":{}}', 'CALL: {"tool":"calc","args":"not an object"}', }) do local v, e = protocol.parse(bad) check("rejects: " .. bad:sub(1, 34), v == nil and type(e) == "string", e) end end -------------------------------------------------------------------------- group("tools") -------------------------------------------------------------------------- local ok_tools, tools = pcall(require, "tools") if ok_tools and tools.registry then local reg = tools.registry() reg:add(tools.calc) eq("calc multiplies", select(2, reg:invoke("calc", { expr = "4871 * 209" })), "1018039") eq("calc adds", select(2, reg:invoke("calc", { expr = "2 + 2" })), "4") -- Validation failures are observations, not crashes local ok1, err1 = reg:validate("calc", {}) check("missing required arg rejected", not ok1, err1) local ok2, err2 = reg:validate("nope", { expr = "1" }) check("unknown tool rejected", not ok2, err2) -- calc must not be a Lua eval hole local _, res = reg:invoke("calc", { expr = "os.exit(1)" }) check("calc refuses non-arithmetic", tostring(res):match("arithmetic") ~= nil, res) -- The approval gate must default to refusing reg:add(tools.write_file) local _, _, status = reg:invoke("write_file", { path = "x.txt", text = "y" }, function() return false end) eq("denied write reports denied", status, "denied") end -------------------------------------------------------------------------- group("context") -------------------------------------------------------------------------- local ok_ctx, context = pcall(require, "context") if ok_ctx and context.new then -- A stand-in tokenizer: one token per 4 chars. The real one is only -- needed to prove the counts are real, not to prove the policies work. local fake_tok = { encode = function(_, s) local t = {} for _ = 1, math.max(1, math.ceil(#s / 4)) do t[#t + 1] = 0 end return t end } local ctx = context.new(fake_tok, 100, "elide_observations") eq("counts via the tokenizer", ctx:count("aaaaaaaa"), 2) eq("count is memoized", ctx:count("aaaaaaaa"), 2) local steps = {} for i = 1, 10 do steps[i] = { thought = "t" .. i, call = { tool = "read_file", args = { path = "f" .. i } }, observation = string.rep("x", 200), } end local kept, note = ctx:fit(10, steps) check("eviction fires under pressure", note ~= nil and note ~= "", note) check("eviction keeps recent steps", #kept > 0) check("result fits the budget", ctx:count(table.concat({ note or "" })) < 100) -- The model must be TOLD it lost information. Silent truncation is the -- single most common context bug. check("note names what was dropped", note and (note:match("elid") or note:match("drop") or note:match("omit")), note) -- fit() must never hand back a window bigger than the budget. The hard -- case is a SINGLE observation larger than the whole budget: no policy -- that evicts whole steps can help, so the observation itself gets cut. -- Before that backstop existed, this returned ~3x the budget and the -- ledger reported a number the model never received. local tight = context.new(fake_tok, 120, "elide_observations") local huge = { { thought = "t", call = { tool = "read_file", args = {} }, observation = string.rep("y", 8000) } } local kept2 = tight:fit(10, huge) check("one oversized observation still fits the budget", tight:count_steps(kept2) <= 110, "got " .. tight:count_steps(kept2) .. " tokens, budget 120 minus 10 fixed") -- And the same must hold across every policy. for _, name in ipairs({ "drop_oldest", "elide_observations", "summarize" }) do local c = context.new(fake_tok, 120, name) local k = c:fit(10, huge) check("policy " .. name .. " respects the budget", c:count_steps(k) <= 110, "got " .. c:count_steps(k)) end end -------------------------------------------------------------------------- group("grammar") -------------------------------------------------------------------------- local ok_gram, grammar = pcall(require, "grammar") if ok_gram and grammar.compile then -- A tiny fake tokenizer with a known vocabulary lets us test the token -- machine without loading a 60MB checkpoint. Character-level plus a few -- multi-character tokens, which is the case that actually matters: -- BPE tokens straddle the character boundaries the grammar cares about. local pieces = { [0] = "", [1] = "", [2] = "", } local n = 3 local function add(s) pieces[n] = s n = n + 1 return n - 1 end for c = 32, 126 do add(string.char(c)) end local multi = { '{"', 'tool', '":"', 'calc', 'args', '"}', '}}', '","', 'read_file', 'answer', 'text', 'expr', 'path', '2', '09', '4871' } for _, m in ipairs(multi) do add(m) end local vocab_size = n local fake_tok = { vocab_size = vocab_size, decode = function(_, id) return pieces[id] or "" end, } local reg = tools.registry() reg:add(tools.calc) reg:add(tools.read_file) local g = grammar.compile(reg, fake_tok, vocab_size) check("compiles", g ~= nil) if g then -- Drive the machine greedily and confirm the result parses. The mask -- is the only thing steering here; there is no model in this test. local m = g:start() local out, guard = {}, 0 while not m:is_done() and guard < 400 do guard = guard + 1 local logits = {} for i = 1, vocab_size do logits[i] = 0 end local legal = m:apply(logits, vocab_size) check("some token is always legal (step " .. guard .. ")", legal > 0) if legal == 0 then break end -- Take the lowest-id legal token: an adversarial "model" that -- knows nothing. If this still parses, the grammar is doing it. local chosen for i = 1, vocab_size do if logits[i] > -math.huge then chosen = i - 1 break end end out[#out + 1] = fake_tok:decode(chosen) m:advance(chosen) end check("machine terminates", m:is_done(), "after " .. guard .. " tokens") local text = table.concat(out) local decoded, derr = trace.decode(text) check("adversarial decode still parses: " .. text, decoded ~= nil, derr) -- A required argument must be non-empty BY CONSTRUCTION, not by -- later validation. tools.lua treats an empty required arg as a -- missing one, so a grammar that permits "" emits calls that are -- syntactically perfect and guaranteed to fail -- and the approval -- gate is never even reached, because validation rejects first. -- stories42M hit this four times running on the denied-write task. if decoded and decoded.args then for k, v in pairs(decoded.args) do check("argument " .. k .. " is non-empty", v ~= "", "the grammar allowed an empty required value") end end if decoded then check("has a tool field", decoded.tool ~= nil) check("tool is a registered name", decoded.tool == "calc" or decoded.tool == "read_file" or decoded.tool == "answer", decoded.tool) check("has an args object", type(decoded.args) == "table") end -- Directly: from the start of a value, the closing quote must be -- illegal. This is the invariant the walk above only samples. local m2 = g:start() local prefix = '{"tool":"calc","args":{"expr":"' local by_char = {} for id = 0, vocab_size - 1 do local t = fake_tok:decode(id) if #t == 1 and by_char[t] == nil then by_char[t] = id end end local drove = true for i = 1, #prefix do local id = by_char[prefix:sub(i, i)] if id == nil then drove = false break end local okc = pcall(function() m2:advance(id) end) if not okc then drove = false break end end check("drove the machine to a value start", drove) if drove then local lg = {} for i = 1, vocab_size do lg[i] = 0 end m2:apply(lg, vocab_size) check("closing quote illegal at an empty required value", lg[by_char['"'] + 1] == -math.huge) -- and legal once one character is in m2:advance(by_char["7"]) local lg2 = {} for i = 1, vocab_size do lg2[i] = 0 end m2:apply(lg2, vocab_size) check("closing quote legal after one character", lg2[by_char['"'] + 1] > -math.huge) end -- finish() force-completes a call; it must not produce the empty -- value the minimum exists to forbid. local m3 = g:start() for i = 1, #prefix do local id = by_char[prefix:sub(i, i)] if id then pcall(function() m3:advance(id) end) end end local forced = trace.decode(m3:text() .. m3:finish()) check("forced finish still yields a parseable call", forced ~= nil) if forced and forced.args then for k, v in pairs(forced.args) do check("forced finish leaves " .. k .. " non-empty", v ~= "") end end end end -------------------------------------------------------------------------- group("agent") -------------------------------------------------------------------------- local ok_agent, Agent = pcall(require, "agent") if ok_agent and Agent.new and ok_tools then -- A scripted backend: the cheapest possible way to test the loop's -- control flow, including every arm of the error taxonomy. local function scripted(replies) local i = 0 return { complete = function(_, _, _) i = i + 1 return replies[i] or 'THOUGHT: giving up\nCALL: {"tool":"answer","args":{"text":"none"}}', {} end } end local reg = tools.registry() reg:add(tools.calc) do local a = Agent.new({ backend = scripted({ 'THOUGHT: multiply\nCALL: {"tool":"calc","args":{"expr":"4871 * 209"}}', 'THOUGHT: done\nCALL: {"tool":"answer","args":{"text":"1018039"}}', }), tools = reg, tokenizer = { encode = function(_, s) return { #s } end }, }) local answer, reason, steps = a:run("What is 4871 * 209?") eq("happy path answers", answer, "1018039") eq("happy path reason", reason, "answer") eq("happy path took two steps", #steps, 2) eq("observation fed back", steps[1].observation, "1018039") end do -- parse error recovers rather than crashing local a = Agent.new({ backend = scripted({ 'THOUGHT: oops\nCALL: {"tool":"calc"', 'THOUGHT: retry\nCALL: {"tool":"calc","args":{"expr":"1+1"}}', 'THOUGHT: done\nCALL: {"tool":"answer","args":{"text":"2"}}', }), tools = reg, tokenizer = { encode = function(_, s) return { #s } end }, }) local answer, _, steps = a:run("one plus one") eq("recovers from a parse error", answer, "2") eq("parse error is a named status", steps[1].status, "parse_error") end do -- tool error becomes an observation local a = Agent.new({ backend = scripted({ 'THOUGHT: bad\nCALL: {"tool":"calc","args":{"expr":"not math"}}', 'THOUGHT: done\nCALL: {"tool":"answer","args":{"text":"recovered"}}', }), tools = reg, tokenizer = { encode = function(_, s) return { #s } end }, }) local answer, _, steps = a:run("x") eq("tool error is a named status", steps[1].status, "tool_error") check("error text reaches the model", (steps[1].observation or ""):match("[Ee]rror") ~= nil, steps[1].observation) eq("recovers from a tool error", answer, "recovered") end do -- loop detection local same = 'THOUGHT: again\nCALL: {"tool":"calc","args":{"expr":"1+1"}}' local a = Agent.new({ backend = scripted({ same, same, same, same, same, same, same, same }), tools = reg, tokenizer = { encode = function(_, s) return { #s } end }, repeat_threshold = 3, max_steps = 8, }) local _, _, steps = a:run("spin") local saw_repeat = false for _, s in ipairs(steps) do if s.status == "repeat" then saw_repeat = true end end check("loop detector fires", saw_repeat) end do -- step ceiling local a = Agent.new({ backend = scripted({}), tools = reg, tokenizer = { encode = function(_, s) return { #s } end }, max_steps = 3, }) local _, reason = a:run("x") check("terminates on its own", reason == "answer" or reason == "max_steps", reason) end end -------------------------------------------------------------------------- group("http") -------------------------------------------------------------------------- -- No network, no key, no tokens spent. What is covered here is the request -- shape and every branch of response handling; what is NOT covered is -- whether the live API accepts the request. The README says so plainly. -- -- These are the branches a happy-path live call would never reach anyway: -- thinking blocks arriving before the text, a refusal at HTTP 200, an error -- object, a transport failure. do local ok_http, http = pcall(require, "backend.http") if ok_http and http.new then local sent local function transport_returning(raw, ok) return function(body_json) sent = body_json return ok ~= false, raw, 0 end end -- Request shape local be = http.new({ transport = transport_returning( '{"content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn"}') }) be:complete("TASK: add 2 and 2") local req = trace.decode(sent or "") check("request decodes", req ~= nil) if req then eq("model defaults to opus 5", req.model, "claude-opus-5") eq("temperature is deterministic", req.temperature, 0) check("max_tokens is set", type(req.max_tokens) == "number") check("system prompt carries the format", (req.system or ""):match("CALL:") ~= nil) check("stop sequences sent", type(req.stop_sequences) == "table") check("one user message", req.messages and #req.messages == 1 and req.messages[1].role == "user") check("prompt reaches the message", (req.messages[1].content or ""):match("add 2 and 2") ~= nil) -- Assistant prefill is rejected on current models; the backend -- must never send one. local has_assistant = false for _, m in ipairs(req.messages or {}) do if m.role == "assistant" then has_assistant = true end end check("no assistant prefill", not has_assistant) end -- Thinking blocks precede the text on Opus-tier models; taking -- content[1] blindly would return an empty string. local text = http.parse_response( '{"content":[{"type":"thinking","thinking":"hmm"},' .. '{"type":"text","text":"THOUGHT: ok"},{"type":"text","text":"\\nCALL: {}"}],' .. '"stop_reason":"end_turn","usage":{"output_tokens":7,"input_tokens":50},' .. '"model":"claude-opus-5"}') eq("skips thinking blocks and joins text", text, "THOUGHT: ok\nCALL: {}") local _, meta = http.parse_response( '{"content":[{"type":"text","text":"x"}],"stop_reason":"end_turn",' .. '"usage":{"output_tokens":7,"input_tokens":50},"model":"claude-opus-5"}') eq("reports output tokens", meta.tokens, 7) eq("reports input tokens", meta.input_tokens, 50) eq("reports the serving model", meta.model, "claude-opus-5") eq("http is never constrained", meta.constrained, false) -- A refusal is HTTP 200 with stop_reason "refusal", not an error. local r_ok, r_err = pcall(http.parse_response, '{"content":[],"stop_reason":"refusal",' .. '"stop_details":{"type":"refusal","category":"cyber"}}') check("refusal raises", not r_ok) check("refusal names the category", tostring(r_err):match("cyber") ~= nil, r_err) -- An API error object. local e_ok, e_err = pcall(http.parse_response, '{"type":"error","error":{"type":"authentication_error","message":"bad key"}}') check("api error raises", not e_ok) check("api error surfaces the message", tostring(e_err):match("bad key") ~= nil, e_err) -- Malformed body (an HTML error page, say). local m_ok = pcall(http.parse_response, "502") check("malformed body raises rather than returning junk", not m_ok) -- Transport failure must surface, not be swallowed. local be2 = http.new({ transport = transport_returning("no route to host", false) }) local t_ok, t_err = pcall(function() return be2:complete("x") end) check("transport failure raises", not t_ok) check("transport failure keeps the detail", tostring(t_err):match("no route to host") ~= nil, t_err) -- The agent loop must treat a backend failure as fatal, not crash. if ok_tools and ok_agent then local reg = tools.registry() reg:add(tools.calc) local a = Agent.new({ backend = http.new({ transport = transport_returning("boom", false) }), tools = reg, tokenizer = { encode = function(_, s) return { #s } end }, max_steps = 3, }) local answer, reason = a:run("x") eq("backend failure ends the run as fatal", reason, "fatal") check("no answer is invented", answer == nil) end end end -------------------------------------------------------------------------- group("tmpfile") -------------------------------------------------------------------------- -- The transport seam that makes the http and ollama backends testable also -- means their curl path is never executed by those tests. It had a bug the -- whole time: os.tmpname() on Windows returns a bare name rooted at the -- current drive ("\s5tg."), io.open on it fails with permission denied, and -- the backends indexed the nil handle. Every real request died before it -- was sent. -- -- So this tests the staging directly -- it is the part the seam hides. do local ok_tmp, tmpfile = pcall(require, "tmpfile") if ok_tmp then local p1 = tmpfile.name(".req") local p2 = tmpfile.name(".req") check("names are unique", p1 ~= p2, p1 .. " == " .. p2) check("name carries the suffix", p1:sub(-4) == ".req", p1) local path, err = tmpfile.write("hello world", ".txt") check("writes a file that can actually be opened", path ~= nil, err) if path then eq("round-trips the contents", tmpfile.slurp(path), "hello world") tmpfile.remove(path) check("remove deletes it", tmpfile.slurp(path) == nil) end -- Bodies contain model output: quotes, newlines, braces. local tricky = '{"tool":"calc","args":{"expr":"2+2"}}\nsecond "line"\n' local p3 = tmpfile.write(tricky, ".json") if p3 then eq("survives quotes and newlines", tmpfile.slurp(p3), tricky) tmpfile.remove(p3) end check("slurp of a missing file returns nil", tmpfile.slurp(tmpfile.name(".nope")) == nil) -- remove() is called on cleanup paths where some handles are nil. local rok = pcall(tmpfile.remove, nil, nil) check("remove tolerates nils", rok) end end -------------------------------------------------------------------------- group("ollama") -------------------------------------------------------------------------- -- Same fixture approach as the http group: no daemon, no model pull. do local ok_ol, ollama = pcall(require, "backend.ollama") if ok_ol and ollama.new then local sent local function transport(raw, ok) return function(body) sent = body return ok ~= false, raw, 0 end end local oreg = tools.registry() oreg:add(tools.calc) local be = ollama.new({ format = false, transport = transport( '{"model":"qwen2.5:0.5b-instruct","response":"THOUGHT: ok\\nCALL: {}",' .. '"done":true,"eval_count":12,"prompt_eval_count":80}') }) local text, meta = be:complete("TASK: x") local req = trace.decode(sent or "") check("request decodes", req ~= nil) if req then eq("default model", req.model, "qwen2.5:0.5b-instruct") eq("streaming off", req.stream, false) -- /api/chat: role-separated messages, not system+prompt fields. check("system turn is first", req.messages and req.messages[1] and req.messages[1].role == "system") check("free mode asks for the text format in the system turn", req.messages and (req.messages[1].content or ""):match("CALL:") ~= nil) check("the prompt is the user turn", req.messages and req.messages[2] and (req.messages[2].content or ""):match("TASK: x") ~= nil) check("stop sequences set", req.options and type(req.options.stop) == "table") eq("temperature deterministic", req.options and req.options.temperature, 0) check("no format schema in free mode", req.format == nil) end eq("reads the response field", text, "THOUGHT: ok\nCALL: {}") eq("reports eval_count as tokens", meta.tokens, 12) eq("reports prompt_eval_count", meta.input_tokens, 80) -- This backend must never claim grammar.lua was involved; the whole -- mechanism/capability distinction rests on it. eq("never reports itself as constrained", meta.constrained, false) -- Schema mode: the per-tool JSON schema goes with the request, and -- the reply is rewrapped into the repo's one text protocol. local be2 = ollama.new({ tools = oreg, transport = transport( '{"message":{"content":"{\\"thought\\":\\"mul\\",\\"call\\":' .. '{\\"tool\\":\\"calc\\",\\"args\\":{\\"expr\\":\\"2+2\\"}}}"},"done":true}') }) local t2, m2 = be2:complete("x") local req2 = trace.decode(sent or "") check("schema sent by default", req2 and type(req2.format) == "table" and req2.format.type == "object") eq("schema mode is reported", m2.schema, true) eq("schema reply is rewrapped into the text protocol", t2, 'THOUGHT: mul\nCALL: {"args":{"expr":"2+2"},"tool":"calc"}') local turn2 = protocol.parse(t2) check("and the rewrapped turn parses", turn2 ~= nil and turn2.tool == "calc") -- KEY ORDER IS LOAD-BEARING, and nothing else in the repo cares. -- llama.cpp compiles the schema to GBNF in declaration order, so -- trace.encode's alphabetical sort put `call` before `thought` and -- `args` before `tool` -- forcing the model to pick a tool before -- reasoning, and to write arguments before naming the tool. qwen2.5 -- then re-called a tool it had already run, five times running, and -- read as a model too small for the job. It was the encoder. local raw2 = sent or "" local ti = raw2:find('"thought"', 1, true) local ci = raw2:find('"call"', 1, true) check("schema declares thought before call", ti and ci and ti < ci, "thought at " .. tostring(ti) .. ", call at " .. tostring(ci)) local tool_i = raw2:find('"tool"', 1, true) local args_i = raw2:find('"args"', 1, true) check("schema declares tool before args", tool_i and args_i and tool_i < args_i, "tool at " .. tostring(tool_i) .. ", args at " .. tostring(args_i)) -- The overwhelmingly most common first failure: model not pulled. local p_ok, p_err = pcall(ollama.parse_response, '{"error":"model \'qwen2.5:0.5b-instruct\' not found, try pulling it first"}', "qwen2.5:0.5b-instruct") check("unpulled model raises", not p_ok) check("and says how to fix it", tostring(p_err):match("ollama pull") ~= nil, p_err) -- Any other Ollama error. local g_ok, g_err = pcall(ollama.parse_response, '{"error":"out of memory"}', "m") check("generic error raises", not g_ok) check("generic error keeps the message", tostring(g_err):match("out of memory") ~= nil, g_err) -- Daemon down: curl writes nothing. local d_ok = pcall(ollama.parse_response, "", "m") check("empty body raises rather than returning junk", not d_ok) -- A well-formed response with no response field. local n_ok = pcall(ollama.parse_response, '{"done":true}', "m") check("missing response field raises", not n_ok) end end -------------------------------------------------------------------------- group("mcp") -------------------------------------------------------------------------- -- No server, no npx, no network. The fixtures are real captures from -- @modelcontextprotocol/server-everything, including the two things the -- spec does not lead with: responses arrive out of order with -- notifications mixed in, and a failing tool returns a SUCCESSFUL result -- carrying isError rather than a JSON-RPC error. do local ok_mcp, mcp = pcall(require, "mcp") if ok_mcp and mcp.new and ok_tools then local sent local function transport(reply, ok) return function(request) sent = request return ok ~= false, reply, 0 end end -- Deliberately out of order: notification first, then tools/list -- (id 2), then initialize (id 1). Position-based matching passes a -- naive test and fails against the real server. local discovery = table.concat({ '{"method":"notifications/tools/list_changed","jsonrpc":"2.0"}', '{"jsonrpc":"2.0","id":2,"result":{"tools":[' .. '{"name":"echo","description":"Echoes back the input string",' .. '"inputSchema":{"type":"object","properties":{"message":{"type":"string"}},' .. '"required":["message"]}},' .. '{"name":"add","description":"Adds two numbers",' .. '"inputSchema":{"type":"object","properties":{"a":{"type":"number"},' .. '"b":{"type":"integer"}},"required":["a","b"]}}]}}', '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18",' .. '"serverInfo":{"name":"mcp-servers/everything","version":"2.0.0"}}}', }, "\n") local c = mcp.new({ transport = transport(discovery) }) local list = c:discover() eq("discovers every tool", #list, 2) eq("matches initialize by id, not position", c.server_info and c.server_info.name, "mcp-servers/everything") eq("records the negotiated version", c.negotiated_version, "2025-06-18") local req = sent or "" check("handshake sends initialize", req:match('"initialize"') ~= nil) check("handshake sends the initialized notification", req:match("notifications/initialized") ~= nil) check("and asks for tools/list", req:match('"tools/list"') ~= nil) check("requests are newline-delimited JSON", select(2, req:gsub("\n", "")) >= 3, req) -- Schema conversion into the registry's arg format. local reg = tools.registry() reg:add(tools.calc) local mounted = mcp.mount(reg, c, { prefix = "mcp_" }) eq("mounts all of them", #mounted, 2) check("namespaced by the prefix", reg:get("mcp_echo") ~= nil) check("built-ins untouched", reg:get("calc") ~= nil) local echo = reg:get("mcp_echo") eq("required string argument survives", echo.args[1].name, "message") eq("typed as a string", echo.args[1].type, "string") eq("and marked required", echo.args[1].required, true) -- Remote code runs outside this process; it does not get the -- unattended treatment read-only built-ins get. eq("remote tools need approval by default", echo.requires_approval, true) local add = reg:get("mcp_add") eq("JSON Schema integer maps to number", add.args[1].type, "number") eq("args are sorted for a stable grammar", add.args[1].name, "a") -- Collisions are refused, not silently resolved: shadowing calc -- with a remote tool of the same name would be a nasty surprise. local reg2 = tools.registry() reg2:add(tools.calc) local clash = mcp.new({ transport = transport(discovery) }) clash:discover() clash.tools[1].name = "calc" local cok, cerr = pcall(mcp.mount, reg2, clash, {}) check("refuses to shadow an existing tool", not cok) check("and says to use a prefix", tostring(cerr):match("prefix") ~= nil, cerr) -- A failing tool: successful JSON-RPC, isError true. local c2 = mcp.new({ transport = transport(table.concat({ '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","serverInfo":{}}}', '{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text",' .. '"text":"MCP error -32602: Invalid input: expected string"}],"isError":true}}', }, "\n")) }) local txt, is_err = c2:call("echo", {}) eq("tool failure is reported, not raised", is_err, true) check("and keeps the message the model needs", txt:match("expected string") ~= nil, txt) -- Non-text content is named rather than dropped silently. local c3 = mcp.new({ transport = transport(table.concat({ '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"x","serverInfo":{}}}', '{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"image","data":"..."}]}}', }, "\n")) }) local itxt = c3:call("shot", {}) check("non-text content is named", itxt:match("image") ~= nil, itxt) -- A real JSON-RPC error object, which IS a failure. local c4 = mcp.new({ transport = transport( '{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}') }) local eok, eerr = pcall(function() return c4:discover() end) check("a JSON-RPC error raises", not eok) check("with the server's message", tostring(eerr):match("Method not found") ~= nil, eerr) -- Server that never started: stdout empty. local c5 = mcp.new({ transport = transport("", false) }) local sok = pcall(function() return c5:discover() end) check("a dead server raises rather than hanging", not sok) -- Streamable HTTP replies are SSE frames, not bare JSON -- even for -- a single response. Captured from server-everything. local sse = table.concat({ "event: message", "id: c93c36e5-2a40-47fe-add0-8295cae3aa4f", 'data: {"result":{"protocolVersion":"2025-06-18","serverInfo":' .. '{"name":"mcp-servers/everything"}},"jsonrpc":"2.0","id":1}', }, "\n") local c7 = mcp.new({ transport = transport(sse) }) local by_id = c7:exchange({ { jsonrpc = "2.0", id = 1, method = "initialize" } }) check("unwraps SSE data: frames", by_id[1] ~= nil) eq("and reads through to the payload", by_id[1] and by_id[1].result.serverInfo.name, "mcp-servers/everything") -- Over HTTP the handshake happens once and the session id carries -- it; over stdio the server is re-spawned, so every exchange must -- re-handshake. Getting this backwards either wastes a round trip -- per call or talks to an uninitialised server. local h = mcp.new({ url = "http://localhost:1/mcp" }) eq("http handshakes on the first exchange", #h:handshake(), 2) h.session_ready = true eq("and not again afterwards", #h:handshake(), 0) local st = mcp.new({ command = "true" }) st.session_ready = true eq("stdio always re-handshakes", #st:handshake(), 2) -- Garbage on stdout must not kill an otherwise good exchange. local c6 = mcp.new({ transport = transport(table.concat({ "Starting default (STDIO) server...", '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1","serverInfo":{}}}', '{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}', }, "\n")) }) local gok = pcall(function() return c6:discover() end) check("non-JSON noise on stdout is skipped", gok) end end -------------------------------------------------------------------------- group("replay") -------------------------------------------------------------------------- -- Every transcript in examples/ is backed by a trace in traces/. Replaying -- one must reproduce the recorded answer exactly. This is what makes the -- examples evidence rather than illustration: if a transcript stops -- reproducing, this fails. -- -- Skipped silently when the traces are absent (regenerate: lua54 record.lua). do local ok_replay, replay = pcall(require, "backend.replay") local ok_tasks, tasklist = pcall(require, "eval.tasks.tasks") local ok_proto, proto = pcall(require, "protocol") if ok_replay and ok_tasks and ok_proto and ok_tools and ok_agent then local real_render = proto.render_system proto.render_system = function(t, task) return real_render(t, task, true) end local approx = { encode = function(_, s) local o = {} for k = 1, math.max(1, math.ceil(#s / 4)) do o[k] = k end return o end } local found = 0 for _, task in ipairs(tasklist.tasks) do local path = "traces/" .. task.id .. ".jsonl" local fh = io.open(path, "r") if fh then fh:close() found = found + 1 local reg = tools.registry() for _, n in ipairs(task.tools) do reg:add(tools[n]) end local a = Agent.new({ backend = replay.new({ path = path }), tools = reg, tokenizer = approx, max_steps = 10, max_tokens = task.budget or 4096, approve = function() return task.approve == true end, }) local ran, answer, _, steps = pcall(function() local x, y, z = a:run(task.prompt) return x, y, z end) check("replays " .. task.id, ran, answer) if ran then local passed, why = task.check(answer, steps or {}, { evicted = true }) check("replay of " .. task.id .. " still passes its check", passed, why) end end end proto.render_system = real_render if found == 0 then io.write(" (no traces on disk; run: lua54 record.lua)\n") end end end -------------------------------------------------------------------------- io.write(string.format("\n%d passed, %d failed\n", pass, fail)) os.exit(fail == 0 and 0 or 1)