diff options
Diffstat (limited to 'spec')
| -rw-r--r-- | spec/fake_ext.lua | 82 | ||||
| -rw-r--r-- | spec/test_init.lua | 57 | ||||
| -rw-r--r-- | spec/test_luatool.lua | 69 | ||||
| -rw-r--r-- | spec/test_progress.lua | 322 | ||||
| -rw-r--r-- | spec/test_progress_replay.lua | 316 | ||||
| -rw-r--r-- | spec/test_run.lua | 48 | ||||
| -rw-r--r-- | spec/test_toml_workflows.lua | 29 |
7 files changed, 916 insertions, 7 deletions
diff --git a/spec/fake_ext.lua b/spec/fake_ext.lua index dfdfdb3..5207e03 100644 --- a/spec/fake_ext.lua +++ b/spec/fake_ext.lua @@ -17,7 +17,8 @@ -- panto.agent{ config =, store =, session_id =, conversation = } -> agent -- panto.file_system_jsonl_store{ dir = } / panto.null_store() -- agent:conversation() / :session_id() / :tools() / :set_tools(decls) / :run_async(opts) --- store:resolve(id) / :load(id) +-- store:list() / :list_bounded(opts) / :resolve(id) / :load(id) / +-- :load_messages(id, opts) -- conv:messages() / :message_metadata(i) / :add_system_message(text, { metadata = }) -- job:next_event() / :result() / :request_cancel() / :close() -- @@ -189,6 +190,10 @@ function conv_mt:messages() return out end +function conv_mt:len() + return #self._messages +end + function conv_mt:message_metadata(index) local message = self._messages[tonumber(index) or 0] if message == nil then @@ -239,7 +244,75 @@ function store_mt:resolve(id) return { id = id, message_count = #session, api_style = "messages" } end +function store_mt:list() + if self._harness.reject_unbounded_replay then error("unbounded list was used", 2) end + local ids = {} + for id in pairs(self._harness.sessions) do + ids[#ids + 1] = id + end + table.sort(ids) + local out = {} + for _, id in ipairs(ids) do + out[#out + 1] = { + id = id, + created = "", + modified = "", + message_count = #self._harness.sessions[id], + model = "", + } + end + return out +end + +function store_mt:list_bounded(opts) + local limit = type(opts) == "table" and math.max(0, math.floor(tonumber(opts.limit) or 0)) or 0 + self._harness.bounded_list_calls = self._harness.bounded_list_calls + 1 + local ids = {} + for id in pairs(self._harness.sessions) do ids[#ids + 1] = id end + table.sort(ids) + local out = {} + for index = 1, math.min(limit, #ids) do + local id = ids[index] + out[#out + 1] = { + id = id, + created = "", + modified = "", + message_count = #self._harness.sessions[id], + model = "", + } + end + return out +end + +local function metadata_has_key(metadata, key) + return type(metadata) == "table" and metadata[key] ~= nil +end + +function store_mt:load_messages(id, opts) + self._harness.bounded_load_calls = self._harness.bounded_load_calls + 1 + local source = self._harness.sessions[id] + if type(source) ~= "table" then return nil end + opts = type(opts) == "table" and opts or {} + local selected = {} + for index, message in ipairs(source) do + local role_ok = opts.role == nil or message.role == opts.role + local metadata_ok = opts.metadata_key == nil or metadata_has_key(message.metadata, opts.metadata_key) + if role_ok and metadata_ok then selected[#selected + 1] = index end + end + local indices = {} + if opts.from_end then + local first = math.max(1, #selected - (tonumber(opts.limit) or 0) + 1) + for index = first, #selected do indices[#indices + 1] = selected[index] end + else + for index = 1, math.min(#selected, tonumber(opts.limit) or 0) do indices[#indices + 1] = selected[index] end + end + local messages = {} + for _, index in ipairs(indices) do messages[#messages + 1] = source[index] end + return new_conversation(messages) +end + function store_mt:load(id) + if self._harness.reject_unbounded_replay then error("unbounded load was used", 2) end local session = self._harness.sessions[id] if session == nil then return nil @@ -415,6 +488,11 @@ function agent_mt:conversation() return self._conv end +function agent_mt:set_message_metadata(index, metadata) + self._record.final_metadata = metadata + return true +end + function agent_mt:session_id() return child_id(self._harness, self._record) end @@ -538,6 +616,8 @@ function M.install(opts) polls = 0, live = 0, max_live = 0, + bounded_list_calls = 0, + bounded_load_calls = 0, } function handle.queue(outcome) diff --git a/spec/test_init.lua b/spec/test_init.lua index 338b7a7..2d5e7d1 100644 --- a/spec/test_init.lua +++ b/spec/test_init.lua @@ -58,10 +58,16 @@ return { end local tmp = assert(uv.fs_mkdtemp("/tmp/panto-subagents-init-XXXXXX")) - assert(os.execute("mkdir -p " .. tmp .. "/agents")) + assert(os.execute("mkdir -p " .. tmp .. "/agents " .. tmp .. "/workflows")) local file = assert(io.open(tmp .. "/agents/reviewer.md", "w")) file:write("---\ndescription: Reviews changes\n---\nYou are a reviewer.\n") file:close() + file = assert(io.open(tmp .. "/workflows/review-chain.toml", "w")) + file:write('name = "review-chain"\n[[steps]]\nid = "review"\nagent = "reviewer"\nprompt = "Review it."\n') + file:close() + file = assert(io.open(tmp .. "/workflows/unusable.toml", "w")) + file:write('name = "unusable"\nsteps = []\n') + file:close() local original_roots = paths.config_roots paths.config_roots = function(kind) @@ -70,6 +76,17 @@ return { local handle = fake.install() local ok, err = pcall(entry.activate) + local header + if ok then + handle.emit("session_start", { + get_component = function() + return { render = function() return { "Panto", "" } end } + end, + set_component = function(_, component) + header = component + end, + }) + end paths.config_roots = original_roots handle.restore() @@ -91,8 +108,28 @@ return { assert(type(run_tool.handler) == "function") assert(handle.tools_by_name["subagents.lua"].schema.properties.source, "the lua tool takes source") + local inline_agents = handle.tools_by_name["subagents.lua"].schema.properties.agents + assert(inline_agents and inline_agents.items.required, + "the lua tool describes workflow-local agent profiles") + assert(inline_agents.items.properties.system_prompt, + "an inline profile carries its system prompt") assert(handle.tools_by_name["subagents.workflow"].schema.properties.steps.items.required, "the workflow tool describes its step shape") + + assert(type(header) == "table" and type(header.render) == "function", + "activation wraps the session header") + local rendered = header:render(18) + local plain = table.concat(rendered, "\n"):gsub("\27%[[%d;]*m", "") + has(plain, "Panto") + has(plain, "subagents:") + has(plain, "reviewer") + has(plain, "workflows:") + has(plain, "review-chain") + assert(not plain:find("unusable", 1, true), "invalid workflows stay out of the usable inventory") + assert(handle.commands_by_name["workflow:unusable"] == nil, + "the header inventory matches command registration") + assert(rendered[#rendered] == "", "annotations stay before the trailing blank") + assert(#rendered > 4, "inventories wrap at the component width") end }, { "an interrupted turn cancels every live child, and its end closes them", function() @@ -100,6 +137,8 @@ return { 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_start"]) == "function", + "startup replay must end before the first live turn") assert(type(handle.on_by_name["turn_end"]) == "function", "a finished turn must be able to close its children") @@ -132,19 +171,27 @@ return { assert(type(handle.on_by_name["tool_call_complete"]) == "function", "children report progress through the tool call that started them") - local claimed + assert(type(handle.on_by_name["tool_result"]) == "function", + "the result restores the component's transcript position") + + local claimed, pins = nil, {} 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 } + return { + invalidate = function() end, + alive = function() return true end, + set_pinned = function(_, value) pins[#pins + 1] = value end, + } end, }) assert(type(claimed) == "table" and type(claimed.render) == "function", "the entry is given a component that renders the cards") + assert(pins[1] == true, "the progress component pins after claim") + handle.emit("tool_result", { id = "call-1", tool_name = "subagents.run" }) + assert(pins[2] == false, "the matching result unpins it") local foreign handle.emit("tool_call_complete", { diff --git a/spec/test_luatool.lua b/spec/test_luatool.lua index 83b4984..65723db 100644 --- a/spec/test_luatool.lua +++ b/spec/test_luatool.lua @@ -11,6 +11,7 @@ local fake = require("spec.fake_ext") local luatool = require("subagents.luatool") +local progress = require("subagents.progress") local function has(text, needle) assert(type(text) == "string", "expected a string, got " .. type(text)) @@ -127,6 +128,74 @@ return { end) end }, + { "inline agent profiles are scoped to one workflow invocation", function() + with_host(function(handle, profiles) + handle.queue_for("local-reviewer", { id = "0198-local", output = "reviewed" }) + local source = [[ + return subagents.workflow(function(ctx, input) + return ctx:agent({ agent = "local-reviewer", prompt = input }):await() + end) + ]] + progress.reset() + local component + progress.claim({ + id = "lua-call", + tool_name = "subagents.lua", + collapsed = true, + set_component = function(_, value) + component = value + return { + invalidate = function() end, + alive = function() return true end, + set_pinned = function() end, + } + end, + }) + progress.bind({ tool_call_id = "lua-call" }) + local text = luatool.handle({ + prompt = "inspect this", + source = source, + agents = { + { + name = "local-reviewer", + description = "One-off reviewer", + system_prompt = "Review only the requested change.", + }, + }, + }, profiles) + + has(text, "reviewed") + local compact = table.concat(component:render(100), "\n") + assert(not compact:find("inspect this", 1, true), compact) + assert(not compact:find("Review only the requested change.", 1, true), compact) + progress.collapse({ collapsed = false }) + local expanded = table.concat(component:render(100), "\n") + has(expanded, "system prompt: Review only the requested change.") + has(expanded, "prompt: inspect this") + assert(#handle.spawns == 1, "the inline profile started one child") + assert(handle.spawns[1].label == "local-reviewer") + local seeded = handle.spawns[1].system_messages + assert(seeded[#seeded].text == "Review only the requested change.") + + local missing = luatool.handle({ prompt = "again", source = source }, profiles) + has(missing, "unknown agent 'local-reviewer'") + assert(#handle.spawns == 1, "the inline profile did not leak into the next workflow") + progress.reset() + end) + end }, + + { "invalid inline profiles fail before running guest source", function() + with_host(function(handle, profiles) + local text = luatool.handle({ + prompt = "x", + source = "error('guest source should not run')", + agents = { { name = "local", system_prompt = "" } }, + }, profiles) + has(text, "agents[1].system_prompt must be a non-empty string") + assert(#handle.spawns == 0) + end) + end }, + { "a fan-out runs end to end and renders one block per child", function() with_host(function(handle, profiles) handle.queue_for("alpha", { id = "0198-a", output = "alpha says hi" }) diff --git a/spec/test_progress.lua b/spec/test_progress.lua new file mode 100644 index 0000000..06509bf --- /dev/null +++ b/spec/test_progress.lua @@ -0,0 +1,322 @@ +local progress = require("subagents.progress") + +local function board(id, collapsed) + local component + progress.claim({ + id = id, + tool_name = "subagents.run", + collapsed = collapsed, + set_component = function(_, value) + component = value + return { invalidate = function() end, alive = function() return true end } + end, + }) + progress.bind({ tool_call_id = id }) + return component +end + +local function plain(lines) + return table.concat(lines, "\n"):gsub("\27%[[%d;]*m", "") +end + +return { + { "concurrent boards pin independently until their matching result", function() + progress.reset() + local pins = { a = {}, b = {} } + local function claim(id) + progress.claim({ + id = id, + tool_name = "subagents.workflow", + set_component = function() + return { + alive = function() return true end, + invalidate = function() end, + set_pinned = function(_, value) + pins[id][#pins[id] + 1] = value + end, + } + end, + }) + end + claim("a") + claim("b") + assert(pins.a[1] == true and pins.b[1] == true, "each invocation pins its own handle") + progress.settle({ id = "a" }) + assert(pins.a[2] == false, "the matching result restores transcript order") + assert(#pins.b == 1, "another in-flight workflow remains pinned") + progress.settle({ id = "missing" }) + assert(#pins.b == 1, "an unrelated or stale result is inert") + progress.reset() + assert(pins.b[2] == false, "reset safely releases unresolved boards") + end }, + + { "a pinned board leaves one blank line before the waiting indicator", function() + progress.reset() + local component, pins + progress.claim({ + id = "spacing-call", + tool_name = "subagents.workflow", + collapsed = true, + set_component = function(_, value) + component = value + pins = {} + return { + alive = function() return true end, + invalidate = function() end, + set_pinned = function(_, value) pins[#pins + 1] = value end, + } + end, + }) + progress.bind({ tool_call_id = "spacing-call" }) + local card = progress.card("worker", "child") + card:event({ type = "block_start", block_type = "text", index = 1 }) + card:event({ type = "content_delta", index = 1, delta = "final line" }) + + local compact = component:render(80) + assert(pins[1] == true, "the board pins before it renders") + assert(compact[#compact] == "", "pinned compact boards end with a blank line") + assert(compact[#compact - 1]:find("final line", 1, true), "the gap follows the final compact line") + + progress.collapse({ collapsed = false }) + local expanded = component:render(80) + assert(expanded[#expanded] == "", "pinned expanded boards end with a blank line") + assert(expanded[#expanded - 1]:find("final line", 1, true), "the gap follows the final expanded line") + + progress.settle({ id = "spacing-call" }) + local historical = component:render(80) + assert(historical[#historical] ~= "", "settled boards do not keep the spacing line") + assert(pins[2] == false, "settling unpins the board") + progress.reset() + end }, + + { "failed pin transitions do not change rendered spacing state", function() + progress.reset() + local component, pins, fail_unpin = nil, {}, true + progress.claim({ + id = "spacing-lifecycle", + tool_name = "subagents.run", + set_component = function(_, value) + component = value + return { + alive = function() return true end, + invalidate = function() end, + set_pinned = function(_, value) + pins[#pins + 1] = value + if value == false and fail_unpin then error("cannot unpin") end + end, + } + end, + }) + progress.bind({ tool_call_id = "spacing-lifecycle" }) + local card = progress.card("worker", "child") + card:event({ type = "block_start", block_type = "text", index = 1 }) + card:event({ type = "content_delta", index = 1, delta = "final line" }) + local active = component:render(80) + assert(active[#active] == "", "a successful pin enables the gap") + + progress.settle({ id = "spacing-lifecycle" }) + local still_pinned = component:render(80) + assert(still_pinned[#still_pinned] == "", "a failed unpin keeps the board active") + fail_unpin = false + progress.settle({ id = "spacing-lifecycle" }) + local unpinned = component:render(80) + assert(unpinned[#unpinned] ~= "", "a successful unpin removes the gap") + assert(#pins == 3 and pins[1] == true and pins[2] == false and pins[3] == false, + "unpin retries only after the failed lifecycle transition") + progress.reset() + end }, + + { "concurrent UUIDv7 cards across boards show distinguishable id prefixes", function() + progress.reset() + local first_board = board("call-1", true) + local first = "0198aaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa" + progress.card("worker", first) + local initial_line = first_board:render(120)[2] + assert(initial_line:find("0198aaaa…", 1, true), initial_line) + + local second_board = board("call-2", true) + local second = "0198aaaa-aaaa-7aab-8aaa-aaaaaaaaaaaa" + progress.card("worker", second) + + local first_line = first_board:render(120)[2] + local second_line = second_board:render(120)[2] + assert(first_line ~= second_line, "distinct session ids must not render identical card headers") + assert(first_line:find("0198aaaa%-aaaa%-7aaa"), first_line) + assert(second_line:find("0198aaaa%-aaaa%-7aab"), second_line) + progress.reset() + end }, + + { "Ctrl+O state expands and recollapses every existing board", function() + progress.reset() + local first = board("call-a", true) + local a = progress.card("alpha", "a") + for i = 1, 8 do + a:event({ type = "block_start", block_type = "text", index = i }) + a:event({ type = "content_delta", index = i, delta = "line-" .. i .. "\n" }) + a:event({ type = "block_complete", block_type = "text", index = i, text = "line-" .. i }) + end + local second = board("call-b", true) + local b = progress.card("beta", "b") + for i = 1, 8 do b:event({ type = "content_delta", index = 99, delta = "ignored" }) end + for i = 1, 8 do + b:event({ type = "block_start", block_type = "text", index = i }) + b:event({ type = "content_delta", index = i, delta = "beta-" .. i .. "\n" }) + end + + assert(#first:render(80) == 6, "blank + header + at most four recent lines") + assert(#second:render(80) == 6, "each board is independently collapsed") + progress.collapse({ collapsed = false }) + assert(#first:render(80) == 10, "expanded board should retain all eight lines") + assert(#second:render(80) == 10, "global state should expand concurrent boards") + progress.collapse({ collapsed = true }) + assert(#first:render(80) == 6 and #second:render(80) == 6) + progress.reset() + end }, + + { "settled historical boards keep responding to Ctrl+O until their handles die", function() + progress.reset() + local alive = { a = true, b = true } + local invalidations = { a = 0, b = 0 } + local components = {} + for _, id in ipairs({ "a", "b" }) do + progress.claim({ + id = id, + tool_name = "subagents.run", + collapsed = true, + set_component = function(_, component) + components[id] = component + return { + alive = function() return alive[id] end, + invalidate = function() invalidations[id] = invalidations[id] + 1 end, + set_pinned = function() end, + } + end, + }) + progress.bind({ tool_call_id = id }) + local card = progress.card(id, id) + for i = 1, 6 do card:event({ type = "block_start", block_type = "text", index = i }) + card:event({ type = "content_delta", index = i, delta = id .. i .. "\n" }) end + progress.settle({ id = id }) + end + + progress.reset() -- turn_end clears coroutine bindings, not historical components + progress.collapse({ collapsed = false }) + assert(#components.a:render(80) == 8 and #components.b:render(80) == 8, + "settled concurrent boards should both expand after turn_end") + alive.a = false + local a_invalidations = invalidations.a + progress.collapse({ collapsed = true }) + assert(invalidations.a == a_invalidations, "dead historical handles should be pruned") + assert(#components.b:render(80) == 6, "a live concurrent board should remain isolated") + alive.b = false + progress.reset() + end }, + + { "prompts are presentation-only in expanded mode and bounded", function() + progress.reset() + local component = board("call-prompts", true) + local card = progress.card("inline", "child", nil, { + prompt = "inspect this\nthen summarize", + system_prompt = "INLINE-SYSTEM " .. string.rep("x", 64 * 1024), + }) + card:event({ type = "block_start", block_type = "text", index = 1 }) + card:event({ type = "content_delta", index = 1, delta = "assistant line\n" }) + + local compact = plain(component:render(100)) + assert(compact:find("assistant line", 1, true), compact) + assert(not compact:find("inspect this", 1, true), compact) + assert(not compact:find("INLINE-SYSTEM", 1, true), compact) + + progress.collapse({ collapsed = false }) + local expanded = plain(component:render(100)) + assert(expanded:find("system prompt: INLINE-SYSTEM", 1, true), expanded) + assert(expanded:find("prompt: inspect this", 1, true), expanded) + assert(expanded:find("then summarize", 1, true), expanded) + assert(#card.system_prompt == 32 * 1024, "presentation metadata must be bounded") + assert(#card.history == 1, "presentation metadata must not enter accumulated output history") + progress.reset() + end }, + + { "expanded history includes streamed assistant text and available tool call fields", function() + progress.reset() + local component = board("call-tools", false) + local card = progress.card("worker", "child") + card:event({ type = "block_start", block_type = "text", index = 0 }) + card:event({ type = "content_delta", index = 0, delta = "assistant output" }) + card:event({ type = "block_complete", block_type = "text", index = 0, text = "assistant output" }) + card:event({ type = "block_start", block_type = "tool_use", index = 1 }) + card:event({ type = "tool_details", index = 1, id = "tool-7", name = "std.echo" }) + card:event({ type = "block_complete", block_type = "tool_use", index = 1, + id = "tool-7", name = "std.echo", text = '{"text":"hello"}' }) + card:event({ type = "tool_dispatch_result", tool_results = { + { tool_use_id = "tool-7", output = "hello\nworld", is_error = false }, + { tool_use_id = "tool-8", output = "boom", is_error = true }, + } }) + + local text = plain(component:render(80)) + assert(text:find("assistant output", 1, true), text) + assert(text:find("std.echo [tool-7]", 1, true), text) + assert(text:find('input: {"text":"hello"}', 1, true), text) + assert(text:find("result [tool-7]: hello", 1, true), text) + assert(text:find(" world", 1, true), text) + assert(text:find("error [tool-8]: boom", 1, true), text) + progress.reset() + end }, + + { "tool names use internal dotted spelling in progress labels", function() + progress.reset() + local component = board("call-tool-names", false) + local card = progress.card("worker", "child") + card:event({ type = "tool_details", index = 1, id = "wire", name = "std__shell" }) + card:event({ type = "tool_details", index = 2, id = "internal", name = "std.read" }) + card:event({ type = "block_complete", block_type = "tool_use", index = 3, + id = "complete", name = "web__fetch" }) + + local text = plain(component:render(80)) + assert(text:find("std.shell [wire]", 1, true), text) + assert(text:find("std.read [internal]", 1, true), text) + assert(text:find("web.fetch [complete]", 1, true), text) + assert(not text:find("std__shell", 1, true), text) + progress.reset() + end }, + + { "pathological single-line payloads are bounded before history ingestion", function() + progress.reset() + local component = board("call-huge", false) + local card = progress.card("worker", "huge") + local huge = "head\0" .. string.rep("x", 2 * 1024 * 1024) + card:event({ type = "block_start", block_type = "text", index = 1 }) + card:event({ type = "content_delta", index = 1, delta = huge }) + card:event({ type = "tool_dispatch_result", tool_results = { + { tool_use_id = "huge-tool", output = huge }, + } }) + + assert(#card.history == 2) + assert(#card.history[1] <= 4096 and #card.history[2] <= 4096, + "assistant and tool lines must be bounded at ingestion") + local text = plain(component:render(120)) + assert(text:find("head x", 1, true), "ordinary control sanitization should be preserved") + progress.reset() + end }, + + { "rendering sanitizes controls, stays width-bound, and caps retained history", function() + progress.reset() + local component = board("call-bounds", false) + local card = progress.card("bad\27label", "child") + for i = 1, 600 do + card:event({ type = "block_start", block_type = "text", index = i }) + card:event({ type = "content_delta", index = i, + delta = string.format("history-%03d-abcdefghijklmnopqrstuvwxyz\n", i) }) + end + local lines = component:render(16) + local text = plain(lines) + assert(not text:find("\27", 1, true), "control bytes must not reach the renderer") + assert(not text:find("history%-001"), "old history should be evicted") + assert(text:find("history%-600"), "recent history should remain") + assert(#lines <= 4098, "rendering itself must remain bounded") + for _, line in ipairs(lines) do + assert(utf8.len(line) <= 16, "line exceeded component width: " .. line) + end + progress.reset() + end }, +} diff --git a/spec/test_progress_replay.lua b/spec/test_progress_replay.lua new file mode 100644 index 0000000..aba8e33 --- /dev/null +++ b/spec/test_progress_replay.lua @@ -0,0 +1,316 @@ +local fake = require("spec.fake_ext") +local progress = require("subagents.progress") +local run = require("subagents.run") +local luatool = require("subagents.luatool") +local toml_workflows = require("subagents.toml_workflows") + +local function plain(lines) + return table.concat(lines, "\n"):gsub("\27%[[%d;]*m", "") +end + +local function has(text, needle) + assert(text:find(needle, 1, true), "expected to find " .. needle .. " in:\n" .. text) +end + +local function profile_set() + local alpha = { name = "alpha", description = "alpha", body = "ALPHA\n" } + local beta = { name = "beta", description = "beta", body = "BETA\n" } + return { + list = { alpha, beta }, + by_name = { alpha = alpha, beta = beta }, + warnings = {}, + } +end + +local function message(role, text, metadata) + return { role = role, text = text, metadata = metadata } +end + +local function tool_use(id, name, input) + return { + type = "tool_use", id = id, name = name, input = input, + } +end + +local function tool_result(id, output, is_error) + return { + type = "tool_result", tool_use_id = id, is_error = is_error == true, + parts = { { text = output } }, + } +end + +local function claim(handle, id, tool_name) + local component + local alive = true + progress.claim({ + id = id, + tool_name = tool_name or "subagents.run", + collapsed = true, + set_component = function(_, value) + component = value + return { + render = value.render, + invalidate = function() end, + alive = function() return alive end, + set_pinned = function() end, + } + end, + }) + assert(component ~= nil, "the production claim path installed a component") + return component, function() alive = false end +end + +local function with_host(fn, opts) + local handle = fake.install(opts) + local ok, err = pcall(fn, handle) + progress.reset() + handle.restore() + if not ok then error(err, 0) end +end + +return { + { "replay reconstructs a direct child turn, tools, result, model, and status", function() + with_host(function(handle) + handle.add_session("child-direct", { + message("system", "You are a subagent."), + message("system", "Discovered profile.", { + subagents = { owner = "0198-primary", agent = "reviewer" }, + }), + message("user", "inspect the change", { + subagents = { tool_call_id = "outer-direct", model = "openai:test", reasoning = "high" }, + }), + { role = "assistant", blocks = { + { type = "text", text = "checking" }, + tool_use("tool-1", "std__read", '{"path":"auth.lua"}'), + } }, + { role = "user", blocks = { tool_result("tool-1", "file contents") } }, + { role = "assistant", blocks = { { type = "text", text = "found the issue" } } }, + }) + local component, kill = claim(handle, "outer-direct") + progress.collapse({ collapsed = false }) + local text = plain(component:render(120)) + has(text, "✔ reviewer child-direct") + has(text, "completed") + has(text, "↳ openai:test") + has(text, "prompt: inspect the change") + has(text, "checking") + has(text, "std.read [tool-1]") + has(text, 'input: {"path":"auth.lua"}') + has(text, "result [tool-1]: file contents") + has(text, "found the issue") + kill() + end) + end }, + + { "replay isolates dynamic and fixed workflow cards and only inline manifests show system prompts", function() + with_host(function(handle) + handle.add_session("child-dynamic", { + message("system", "You are a subagent."), + message("system", "INLINE-WORKFLOW-SYSTEM", { + subagents = { owner = "0198-primary", agent = "inline", inline = true }, + }), + message("user", "dynamic step", { + subagents = { tool_call_id = "outer-dynamic", model = "fixture:one" }, + }), + message("assistant", "dynamic answer"), + }) + handle.add_session("child-fixed", { + message("system", "You are a subagent."), + message("system", "DISCOVERED-SYSTEM", { + subagents = { owner = "0198-primary", agent = "alpha" }, + }), + message("user", "fixed step", { + subagents = { tool_call_id = "outer-fixed", model = "fixture:two" }, + }), + message("assistant", "fixed answer"), + }) + + local dynamic, kill_dynamic = claim(handle, "outer-dynamic", "subagents.lua") + local fixed, kill_fixed = claim(handle, "outer-fixed", "subagents.workflow") + progress.collapse({ collapsed = false }) + local dynamic_text = plain(dynamic:render(120)) + local fixed_text = plain(fixed:render(120)) + has(dynamic_text, "✔ inline child-dynamic") + has(dynamic_text, "system prompt: INLINE-WORKFLOW-SYSTEM") + has(dynamic_text, "prompt: dynamic step") + has(dynamic_text, "dynamic answer") + assert(not dynamic_text:find("fixed answer", 1, true), dynamic_text) + has(fixed_text, "✔ alpha child-fixed") + has(fixed_text, "prompt: fixed step") + has(fixed_text, "fixed answer") + assert(not fixed_text:find("system prompt:", 1, true), fixed_text) + kill_dynamic() + kill_fixed() + end) + end }, + + { "one resumed child can contribute separate turns to separate outer calls", function() + with_host(function(handle) + handle.add_session("child-shared", { + message("system", "role"), + message("system", "Alpha", { subagents = { owner = "0198-primary", agent = "alpha" } }), + message("user", "first turn", { subagents = { tool_call_id = "outer-a", model = "model-a" } }), + message("assistant", "first answer"), + message("user", "second turn", { subagents = { tool_call_id = "outer-b", model = "model-b" } }), + message("assistant", "second answer"), + message("user", "third turn", { subagents = { tool_call_id = "outer-a", model = "model-c" } }), + message("assistant", "third answer"), + }) + + local a, kill_a = claim(handle, "outer-a") + local b, kill_b = claim(handle, "outer-b") + progress.collapse({ collapsed = false }) + local a_text = plain(a:render(120)) + local b_text = plain(b:render(120)) + has(a_text, "first answer") + has(a_text, "third answer") + assert(not a_text:find("second answer", 1, true), a_text) + has(b_text, "second answer") + assert(not b_text:find("first answer", 1, true), b_text) + assert(not b_text:find("third answer", 1, true), b_text) + local _, shared_cards = a_text:gsub("child%-shared", "") + assert(shared_cards == 2, "both turns owned by outer-a are separate cards:\n" .. a_text) + kill_a() + kill_b() + end) + end }, + + { "replay preserves interleaved spawn order across child sessions", function() + with_host(function(handle) + handle.add_session("child-a", { + message("system", "role"), + message("system", "Alpha", { subagents = { owner = "0198-primary", agent = "alpha" } }), + message("user", "A1", { subagents = { tool_call_id = "outer-order", sequence = 1, model = "m" } }), + message("assistant", "answer A1"), + message("user", "A2", { subagents = { tool_call_id = "outer-order", sequence = 3, model = "m" } }), + message("assistant", "answer A2"), + }) + handle.add_session("child-b", { + message("system", "role"), + message("system", "Beta", { subagents = { owner = "0198-primary", agent = "beta" } }), + message("user", "B1", { subagents = { tool_call_id = "outer-order", sequence = 2, model = "m" } }), + message("assistant", "answer B1"), + }) + local component, kill = claim(handle, "outer-order") + progress.collapse({ collapsed = false }) + local text = plain(component:render(120)) + local a1 = assert(text:find("answer A1", 1, true)) + local b1 = assert(text:find("answer B1", 1, true)) + local a2 = assert(text:find("answer A2", 1, true)) + assert(a1 < b1 and b1 < a2, text) + kill() + end) + end }, + + { "replay keeps cancelled and non-text completed turns terminal", function() + with_host(function(handle) + handle.add_session("child-cancel", { + message("system", "role"), + message("system", "Alpha", { subagents = { owner = "0198-primary", agent = "alpha" } }), + message("user", "cancelled prompt", { subagents = { + tool_call_id = "outer-status", sequence = 1, status = "cancelled", + } }), + }) + handle.add_session("child-thinking", { + message("system", "role"), + message("system", "Beta", { subagents = { owner = "0198-primary", agent = "beta" } }), + message("user", "thinking prompt", { subagents = { + tool_call_id = "outer-status", sequence = 2, status = "completed", + } }), + { role = "assistant", blocks = { { type = "thinking", text = "private reasoning" } } }, + }) + local component, kill = claim(handle, "outer-status") + progress.collapse({ collapsed = false }) + local text = plain(component:render(120)) + has(text, "⊘ alpha child-cancel") + has(text, "✔ beta child-thinking") + assert(not text:find("✖", 1, true), text) + kill() + end) + end }, + + { "replay finds a manifest after long primary system context through the bounded API", function() + with_host(function(handle) + handle.reject_unbounded_replay = true + local messages = {} + for index = 1, 80 do messages[#messages + 1] = message("system", "context " .. index) end + messages[#messages + 1] = message("system", "INLINE-LONG", { + subagents = { owner = "0198-primary", agent = "long", inline = true }, + }) + messages[#messages + 1] = message("user", "long prompt", { + subagents = { tool_call_id = "outer-long", sequence = 1 }, + }) + messages[#messages + 1] = message("assistant", "long answer") + handle.add_session("child-long", messages) + local component, kill = claim(handle, "outer-long") + progress.collapse({ collapsed = false }) + local text = plain(component:render(120)) + has(text, "✔ long child-long") + has(text, "system prompt: INLINE-LONG") + assert(handle.bounded_list_calls == 1, "replay must use the bounded catalog API") + assert(handle.bounded_load_calls == 2, "replay must use bounded tail + manifest reads") + kill() + end) + end }, + + { "durable turns carry the owning outer call and inline manifests are marked", function() + with_host(function(handle) + local profiles = profile_set() + local component, kill = claim(handle, "outer-run") + progress.bind({ tool_call_id = "outer-run" }) + handle.queue_for("alpha", { output = "done" }) + run.handle({ agent = "alpha", prompt = "direct" }, profiles) + assert(handle.runs[1].metadata.subagents.tool_call_id == "outer-run", + "direct child metadata names its outer tool call") + assert(handle.runs[1].metadata.subagents.sequence == 1, + "direct child metadata records its card sequence") + assert(handle.runs[1].metadata.subagents.status == "completed", + "settled status is retained for durable presentation") + + handle.queue_for("alpha", { output = "fixed done" }) + toml_workflows.handle({ + prompt = "fixed", + steps = { { id = "step", agent = "alpha", prompt = "run fixed" } }, + }, profiles) + assert(handle.runs[2].metadata.subagents.tool_call_id == "outer-run", + "fixed workflow child keeps the same outer owner") + assert(handle.runs[2].metadata.subagents.sequence == 2, + "fixed workflow child follows spawn order") + + handle.queue_for("inline", { output = "inline done" }) + local source = [[return subagents.workflow(function(ctx, input) + return ctx:agent({ agent = "inline", prompt = input }):await() + end)]] + luatool.handle({ + prompt = "dynamic", + source = source, + agents = { { name = "inline", system_prompt = "INLINE" } }, + }, profiles) + local child = handle.spawns[3] + local manifest = child.system_messages[2].metadata.subagents + assert(manifest.inline == true, "inline workflow manifest is explicitly marked") + assert(handle.runs[3].metadata.subagents.tool_call_id == "outer-run", + "inline workflow child keeps the same outer owner") + assert(handle.runs[3].metadata.subagents.sequence == 3, + "inline workflow child follows spawn order") + assert(handle.runs[3].metadata.subagents.status == "completed", + "inline completion is durably annotated") + kill() + progress.reset() + _ = component + end) + end }, + + { "missing and malformed child logs degrade to an empty or partial board", function() + with_host(function(handle) + handle.add_session("malformed", "not a message array") + handle.add_session("valid", { + message("system", "role"), + message("user", "no ownership", { subagents = { model = "model" } }), + }) + local component, kill = claim(handle, "outer-missing") + assert(#component:render(100) == 0, "unowned or malformed logs do not invent cards") + kill() + end, { sessions = {} }) + end }, +} diff --git a/spec/test_run.lua b/spec/test_run.lua index e4bb038..57c6e47 100644 --- a/spec/test_run.lua +++ b/spec/test_run.lua @@ -10,6 +10,7 @@ -- machine's real ~/.config out of the run. local fake = require("spec.fake_ext") +local progress = require("subagents.progress") local run = require("subagents.run") local spawn = require("subagents.spawn") @@ -123,6 +124,53 @@ return { end) end }, + { "subagents.run presents its child prompt only when expanded", function() + with_host(function(handle, profiles) + handle.queue_for("reviewer", { events = { + { type = "block_start", block_type = "text", index = 0 }, + { type = "content_delta", index = 0, delta = "checking auth\n" }, + { type = "block_start", block_type = "tool_use", index = 1 }, + { type = "tool_details", index = 1, id = "tool-1", name = "std__read" }, + { type = "block_complete", block_type = "tool_use", index = 1, + id = "tool-1", name = "std__read", text = '{"path":"auth.lua"}' }, + { type = "tool_dispatch_result", tool_results = { + { tool_use_id = "tool-1", output = "file contents", is_error = false }, + } }, + } }) + progress.reset() + local component + progress.claim({ + id = "run-call", + tool_name = "subagents.run", + collapsed = true, + set_component = function(_, value) + component = value + return { + invalidate = function() end, + alive = function() return true end, + set_pinned = function() end, + } + end, + }) + progress.bind({ tool_call_id = "run-call" }) + + run.handle({ agent = "reviewer", prompt = "Review the auth change." }, profiles) + local compact = table.concat(component:render(100), "\n") + assert(not compact:find("Review the auth change.", 1, true), compact) + + progress.collapse({ collapsed = false }) + local expanded = table.concat(component:render(100), "\n") + has(expanded, "prompt: Review the auth change.") + has(expanded, "checking auth") + has(expanded, "std.read [tool-1]") + has(expanded, 'input: {"path":"auth.lua"}') + has(expanded, "result [tool-1]: file contents") + assert(not expanded:find("system prompt:", 1, true), + "a discovered profile system prompt is not presentation metadata:\n" .. expanded) + progress.reset() + 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) diff --git a/spec/test_toml_workflows.lua b/spec/test_toml_workflows.lua index 8ae2cac..4755b8e 100644 --- a/spec/test_toml_workflows.lua +++ b/spec/test_toml_workflows.lua @@ -11,6 +11,7 @@ local fake = require("spec.fake_ext") local paths = require("subagents.paths") +local progress = require("subagents.progress") local toml_workflows = require("subagents.toml_workflows") local function has(text, needle) @@ -384,9 +385,25 @@ return { end) end }, - { "the tool runs a transient definition", function() + { "the tool runs a transient definition and presents each child prompt only when expanded", function() with_host(function(handle, profiles) handle.queue_for("alpha", { output = "transient output" }) + progress.reset() + local component + progress.claim({ + id = "workflow-call", + tool_name = "subagents.workflow", + collapsed = true, + set_component = function(_, value) + component = value + return { + invalidate = function() end, + alive = function() return true end, + set_pinned = function() end, + } + end, + }) + progress.bind({ tool_call_id = "workflow-call" }) local text = toml_workflows.handle({ prompt = "the input", steps = { { id = "only", agent = "alpha", prompt = "Do it." } }, @@ -394,11 +411,21 @@ return { has(text, "step: only") has(text, "transient output") has(handle.spawns[1].prompt, "Do it.\n\n## Workflow input\n\nthe input") + local compact = table.concat(component:render(100), "\n") + assert(not compact:find("Do it.", 1, true), compact) + assert(not compact:find("the input", 1, true), compact) + progress.collapse({ collapsed = false }) + local expanded = table.concat(component:render(100), "\n") + has(expanded, "prompt: Do it.") + has(expanded, "## Workflow input") + has(expanded, "the input") + assert(not expanded:find("system prompt:", 1, true), expanded) has(toml_workflows.handle({ prompt = "x", steps = { { id = "only", agent = "alpha", prompt = "p", needs = { "ghost" } } }, }, profiles), "unknown dependency 'ghost'") + progress.reset() end) end }, } |
