summaryrefslogtreecommitdiff
path: root/_selfcheck.lua
blob: adf02f1b05490edd85957cbc50c187d12b6f15f4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
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")