summaryrefslogtreecommitdiff
path: root/spec/test_run.lua
diff options
context:
space:
mode:
Diffstat (limited to 'spec/test_run.lua')
-rw-r--r--spec/test_run.lua332
1 files changed, 332 insertions, 0 deletions
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 },
+}