diff options
Diffstat (limited to 'spec/test_init.lua')
| -rw-r--r-- | spec/test_init.lua | 180 |
1 files changed, 180 insertions, 0 deletions
diff --git a/spec/test_init.lua b/spec/test_init.lua new file mode 100644 index 0000000..800dcaa --- /dev/null +++ b/spec/test_init.lua @@ -0,0 +1,180 @@ +-- init.lua: the extension entry point pantograph evaluates and activates. +-- +-- Activation is the whole contract with the host: the wrong shape, a missing +-- tool, a description that does not name the discovered profiles, or a missing +-- lifecycle subscription is invisible until a user notices the tools are gone or +-- a cancelled turn leaves children running. Discovery is pointed at a temporary +-- config layer, so the assertions do not depend on this machine's ~/.config; the +-- first case skips when luv or lyaml is missing because the profile it looks for +-- could not be read without them. + +local fake = require("spec.fake_ext") +local jobs = require("subagents.jobs") +local paths = require("subagents.paths") + +local entry = require("init") + +local function has(text, needle) + assert(type(text) == "string", "expected a string, got " .. type(text)) + assert(text:find(needle, 1, true), "expected to find " .. needle .. " in:\n" .. tostring(text)) +end + +-- Activate against the fake host with discovery pointed at nothing, for the +-- cases that care about registration rather than profiles. Profile discovery is +-- cached in subagents.spawn, so a case that ran earlier may have filled it. +local function activate_bare(fn, opts) + local original = paths.config_roots + paths.config_roots = function() + return {} + end + local handle = fake.install(opts) + local ok, err = pcall(function() + if opts and opts.before then + opts.before(handle) + end + entry.activate() + end) + paths.config_roots = original + local result = table.pack(pcall(fn, handle, ok, err)) + handle.restore() + if not result[1] then + error(result[2], 0) + end +end + +return { + { "the entry is the extension shape pantograph expects", function() + assert(entry.name == "subagents", tostring(entry.name)) + assert(type(entry.activate) == "function", "activate must be a function") + end }, + + { "activation registers the four tools and names the discovered profiles", function() + local ok_uv, uv = pcall(require, "luv") + if not ok_uv then + return "skip", "luv is not installed" + end + if not pcall(require, "lyaml") then + return "skip", "lyaml is not installed" + end + + local tmp = assert(uv.fs_mkdtemp("/tmp/panto-subagents-init-XXXXXX")) + assert(os.execute("mkdir -p " .. tmp .. "/agents")) + local file = assert(io.open(tmp .. "/agents/reviewer.md", "w")) + file:write("---\ndescription: Reviews changes\n---\nYou are a reviewer.\n") + file:close() + + local original_roots = paths.config_roots + paths.config_roots = function(kind) + return { tmp .. "/" .. kind } + end + local handle = fake.install() + + local ok, err = pcall(entry.activate) + + paths.config_roots = original_roots + handle.restore() + os.execute("rm -rf " .. tmp) + assert(ok, tostring(err)) + + for _, name in ipairs({ "subagents.run", "subagents.models", "subagents.lua", "subagents.workflow" }) do + assert(handle.tools_by_name[name], "missing tool " .. name) + end + assert(#handle.tools == 4, "expected exactly four tools, saw " .. #handle.tools) + + local run_tool = handle.tools_by_name["subagents.run"] + has(run_tool.description, "reviewer — Reviews changes") + has(run_tool.description, "exactly one of `agent`") + has(run_tool.description, "subagents.models") + has(run_tool.description, "one tool batch") + assert(run_tool.schema.required[1] == "prompt", "prompt is the only required field") + assert(run_tool.schema.properties.agent and run_tool.schema.properties.id) + assert(type(run_tool.handler) == "function") + + assert(handle.tools_by_name["subagents.lua"].schema.properties.source, "the lua tool takes source") + assert(handle.tools_by_name["subagents.workflow"].schema.properties.steps.items.required, + "the workflow tool describes its step shape") + end }, + + { "an interrupted turn cancels every live child, and its end closes them", function() + activate_bare(function(handle, ok, err) + assert(ok, tostring(err)) + assert(type(handle.on_by_name["turn_interrupt"]) == "function", + "an interrupted turn must be able to cancel its children") + assert(type(handle.on_by_name["turn_end"]) == "function", + "a finished turn must be able to close its children") + + -- A child that would not settle on its own, so the lifecycle is the + -- only thing that can end it. close_all runs whatever happens, or a + -- leaked handle would count against the next case's gate. + local job = fake.job({ settle = 99 }) + local started = assert(jobs.start({ label = "alpha", build = function() + return job + end })) + local checked, failure = pcall(function() + assert(started:result() == nil, "the child is still running") + handle.emit("turn_interrupt", { phase = "interrupt" }) + assert(job._cancel_requested, "an interrupted turn asks its children to stop") + handle.emit("turn_end", { phase = "end", reason = "interrupted" }) + assert(job._closed, "the end of the turn joins every child") + end) + jobs.close_all() + assert(checked, failure) + end) + end }, + + { "only the subagents tool calls claim a progress component", function() + activate_bare(function(handle, ok, err) + assert(ok, tostring(err)) + assert(type(handle.on_by_name["tool_call_complete"]) == "function", + "children report progress through the tool call that started them") + + local claimed + handle.emit("tool_call_complete", { + id = "call-1", + tool_name = "subagents.run", + set_component = function(_, component) + claimed = component + return { invalidate = function() end, alive = function() + return true + end } + end, + }) + assert(type(claimed) == "table" and type(claimed.render) == "function", + "the entry is given a component that renders the cards") + + local foreign + handle.emit("tool_call_complete", { + id = "call-2", + tool_name = "read_file", + set_component = function(_, component) + foreign = component + end, + }) + assert(foreign == nil, "another tool's entry is left alone") + end) + end }, + + { "a host without resolve_model fails activation loudly", function() + activate_bare(function(handle, ok, err) + assert(not ok, "activation must not silently register unusable tools") + has(tostring(err), "too old") + assert(#handle.tools == 0, "nothing is registered by a failed activation") + end, { + before = function(handle) + handle.ext.resolve_model = nil + end, + }) + end }, + + { "a host without panto.agent fails activation loudly", function() + activate_bare(function(handle, ok, err) + assert(not ok, "a host that cannot build a child agent is too old") + has(tostring(err), "too old") + assert(#handle.tools == 0, "nothing is registered by a failed activation") + end, { + before = function() + package.loaded.panto.agent = nil + end, + }) + end }, +} |
