From 4f0a91ef55fe96835172bdad34feec1e2a0a0977 Mon Sep 17 00:00:00 2001 From: t Date: Sun, 16 Aug 2026 20:42:43 -0600 Subject: subagents extension on the generic host surfaces The rock now owns all subagent policy on top of libpanto-lua's generic APIs: children are ordinary panto.agent instances over rock-constructed stores, started with agent:run_async and awaited by arming uv.new_poll on each job's wake_fd from the tool handler's coroutine. subagents/jobs.lua carries the session policy the host used to own: the concurrency gate (4 running, FIFO queue, cancel-while-queued never starts), the await contract (results in input order; "first" returns settled plus remaining by identity), and settle-time shaping. subagents/spawn.lua seeds new children (primary system context, child role, profile body with manifest metadata), resolves model/reasoning through panto.ext.resolve_model, filters subagents.* out of the inherited tool set via agent:set_tools, and reads resume defaults back from stored message metadata. One-shot structured workers are a null_store agent with a declaration-only output tool, tool_choice forced, dispatch_tools=false. subagents/progress.lua renders per-tool-entry cards through the component handle's invalidate seam; turn_interrupt cancels live children, turn_end closes them. Spec suite rewritten against fakes of the new surfaces (98 cases), including gate/queue/cancel bounds, resume-default extraction, one-shot capture via unresolved tool calls, tool filtering, and manifest seeding. --- spec/test_run.lua | 332 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 spec/test_run.lua (limited to 'spec/test_run.lua') diff --git a/spec/test_run.lua b/spec/test_run.lua new file mode 100644 index 0000000..706faff --- /dev/null +++ b/spec/test_run.lua @@ -0,0 +1,332 @@ +-- subagents/run.lua and subagents/spawn.lua: what a child is built out of, and +-- what the model is told afterwards. +-- +-- Every case installs a fresh fake host, so `handle.spawns` holds exactly the +-- children this case produced: one record per child agent, carrying the +-- resolve_model arguments, the store directory, the system messages it was +-- seeded with, the tool declarations it was given and the run_async options. +-- Profile sets are built inline rather than discovered: precedence, not +-- discovery, is what these cases are about, and an explicit set also keeps the +-- machine's real ~/.config out of the run. + +local fake = require("spec.fake_ext") +local run = require("subagents.run") +local spawn = require("subagents.spawn") + +local function has(text, needle) + assert(type(text) == "string", "expected a string result, got " .. type(text)) + assert(text:find(needle, 1, true), "expected to find " .. needle .. " in:\n" .. tostring(text)) +end + +local function profile_set() + local reviewer = { + name = "reviewer", + description = "Reviews changes", + model = "anthropic:sonnet", + reasoning = "high", + body = "You are a reviewer.\n", + } + local scout = { name = "scout", description = "", body = "" } + return { + list = { reviewer, scout }, + by_name = { reviewer = reviewer, scout = scout }, + warnings = {}, + } +end + +-- with_host(fn, opts): install the fake, run fn(handle, profiles), restore. +local function with_host(fn, opts) + local handle = fake.install(opts) + local ok, err = pcall(fn, handle, profile_set()) + handle.restore() + if not ok then + error(err, 0) + end +end + +-- The child conversation a resumed reviewer loads: the manifest on its profile +-- system message, and the model/reasoning it last ran with on its last turn. +local function stored_reviewer() + return { + { role = "system", text = spawn.CHILD_ROLE }, + { + role = "system", + text = "You are a reviewer.\n", + metadata = { subagents = { owner = "0198-primary", agent = "reviewer" } }, + }, + { + role = "user", + text = "the first turn", + metadata = { subagents = { model = "openai:gpt-5.6", reasoning = "xhigh" } }, + }, + { role = "assistant", text = "first answer" }, + } +end + +return { + { "neither agent nor id is refused before anything is spawned", function() + with_host(function(handle, profiles) + local text = run.handle({ prompt = "do the thing" }, profiles) + has(text, "Error:") + has(text, "exactly one of `agent`") + assert(#handle.spawns == 0, "nothing may be spawned by a rejected call") + assert(not text:find("id:", 1, true), "a pre-allocation failure has no id") + end) + end }, + + { "both agent and id is refused", function() + with_host(function(handle, profiles) + local text = run.handle({ agent = "reviewer", id = "0198-x", prompt = "go" }, profiles) + has(text, "Error:") + has(text, "not both") + assert(#handle.spawns == 0) + end) + end }, + + { "an empty or missing prompt is refused", function() + with_host(function(handle, profiles) + has(run.handle({ agent = "reviewer", prompt = "" }, profiles), "`prompt` must be a non-empty string") + has(run.handle({ agent = "reviewer", prompt = " " }, profiles), "`prompt` must be a non-empty string") + has(run.handle({ agent = "reviewer" }, profiles), "`prompt` must be a non-empty string") + assert(#handle.spawns == 0) + end) + end }, + + { "an unknown agent names the known profiles", function() + with_host(function(handle, profiles) + local text = run.handle({ agent = "ghost", prompt = "go" }, profiles) + has(text, "unknown agent 'ghost'") + has(text, "reviewer, scout") + assert(#handle.spawns == 0) + end) + end }, + + { "a new child is seeded with the child-role and profile system messages", function() + with_host(function(handle, profiles) + run.handle({ agent = "reviewer", prompt = "Review the auth change." }, profiles) + assert(#handle.spawns == 1, "expected exactly one child") + local child = handle.spawns[1] + + assert(child.store_dir == handle.session.session_dir .. "/subagents/" .. handle.session.session_id, + "wrong child store dir: " .. tostring(child.store_dir)) + assert(child.session_id == nil, "a new child must not name a session") + assert(child.prompt == "Review the auth change.", tostring(child.prompt)) + + local messages = child.system_messages + assert(type(messages) == "table" and #messages == 2, "expected role + profile messages, saw " .. #messages) + assert(messages[1].text == spawn.CHILD_ROLE, "the first message is the fixed child role") + assert(messages[1].metadata == nil, "the role message carries no manifest") + assert(messages[2].text == "You are a reviewer.\n", "the profile body is the second message") + local manifest = messages[2].metadata.subagents + assert(manifest.owner == handle.session.session_id, tostring(manifest.owner)) + assert(manifest.agent == "reviewer", tostring(manifest.agent)) + end) + end }, + + { "the primary's system context comes first, then the role, then the profile", function() + with_host(function(handle, profiles) + run.handle({ agent = "reviewer", prompt = "go" }, profiles) + local messages = handle.spawns[1].system_messages + assert(#messages == 4, "expected two copied messages plus role and profile, saw " .. #messages) + assert(messages[1].text == "Project context.", tostring(messages[1].text)) + assert(messages[2].text == "House style.", tostring(messages[2].text)) + assert(messages[3].text == spawn.CHILD_ROLE, "the child role follows the primary's context") + assert(messages[4].text == "You are a reviewer.\n", "the profile body is last") + assert(messages[1].metadata == nil, "copied context carries no manifest") + end, { + primary_messages = { + { role = "system", text = "Project context." }, + { role = "user", text = "the parent dialogue is never copied" }, + { role = "assistant", text = "nor this" }, + { role = "system", text = "House style." }, + }, + }) + end }, + + { "the child store directory is created before the store is opened", function() + with_host(function(handle, profiles) + run.handle({ agent = "reviewer", prompt = "go" }, profiles) + local dir = handle.session.session_dir .. "/subagents/" .. handle.session.session_id + assert(handle.made_dir(dir), "the child catalog is created, not assumed: " .. + table.concat(handle.mkdirs, ", ")) + assert(handle.stores[1] == dir, "the store opens on that directory: " .. tostring(handle.stores[1])) + end) + end }, + + { "a child inherits the primary's tools except the subagents ones", function() + with_host(function(handle, profiles) + run.handle({ agent = "reviewer", prompt = "go" }, profiles) + local child = handle.spawns[1] + assert(table.concat(child.tools, ",") == "bash,read_file", + "expected only the non-subagents tools, saw " .. table.concat(child.tools, ",")) + for _, decl in ipairs(child.tool_decls) do + assert(decl._source == fake.SOURCE, "the re-registration tag must be passed through untouched") + end + end) + end }, + + { "the resolved model and reasoning are recorded on the turn", function() + with_host(function(handle, profiles) + run.handle({ agent = "reviewer", prompt = "go" }, profiles) + local metadata = handle.spawns[1].run.metadata + assert(type(metadata) == "table" and type(metadata.subagents) == "table", + "every child turn records what it ran on") + assert(metadata.subagents.model == "anthropic:sonnet", tostring(metadata.subagents.model)) + assert(metadata.subagents.reasoning == "high", tostring(metadata.subagents.reasoning)) + assert(handle.spawns[1].run.dispatch_tools ~= false, "an ordinary child dispatches its tools") + end) + end }, + + { "the child-role instruction states the contract the design fixes", function() + local role = spawn.CHILD_ROLE + has(role, "You are a subagent") + has(role, "self-contained report") + has(role, "verbatim") + has(role, "cannot ask the user questions") + end }, + + { "model and reasoning resolve tool over profile over inherited", function() + with_host(function(handle, profiles) + run.handle({ agent = "reviewer", prompt = "a" }, profiles) + assert(handle.spawns[1].model == "anthropic:sonnet", "the profile model applies") + assert(handle.spawns[1].reasoning == "high", "the profile reasoning applies") + + run.handle({ agent = "reviewer", prompt = "b", model = "openai:gpt-5.6", reasoning = "xhigh" }, profiles) + assert(handle.spawns[2].model == "openai:gpt-5.6", "the call overrides the profile") + assert(handle.spawns[2].reasoning == "xhigh", "the call overrides the profile") + + run.handle({ agent = "reviewer", prompt = "d", reasoning = "low" }, profiles) + assert(handle.spawns[3].model == "anthropic:sonnet", "model and reasoning resolve independently") + assert(handle.spawns[3].reasoning == "low") + end) + end }, + + { "a profile that names neither inherits the primary's pair", function() + with_host(function(handle, profiles) + run.handle({ agent = "scout", prompt = "look around" }, profiles) + assert(handle.spawns[1].model == "openai:gpt-5.6", tostring(handle.spawns[1].model)) + assert(handle.spawns[1].reasoning == "low", tostring(handle.spawns[1].reasoning)) + end, { session = { model = "openai:gpt-5.6", reasoning = "low" } }) + end }, + + { "a profile with an empty body contributes no system message", function() + with_host(function(handle, profiles) + run.handle({ agent = "scout", prompt = "look around" }, profiles) + local messages = handle.spawns[1].system_messages + assert(#messages == 1, "expected only the child-role message, saw " .. #messages) + assert(messages[1].text == spawn.CHILD_ROLE) + end) + end }, + + { "a resume loads the stored conversation and seeds nothing", function() + with_host(function(handle, profiles) + handle.add_session("0198-child", stored_reviewer()) + handle.queue({ output = "second turn" }) + + local text = run.handle({ id = "0198-child", prompt = "now the tests" }, profiles) + local child = handle.spawns[1] + assert(child.session_id == "0198-child", tostring(child.session_id)) + assert(child.resumed, "the agent is built on the resolved session") + assert(#child.system_messages == 0, "the stored conversation is canonical on resume") + assert(child.store_dir == handle.session.session_dir .. "/subagents/" .. handle.session.session_id) + has(text, "id: 0198-child") + has(text, "second turn") + end) + end }, + + { "a resumed child keeps the model and reasoning of its last turn", function() + with_host(function(handle, profiles) + handle.add_session("0198-child", stored_reviewer()) + handle.queue({}) + run.handle({ id = "0198-child", prompt = "carry on" }, profiles) + assert(handle.spawns[1].model == "openai:gpt-5.6", tostring(handle.spawns[1].model)) + assert(handle.spawns[1].reasoning == "xhigh", tostring(handle.spawns[1].reasoning)) + end) + end }, + + { "a per-turn override beats the stored default and only where it is given", function() + with_host(function(handle, profiles) + handle.add_session("0198-child", stored_reviewer()) + handle.queue({}) + run.handle({ id = "0198-child", prompt = "carry on", reasoning = "high" }, profiles) + assert(handle.spawns[1].reasoning == "high", "the call overrides the stored default") + assert(handle.spawns[1].model == "openai:gpt-5.6", "the model keeps its last effective value") + end) + end }, + + { "a resumed child takes its agent name from the manifest", function() + with_host(function(handle, profiles) + handle.add_session("0198-child", stored_reviewer()) + handle.queue({ output = "done" }) + local text = run.handle({ id = "0198-child", prompt = "carry on" }, profiles) + has(text, "agent: reviewer") + end) + end }, + + { "an id this session never started is refused before anything is built", function() + with_host(function(handle, profiles) + local text = run.handle({ id = "0198-nope", prompt = "carry on" }, profiles) + has(text, "Error:") + has(text, "unknown subagent id '0198-nope'") + assert(#handle.spawns == 0, "no child is built for an id that does not resolve") + assert(#handle.runs == 0, "and no turn is started") + end) + end }, + + { "a completed result renders every field", function() + with_host(function(handle, profiles) + handle.queue({ id = "0198-abc", status = "completed", output = "Found two issues." }) + local text = run.handle({ agent = "reviewer", prompt = "review" }, profiles) + assert(text == table.concat({ + "id: 0198-abc", + "agent: reviewer", + "status: completed", + "resumable: true", + "--- output ---", + "Found two issues.", + }, "\n"), "unexpected block:\n" .. text) + end) + end }, + + { "a failed first turn reports the error and is not resumable", function() + with_host(function(handle, profiles) + handle.queue({ id = "0198-def", status = "failed", error = "provider refused", resumable = false }) + local text = run.handle({ agent = "reviewer", prompt = "review" }, profiles) + has(text, "status: failed") + has(text, "resumable: false") + has(text, "provider refused") + end) + end }, + + { "resumability is read back from the store after the turn settles", function() + with_host(function(handle, profiles) + handle.queue({ id = "0198-ghi", output = "wrote something" }) + has(run.handle({ agent = "reviewer", prompt = "review" }, profiles), "resumable: true") + assert(handle.sessions["0198-ghi"], "a durable child leaves a session behind") + + handle.queue({ id = "0198-jkl", output = "died young", resumable = false }) + has(run.handle({ agent = "reviewer", prompt = "review" }, profiles), "resumable: false") + assert(handle.sessions["0198-jkl"] == nil, "no file, no continuation") + end) + end }, + + { "a cancelled child settles as cancelled", function() + with_host(function(handle, profiles) + handle.queue({ id = "0198-ghi", status = "cancelled", error = "cancelled by the user" }) + local text = run.handle({ agent = "reviewer", prompt = "review" }, profiles) + has(text, "status: cancelled") + has(text, "cancelled by the user") + end) + end }, + + { "a model the host cannot resolve is reported and starts nothing", function() + with_host(function(handle, profiles) + local text = run.handle({ agent = "reviewer", prompt = "review", model = "openai:ghost" }, profiles) + assert(text == "Error: resolve_model: unknown model 'openai:ghost'", text) + assert(#handle.resolves == 1, "the host decides what a model reference means, not the rock") + assert(handle.resolves[1].model == "openai:ghost", tostring(handle.resolves[1].model)) + assert(#handle.spawns == 0, "the child is never built") + assert(#handle.runs == 0, "a rejected child is never started") + end, { unknown_models = { ["openai:ghost"] = true } }) + end }, +} -- cgit v1.3