summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
Diffstat (limited to 'spec')
-rw-r--r--spec/fake_ext.lua82
-rw-r--r--spec/test_init.lua57
-rw-r--r--spec/test_luatool.lua69
-rw-r--r--spec/test_progress.lua322
-rw-r--r--spec/test_progress_replay.lua316
-rw-r--r--spec/test_run.lua48
-rw-r--r--spec/test_toml_workflows.lua29
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 },
}