diff options
| -rw-r--r-- | spec/test_luatool.lua | 34 | ||||
| -rw-r--r-- | spec/test_progress.lua | 30 | ||||
| -rw-r--r-- | spec/test_progress_replay.lua | 2 | ||||
| -rw-r--r-- | subagents/progress.lua | 53 | ||||
| -rw-r--r-- | subagents/spawn.lua | 4 | ||||
| -rw-r--r-- | subagents/workflow.lua | 3 |
6 files changed, 117 insertions, 9 deletions
diff --git a/spec/test_luatool.lua b/spec/test_luatool.lua index 509b804..e7f2294 100644 --- a/spec/test_luatool.lua +++ b/spec/test_luatool.lua @@ -4,6 +4,7 @@ local fake = require("spec.fake_ext") local jobs = require("subagents.jobs") local luatool = require("subagents.luatool") +local progress = require("subagents.progress") local workflow = require("subagents.workflow") local uv = require("luv") @@ -115,6 +116,39 @@ return { end) end }, + { "a background workflow keeps its completed tool entry pinned", function() + with_host(function(handle, profiles) + progress.reset() + progress.begin_live_turn() + local pins + progress.claim({ + id = "pinned-workflow-call", + tool_name = "subagents.lua", + set_component = function() + pins = {} + return { + alive = function() return true end, + invalidate = function() end, + set_pinned = function(_, value) pins[#pins + 1] = value end, + } + end, + }) + handle.queue_for("alpha", { output = "finished" }) + local id = luatool.handle({ source = [[ + return subagents.workflow(function(ctx) + return ctx:agent{name="work", agent="alpha", prompt="go"}:await().output + end) + ]] }, profiles, { tool_call_id = "pinned-workflow-call" }) + progress.settle({ id = "pinned-workflow-call" }) + assert(#pins == 1 and pins[1] == true, "the outer result must not unpin the running workflow") + + local record = pump(id) + assert(record.status == "completed", tostring(record.error)) + assert(#pins == 2 and pins[2] == false, "workflow completion restores transcript order") + progress.reset() + end) + end }, + { "a later Lua call inspects workflows and records are read-only", function() with_host(function(_, profiles) local id = luatool.handle({ source = [[ diff --git a/spec/test_progress.lua b/spec/test_progress.lua index 5788998..76cd289 100644 --- a/spec/test_progress.lua +++ b/spec/test_progress.lua @@ -76,6 +76,36 @@ return { assert(pins.b[2] == false, "reset safely releases unresolved boards") end }, + { "background workflows keep their board pinned after the outer tool settles", function() + progress.reset() + progress.begin_live_turn() + local component, pins + progress.claim({ + id = "background-call", + tool_name = "subagents.lua", + set_component = function(_, value) + component, pins = value, {} + return { + alive = function() return true end, + invalidate = function() end, + set_pinned = function(_, value) pins[#pins + 1] = value end, + } + end, + }) + + progress.bind({ tool_call_id = "background-call" }) + progress.card("worker", "child") + progress.workflow_started("background-call") + progress.settle({ id = "background-call" }) + progress.reset() + assert(#pins == 1 and pins[1] == true, "turn boundaries must not unpin a running workflow") + assert(component:render(80)[#component:render(80)] == "", "a running workflow retains its spacing") + + progress.workflow_finished("background-call") + assert(#pins == 2 and pins[2] == false, "the board unpins when its final workflow settles") + progress.reset() + end }, + { "a pinned board leaves one blank line before the waiting indicator", function() progress.reset() local component, pins diff --git a/spec/test_progress_replay.lua b/spec/test_progress_replay.lua index ffe7e5c..ca87657 100644 --- a/spec/test_progress_replay.lua +++ b/spec/test_progress_replay.lua @@ -94,7 +94,7 @@ return { local text = plain(component:render(120)) has(text, "✔ reviewer child-direct") has(text, "completed") - has(text, "↳ openai:test") + has(text, "↳ openai:test (high)") has(text, "prompt: inspect the change") has(text, "checking") has(text, "std.read [tool-1]") diff --git a/subagents/progress.lua b/subagents/progress.lua index d88e239..b39fe00 100644 --- a/subagents/progress.lua +++ b/subagents/progress.lua @@ -32,6 +32,7 @@ local GLYPHS = { local M = {} local boards = {} +local background_workflows = {} local bound = setmetatable({}, { __mode = "k" }) local tools_collapsed = true local sid_width = 8 @@ -284,6 +285,11 @@ local function set_board_pinned(board, pinned) end end +local function sync_board_pin(board) + local workflows = type(board.key) == "string" and background_workflows[board.key] or 0 + set_board_pinned(board, board.tool_active or (workflows or 0) > 0) +end + local function prune_boards() for key, board in pairs(boards) do if board.handle and board.handle.alive then @@ -293,8 +299,16 @@ local function prune_boards() end end -local function new_board() - local board = { cards = {}, handle = nil, collapsed = tools_collapsed, next_sequence = 0, pinned = false } +local function new_board(key) + local board = { + key = key, + cards = {}, + handle = nil, + collapsed = tools_collapsed, + next_sequence = 0, + pinned = false, + tool_active = false, + } board.component = { render = function(_, width) return render(board, width) end } return board end @@ -305,7 +319,8 @@ function M.claim(event) if not PROGRESS_TOOLS[name] then return end if type(event.set_component) ~= "function" then return end if type(event.collapsed) == "boolean" then tools_collapsed = event.collapsed end - local key, board = event.id or event.tool_call_id or name, new_board() + local key = event.id or event.tool_call_id or name + local board = new_board(key) local attached, handle = pcall(event.set_component, event, board.component) if not attached then return end board.handle = handle @@ -317,20 +332,41 @@ function M.claim(event) if replaying then replay_board(board, type(key) == "string" and key or nil) else - set_board_pinned(board, true) + board.tool_active = true end + sync_board_pin(board) end -- A result settles the outer tool invocation. Keep the board attached to its --- transcript entry, but return it to that entry's original ordering. +-- transcript entry, but leave it pinned while a background workflow it started +-- is still running. function M.settle(event) local key = type(event) == "table" and (event.id or event.tool_call_id) or nil local board = key and boards[key] - if board then set_board_pinned(board, false) end + if board then + board.tool_active = false + sync_board_pin(board) + end local co = coroutine.running() if co and bound[co] == key then bound[co] = nil end end +function M.workflow_started(tool_call_id) + if type(tool_call_id) ~= "string" or tool_call_id == "" then return end + background_workflows[tool_call_id] = (background_workflows[tool_call_id] or 0) + 1 + local board = boards[tool_call_id] + if board then sync_board_pin(board) end +end + +function M.workflow_finished(tool_call_id) + if type(tool_call_id) ~= "string" or tool_call_id == "" then return end + local count = background_workflows[tool_call_id] + if count == nil then return end + if count <= 1 then background_workflows[tool_call_id] = nil else background_workflows[tool_call_id] = count - 1 end + local board = boards[tool_call_id] + if board then sync_board_pin(board) end +end + function M.collapse(event) if type(event.collapsed) ~= "boolean" then return end prune_boards() @@ -510,6 +546,8 @@ local function replay_turn(board, conv, messages, start, finish, info, agent, sy local id = nonempty(info and info.id) local model = nonempty(metadata and metadata.model) or nonempty(info and info.model) + local reasoning = nonempty(metadata and metadata.reasoning) or nonempty(info and info.reasoning) + if model and reasoning then model = model .. " (" .. reasoning .. ")" end local card = new_card(board, agent or "subagent", id, model, { prompt = prompt, system_prompt = system_prompt, @@ -727,7 +765,8 @@ end function M.reset() for _, board in pairs(boards) do - set_board_pinned(board, false) + board.tool_active = false + sync_board_pin(board) end prune_boards() -- `bound` has weak coroutine keys. Foreground handlers clear themselves in diff --git a/subagents/spawn.lua b/subagents/spawn.lua index 9117fdd..d90631d 100644 --- a/subagents/spawn.lua +++ b/subagents/spawn.lua @@ -434,7 +434,9 @@ function M.spawn(spec) if got_len and type(length) == "number" then turn_index = length + 1 end end - local card = progress.card(spec.label or "subagent", id, model_label, { + local card_detail = model_label + if card_detail and reasoning_label then card_detail = card_detail .. " (" .. reasoning_label .. ")" end + local card = progress.card(spec.label or "subagent", id, card_detail, { prompt = spec.prompt, system_prompt = spec.presentation_system_prompt, }) diff --git a/subagents/workflow.lua b/subagents/workflow.lua index 97dd858..671890e 100644 --- a/subagents/workflow.lua +++ b/subagents/workflow.lua @@ -767,6 +767,7 @@ local function finish_workflow(record, status, result, err) record.error = err record.coroutine = nil active_workflows[record.id] = nil + progress.workflow_finished(record.tool_call_id) notify(record) end @@ -785,6 +786,7 @@ function M.start(wf, opts) workflow_sequence = workflow_sequence + 1 local id = "workflow-" .. workflow_sequence local record = make_workflow_record(id) + record.tool_call_id = type(opts.tool_call_id) == "string" and opts.tool_call_id ~= "" and opts.tool_call_id or nil workflow_records[id] = record active_workflows[id] = record @@ -823,6 +825,7 @@ function M.start(wf, opts) finish_workflow(record, record.cancel_requested and "cancelled" or "failed", nil, tostring(err)) end end) + progress.workflow_started(record.tool_call_id) return id end |
