summaryrefslogtreecommitdiff
path: root/_selfcheck.lua
diff options
context:
space:
mode:
Diffstat (limited to '_selfcheck.lua')
-rw-r--r--_selfcheck.lua236
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")