diff options
Diffstat (limited to 'spec/test_workflow.lua')
| -rw-r--r-- | spec/test_workflow.lua | 299 |
1 files changed, 299 insertions, 0 deletions
diff --git a/spec/test_workflow.lua b/spec/test_workflow.lua new file mode 100644 index 0000000..c5ba06c --- /dev/null +++ b/spec/test_workflow.lua @@ -0,0 +1,299 @@ +-- subagents/workflow.lua: the callback API's awaiting, failure-as-value rule, +-- structured workers, the concurrency gate, and the job budget. +-- +-- The fake host settles jobs in the order each outcome's `settle` key asks for, +-- which is what makes the ordering cases meaningful: input order and settle +-- order are deliberately different everywhere below. + +local fake = require("spec.fake_ext") +local workflow = require("subagents.workflow") + +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 + +local function profile_set() + local set = { list = {}, by_name = {}, warnings = {} } + for _, name in ipairs({ "alpha", "beta", "gamma" }) do + local profile = { name = name, description = name, body = "You are " .. name .. ".\n" } + set.list[#set.list + 1] = profile + set.by_name[name] = profile + end + return set +end + +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 + +local function three_children(ctx) + local handles = {} + for index, name in ipairs({ "alpha", "beta", "gamma" }) do + handles[index] = ctx:agent({ agent = name, prompt = "work on " .. name }) + end + return handles +end + +local function json_available() + return pcall(require, "dkjson") +end + +local ITEMS_SCHEMA = { + type = "object", + required = { "items" }, + properties = { + items = { type = "array", items = { type = "string" } }, + }, +} + +local GHOST = { unknown_models = { ["openai:ghost"] = true } } + +return { + { "await all returns results in input order, not settle order", function() + with_host(function(handle, profiles) + handle.queue_for("alpha", { output = "A", settle = 3 }) + handle.queue_for("beta", { output = "B", settle = 1 }) + handle.queue_for("gamma", { output = "C", settle = 2 }) + + local results = workflow.execute(workflow.workflow(function(ctx) + return ctx:await(three_children(ctx), "all") + end), "input", { profiles = profiles }) + + assert(#results == 3, "expected three results") + assert(results[1].output == "A", tostring(results[1].output)) + assert(results[2].output == "B", tostring(results[2].output)) + assert(results[3].output == "C", tostring(results[3].output)) + assert(#handle.spawns == 3, "one child per ctx:agent") + assert(handle.spawns[1].prompt == "work on alpha") + end) + end }, + + { "await first returns the earliest settler and the remaining handles", function() + with_host(function(handle, profiles) + handle.queue_for("alpha", { output = "A", settle = 3 }) + handle.queue_for("beta", { output = "B", settle = 1 }) + handle.queue_for("gamma", { output = "C", settle = 2 }) + + local order = workflow.execute(workflow.workflow(function(ctx) + local handles = three_children(ctx) + local seen = {} + while #handles > 0 do + local result, remaining = ctx:await(handles, "first") + seen[#seen + 1] = result.output + assert(#remaining == #handles - 1, "one handle settles per await") + handles = remaining + end + return seen + end), "input", { profiles = profiles }) + + assert(table.concat(order, ",") == "B,C,A", table.concat(order, ",")) + end) + end }, + + { "handle:await settles one child", function() + with_host(function(handle, profiles) + handle.queue_for("alpha", { output = "just me" }) + local result = workflow.execute(workflow.workflow(function(ctx) + return ctx:agent({ agent = "alpha", prompt = "go" }):await() + end), "input", { profiles = profiles }) + assert(result.status == "completed", tostring(result.status)) + assert(result.output == "just me", tostring(result.output)) + end) + end }, + + { "the gate keeps four children in flight and queues the rest", function() + with_host(function(handle, profiles) + local results = workflow.execute(workflow.workflow(function(ctx) + local handles = {} + for index = 1, 5 do + handles[index] = ctx:agent({ agent = "alpha", prompt = "task " .. index }) + end + assert(#handle.runs == 4, "four turns run at once, saw " .. #handle.runs) + return ctx:await(handles, "all") + end), "input", { profiles = profiles }) + + assert(#results == 5, "every child reports") + assert(#handle.runs == 5, "the queued child starts once a slot frees up") + assert(handle.max_live == 4, "never more than four at once, peaked at " .. handle.max_live) + end) + end }, + + { "a rejected child is a failed result, not an error", function() + with_host(function(handle, profiles) + handle.queue_for("beta", { output = "B" }) + + local results = workflow.execute(workflow.workflow(function(ctx) + local first = ctx:agent({ agent = "alpha", model = "openai:ghost", prompt = "a" }) + local second = ctx:agent({ agent = "beta", prompt = "b" }) + return ctx:await({ first, second }, "all") + end), "input", { profiles = profiles }) + + assert(results[1].status == "failed", tostring(results[1].status)) + has(results[1].error, "unknown model 'openai:ghost'") + assert(results[1].resumable == false, "a child that never allocated is not resumable") + assert(results[2].status == "completed", "a sibling failure must not disturb this one") + assert(#handle.runs == 1, "only the sibling was ever started") + end, GHOST) + end }, + + { "a failed child is reported as a value", function() + with_host(function(handle, profiles) + handle.queue_for("alpha", { status = "failed", error = "provider refused", resumable = false }) + local result = workflow.execute(workflow.workflow(function(ctx) + return ctx:agent({ agent = "alpha", prompt = "a" }):await() + end), "input", { profiles = profiles }) + assert(result.status == "failed") + assert(result.error == "provider refused", tostring(result.error)) + end) + end }, + + { "an id with a turn already in flight is refused", function() + with_host(function(handle, profiles) + handle.add_session("0198-child", { + { role = "system", text = "You are alpha.\n", + metadata = { subagents = { owner = "0198-primary", agent = "alpha" } } }, + }) + handle.queue({ output = "the first turn wins" }) + + local results = workflow.execute(workflow.workflow(function(ctx) + local first = ctx:agent({ id = "0198-child", prompt = "a" }) + local second = ctx:agent({ id = "0198-child", prompt = "b" }) + return ctx:await({ first, second }, "all") + end), "input", { profiles = profiles }) + + assert(results[1].status == "completed", tostring(results[1].error)) + assert(results[2].status == "failed", "one turn per child at a time") + has(results[2].error, "already has a turn in flight") + assert(#handle.runs == 1, "the second call never starts a turn") + end) + end }, + + { "a structured worker decodes and validates its output", function() + if not json_available() then + return "skip", "dkjson is not installed" + end + with_host(function(handle, profiles) + handle.queue_for("alpha", { structured_json = '{"items":["x","y"]}' }) + local result = workflow.execute(workflow.workflow(function(ctx) + return ctx:agent({ + agent = "alpha", + prompt = "split it", + output = { description = "Return the work items.", schema = ITEMS_SCHEMA }, + }):await() + end), "input", { profiles = profiles }) + + assert(result.status == "completed", tostring(result.error)) + assert(type(result.output) == "table", "structured output decodes to a table") + assert(result.output.items[1] == "x" and result.output.items[2] == "y") + assert(result.resumable == false, "a one-shot child is not resumable") + assert(result.id == nil, "and has no durable session to name") + assert(#handle.stores == 0, "a structured worker never touches the child catalog") + + local child = handle.spawns[1] + assert(type(child.tool_choice) == "table" and child.tool_choice.name == "emit_result", + "the output tool is the only choice the child has") + assert(child.run.dispatch_tools == false, "a one-shot child never dispatches a tool call") + assert(#child.tool_decls == 1, "the output tool replaces the inherited set, saw " .. + table.concat(child.tools, ",")) + + local decl = child.tool_decls[1] + assert(decl.name == "emit_result", "the synthetic tool is named for the host") + assert(decl.description == "Return the work items.") + assert(decl.schema == ITEMS_SCHEMA, "the schema is passed through untouched") + assert(decl._source == nil and decl._ctx == nil and decl._vt == nil, + "the output tool is declaration-only: nothing may dispatch it") + end) + end }, + + { "structured output that violates the schema fails the result", function() + if not json_available() then + return "skip", "dkjson is not installed" + end + with_host(function(handle, profiles) + handle.queue_for("alpha", { structured_json = '{"nope":1}' }) + local result = workflow.execute(workflow.workflow(function(ctx) + return ctx:agent({ + agent = "alpha", + prompt = "split it", + output = { schema = ITEMS_SCHEMA }, + }):await() + end), "input", { profiles = profiles }) + + assert(result.status == "failed", "a schema violation is never a success") + has(result.error, "structured output failed validation") + assert(result.output == nil, "invalid output is not handed to the caller") + end) + end }, + + { "a structured worker that answers in prose fails", function() + with_host(function(handle, profiles) + handle.queue_for("alpha", { output = "prose, not a tool call" }) + local result = workflow.execute(workflow.workflow(function(ctx) + return ctx:agent({ + agent = "alpha", + prompt = "split it", + output = { schema = ITEMS_SCHEMA }, + }):await() + end), "input", { profiles = profiles }) + assert(result.status == "failed", tostring(result.status)) + has(result.error, "did not call the required 'emit_result' output tool") + end) + end }, + + { "an already settled handle is served without polling again", function() + with_host(function(handle, profiles) + handle.queue_for("beta", { output = "B" }) + handle.queue_for("gamma", { output = "C" }) + + local first_status = workflow.execute(workflow.workflow(function(ctx) + local rejected = ctx:agent({ agent = "alpha", model = "openai:ghost", prompt = "a" }) + local live = { rejected, ctx:agent({ agent = "beta", prompt = "b" }) } + local result, remaining = ctx:await(live, "first") + assert(#remaining == 1, "the pending sibling stays outstanding") + assert(handle.polls == 0, "a cached result must not reach the job machinery") + return result.status + end), "input", { profiles = profiles }) + + assert(first_status == "failed", tostring(first_status)) + assert(#handle.runs == 1, "only the live sibling was ever started") + end, GHOST) + end }, + + { "max_jobs caps how many children one workflow starts", function() + with_host(function(handle, profiles) + local ok, err = pcall(workflow.execute, workflow.workflow(function(ctx) + three_children(ctx) + end), "input", { profiles = profiles, max_jobs = 2 }) + assert(not ok, "the third ctx:agent must be refused") + has(tostring(err), "job limit exceeded") + assert(#handle.runs == 2, "the capped call never reaches the host") + end) + end }, + + { "handles the callback never awaited are settled before returning", function() + with_host(function(handle, profiles) + workflow.execute(workflow.workflow(function(ctx) + ctx:agent({ agent = "alpha", prompt = "orphan" }) + return "done" + end), "input", { profiles = profiles }) + assert(#handle.jobs == 1, "one child ran") + assert(handle.jobs[1]._settled, "no child is left running") + end) + end }, + + { "an unknown agent inside a workflow is a workflow error", function() + with_host(function(handle, profiles) + local ok, err = pcall(workflow.execute, workflow.workflow(function(ctx) + ctx:agent({ agent = "ghost", prompt = "go" }) + end), "input", { profiles = profiles }) + assert(not ok, "an unknown profile is a programmer error, not a child failure") + has(tostring(err), "unknown agent 'ghost'") + end) + end }, +} |
