diff options
Diffstat (limited to '_selfcheck.lua')
| -rw-r--r-- | _selfcheck.lua | 236 |
1 files changed, 236 insertions, 0 deletions
diff --git a/_selfcheck.lua b/_selfcheck.lua new file mode 100644 index 0000000..adf02f1 --- /dev/null +++ b/_selfcheck.lua @@ -0,0 +1,236 @@ +-- Self-check for the panto-agent suite. Run with: +-- +-- panto lua panto-agent/_selfcheck.lua +-- +-- Uses a stub `panto` module, so it exercises the pure extension logic +-- (file selection, symlink resolution, message format) without a session. + +local uv = require("luv") + +-- Stub panto module: capture agent:add_system_message calls and `on` +-- subscriptions (ext.agent stands in for the session agent). Must be +-- seeded before rules.lua is required. +local captured = {} +local handlers = {} +local registered = {} +package.loaded["panto"] = { + ext = { + agent = { + add_system_message = function(_, text) captured[#captured + 1] = text end, + }, + on = function(event, handler) handlers[event] = handler end, + register_tool = function(t) registered[#registered + 1] = t end, + json = { + -- Just enough JSON for the inputs this selfcheck fabricates + -- (a flat object with a "path" string, no escapes). + decode = function(s) return { path = s:match('"path":"(.-)"') } end, + }, + }, +} + +-- Make the suite requirable relative to this file, wherever cwd is. +local here = debug.getinfo(1, "S").source:match("^@(.*)/[^/]+$") or "." +package.path = here .. "/panto-agent/?.lua;" .. here .. "/panto-agent/?/init.lua;" .. package.path + +local rules = require("rules") +assert(rules.name == "agent.rules", "entry name") + +-- Harness pump: panto runs entrypoints inside a coroutine with the uv +-- loop driven; this standalone script has to provide the same +-- environment itself. +local function run(fn) + local results + local co = coroutine.create(function() results = table.pack(fn()) end) + local ok, err = coroutine.resume(co) + if not ok then error(err, 0) end + while coroutine.status(co) ~= "dead" do + uv.run("once") + end + return table.unpack(results, 1, results.n) +end + +local function write(path, text) + local f = assert(io.open(path, "w")) + f:write(text) + f:close() +end + +local tmp = assert(uv.fs_mkdtemp("/tmp/panto-agent-selfcheck-XXXXXX")) +local function dir(name) + local d = tmp .. "/" .. name + assert(uv.fs_mkdir(d, 493)) -- 0755 + return d +end + +-- both: AGENTS.md wins, CLAUDE.md ignored +local both = dir("both") +write(both .. "/AGENTS.md", "agents rules") +write(both .. "/CLAUDE.md", "claude rules") +-- claude-only: falls back to CLAUDE.md +local claude_only = dir("claude-only") +write(claude_only .. "/CLAUDE.md", "claude only") +-- empty: contributes nothing +local empty = dir("empty") +-- linked: AGENTS.md is a symlink; resolved path reported +local target_dir = dir("target") +write(target_dir .. "/real-rules.md", "linked rules") +local linked = dir("linked") +assert(uv.fs_symlink(target_dir .. "/real-rules.md", linked .. "/AGENTS.md")) + +-- activate(): subscribes to session_start; each firing resets the +-- session-global `seen` set and injects one message per file, using the +-- event's cwd as the project root. Activate first so firing session_start +-- doubles as the seen-reset between the scenarios below. +rules.layer_dirs = function(cwd) return { cwd } end +rules.activate() +local on_session_start = handlers["session_start"] +assert(type(on_session_start) == "function", "session_start handler registered") +assert(#captured == 0, "nothing injected before session_start fires") + +-- collect() awaits libuv callbacks, so it runs under the harness pump. +local got = run(function() return rules.collect({ both, claude_only, empty, linked }) end) +assert(#got == 3, "expected 3 collected, got " .. #got) +assert(got[1].path == uv.fs_realpath(both .. "/AGENTS.md"), "AGENTS.md over CLAUDE.md") +assert(got[1].text == "agents rules") +assert(got[2].path == uv.fs_realpath(claude_only .. "/CLAUDE.md"), "CLAUDE.md fallback") +assert(got[2].text == "claude only") +assert(got[3].path == uv.fs_realpath(target_dir .. "/real-rules.md"), "symlink resolved") +assert(got[3].text == "linked rules") + +-- Session-global dedupe: a repeat collect of already-seen files is empty. +local again = run(function() return rules.collect({ both }) end) +assert(#again == 0, "seen files are not collected twice in a session") + +-- A session boundary resets `seen` (empty dir: injects nothing). +run(function() on_session_start({ cwd = empty }) end) +assert(#captured == 0, "empty root injects nothing") +-- Two dirs reaching the same real file within one collect: used once. +local dedup = run(function() return rules.collect({ linked, target_dir }) end) +assert(#dedup == 1, "same real file deduped") + +run(function() on_session_start({ cwd = both }) end) +assert(#captured == 1, "one system message") +local want = "context from " .. uv.fs_realpath(both .. "/AGENTS.md") .. ":\n\nagents rules" +assert(captured[1] == want, "message format:\n" .. captured[1]) +-- A second session boundary (e.g. /new) re-injects for the new cwd. +run(function() on_session_start({ cwd = claude_only }) end) +assert(#captured == 2 and captured[2]:find("claude only"), "re-injects on next session") + +-- tool_result handler: rules files between a read and the session root +-- ride on the result via the writable `output` field, and the component +-- wrapper lists them. Tree: proj/ (root, covered by the system prompt), +-- proj/sub (AGENTS.md), proj/sub/deep (CLAUDE.md only). +local proj = dir("proj") +write(proj .. "/AGENTS.md", "root rules") +assert(uv.fs_mkdir(proj .. "/sub", 493)) +write(proj .. "/sub/AGENTS.md", "sub rules") +assert(uv.fs_mkdir(proj .. "/sub/deep", 493)) +write(proj .. "/sub/deep/CLAUDE.md", "deep rules") +write(proj .. "/sub/deep/file.txt", "hello") + +run(function() on_session_start({ cwd = proj }) end) +local on_tool_result = handlers["tool_result"] +assert(type(on_tool_result) == "function", "tool_result handler registered") + +-- Fabricate a `tool_result` event: a plain table stands in for the real +-- event userdata (assignment and method calls look the same). +local fake_comp = { render = function(_, _) return { "read line" } end } +local function read_event(path) + local ev = { + tool_name = "std.read", + input = '{"path":"' .. path .. '"}', + output = "FILE", + } + ev.get_component = function() return fake_comp end + ev.set_component = function(_, t) ev.component = t end + return ev +end + +local ev = read_event(proj .. "/sub/deep/file.txt") +run(function() on_tool_result(ev) end) +local sub_real = uv.fs_realpath(proj .. "/sub/AGENTS.md") +local deep_real = uv.fs_realpath(proj .. "/sub/deep/CLAUDE.md") +assert(ev.output == "FILE" + .. "\n\n---\ncontext from " .. sub_real .. ":\n\nsub rules" + .. "\n\n---\ncontext from " .. deep_real .. ":\n\ndeep rules", + "walk order + format:\n" .. ev.output) +-- The component wrapper passes the original lines through and lists the +-- attached files below them. +local lines = ev.component.render(ev.component, 80) +assert(lines[1] == "read line", "wrapper renders through the original") +assert(lines[2] == "+ rules: " .. sub_real and lines[3] == "+ rules: " .. deep_real, + "wrapper lists attached files") + +-- Same read again: everything already seen, result and component untouched. +local ev2 = read_event(proj .. "/sub/deep/file.txt") +run(function() on_tool_result(ev2) end) +assert(ev2.output == "FILE" and ev2.component == nil, "already-seen rules are not re-injected") + +-- Reading a directory directly starts the walk at that directory. +assert(uv.fs_mkdir(proj .. "/other", 493)) +write(proj .. "/other/AGENTS.md", "other rules") +local ev3 = read_event(proj .. "/other") +run(function() on_tool_result(ev3) end) +assert(ev3.output:find("other rules", 1, true), "directory read includes its own rules") + +-- Reads outside the session root add nothing; other tools are ignored. +local ev4 = read_event(both .. "/AGENTS.md") +run(function() on_tool_result(ev4) end) +assert(ev4.output == "FILE", "outside-root reads are untouched") +local ev5 = read_event(proj .. "/sub/deep/file.txt") +ev5.tool_name = "std.shell" +run(function() on_tool_result(ev5) end) +assert(ev5.output == "FILE", "non-read tools are ignored") + +-- agent.skill: layered discovery, frontmatter-name shadowing, enum/desc +-- registration, frontmatter-stripped load with a path preamble. +local skill = require("skill") +assert(skill.name == "agent.skill", "skill entry name") + +local function write_skill(layer, dirname, name, desc, body) + local d = layer .. "/" .. dirname + assert(uv.fs_mkdir(d, 493)) + write(d .. "/SKILL.md", "---\nname: " .. name .. "\ndescription: " .. desc + .. "\nmetadata:\n author: selfcheck\n---\n" .. body) + return d +end + +local sk_low = dir("skills-low") +local sk_high = dir("skills-high") +-- alpha in both layers; the high layer's directory name differs from the +-- frontmatter name, which is the identity that shadows. +write_skill(sk_low, "alpha", "alpha", "low alpha", "low alpha body\n") +local alpha_high = write_skill(sk_high, "alpha-dir", "alpha", '"high alpha"', "high alpha body\n") +write_skill(sk_low, "beta", "beta", "beta desc", "beta body\n") +-- Not skills: no SKILL.md; SKILL.md without frontmatter. +assert(uv.fs_mkdir(sk_low .. "/noskill", 493)) +assert(uv.fs_mkdir(sk_low .. "/badfm", 493)) +write(sk_low .. "/badfm/SKILL.md", "just markdown, no frontmatter\n") + +skill.skill_dirs = function() return { sk_low, sk_high } end +run(function() skill.activate() end) +assert(#registered == 1, "one tool registered") +local tool = registered[1] +assert(tool.name == "agent.skill", "tool name") +local enum = tool.schema.properties.skill.enum +assert(#enum == 2 and enum[1] == "alpha" and enum[2] == "beta", "enum is the surviving names, sorted") +assert(tool.description:find("- alpha: high alpha", 1, true), "shadowing layer's description wins") +assert(tool.description:find("- beta: beta desc", 1, true), "description lists every skill") + +local alpha_real = uv.fs_realpath(alpha_high) +local out = run(function() return tool.handler({ skill = "alpha" }) end) +assert(out:find("loaded from " .. alpha_real .. "/SKILL.md", 1, true), "preamble names the real path") +assert(out:find("resolve it against " .. alpha_real .. "/", 1, true), "preamble covers relative paths") +assert(out:find("high alpha body", 1, true), "higher layer shadows lower") +assert(not out:find("name: alpha", 1, true), "frontmatter stripped") + +local miss = run(function() return tool.handler({ skill = "nope" }) end) +assert(miss:find("unknown skill: nope", 1, true) and miss:find("alpha, beta", 1, true), + "unknown skill lists the available ones") + +-- No skills anywhere: the tool is not registered at all. +skill.skill_dirs = function() return { empty } end +run(function() skill.activate() end) +assert(#registered == 1, "no skills, no tool") + +print("panto-agent selfcheck: OK") |
