summaryrefslogtreecommitdiff
path: root/spec/test_init.lua
diff options
context:
space:
mode:
Diffstat (limited to 'spec/test_init.lua')
-rw-r--r--spec/test_init.lua180
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 },
+}