From cc69a2e431779528578211a5cb3385bb23e076bf Mon Sep 17 00:00:00 2001 From: t Date: Tue, 18 Aug 2026 14:18:14 -0600 Subject: Persist and replay subagent progress across sessions Record durable child-turn metadata, restore bounded progress cards during startup replay, and keep settled cards attached to transcript entries. Add workflow-local Lua profiles and expose discovered agents and workflows in the session header. --- spec/test_progress.lua | 322 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 spec/test_progress.lua (limited to 'spec/test_progress.lua') 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 }, +} -- cgit v1.3