summaryrefslogtreecommitdiff
path: root/spec/fake_ext.lua
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-08-16 20:42:43 -0600
committert <t@tjp.lol>2026-08-17 20:31:29 -0600
commit4f0a91ef55fe96835172bdad34feec1e2a0a0977 (patch)
treeed4f3e86575aa6243043bc22f8037be153a609c6 /spec/fake_ext.lua
parentc1ab34754d3f3695fafd344fe1a181ecf0740761 (diff)
subagents extension on the generic host surfaces
The rock now owns all subagent policy on top of libpanto-lua's generic APIs: children are ordinary panto.agent instances over rock-constructed stores, started with agent:run_async and awaited by arming uv.new_poll on each job's wake_fd from the tool handler's coroutine. subagents/jobs.lua carries the session policy the host used to own: the concurrency gate (4 running, FIFO queue, cancel-while-queued never starts), the await contract (results in input order; "first" returns settled plus remaining by identity), and settle-time shaping. subagents/spawn.lua seeds new children (primary system context, child role, profile body with manifest metadata), resolves model/reasoning through panto.ext.resolve_model, filters subagents.* out of the inherited tool set via agent:set_tools, and reads resume defaults back from stored message metadata. One-shot structured workers are a null_store agent with a declaration-only output tool, tool_choice forced, dispatch_tools=false. subagents/progress.lua renders per-tool-entry cards through the component handle's invalidate seam; turn_interrupt cancels live children, turn_end closes them. Spec suite rewritten against fakes of the new surfaces (98 cases), including gate/queue/cancel bounds, resume-default extraction, one-shot capture via unresolved tool calls, tool filtering, and manifest seeding.
Diffstat (limited to 'spec/fake_ext.lua')
-rw-r--r--spec/fake_ext.lua709
1 files changed, 709 insertions, 0 deletions
diff --git a/spec/fake_ext.lua b/spec/fake_ext.lua
new file mode 100644
index 0000000..5ec4c06
--- /dev/null
+++ b/spec/fake_ext.lua
@@ -0,0 +1,709 @@
+-- A scriptable stand-in for the `panto` module the extension talks to.
+--
+-- Every module reaches the host through `require("panto")` at call time, never
+-- as a load-time alias, so a spec can install this fake after the modules are
+-- already loaded. `install()` writes `package.loaded.panto`; `handle.restore()`
+-- removes it again.
+--
+-- The surface mirrors the real one, covering what the extension actually calls:
+--
+-- panto.ext.session_info() -> { session_id, session_dir, model, reasoning }
+-- panto.ext.resolve_model(q) -> cfg | nil, "resolve_model: ..."
+-- panto.ext.models(query) -> catalog answer
+-- panto.ext.agent -- the primary, borrowed: :tools(), :conversation()
+-- panto.ext.on(name, handler) -- lifecycle subscriptions
+-- panto.ext.register_tool / register_command
+-- panto.ext.json -- panto's JSON codec (dkjson stands in here)
+-- 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)
+-- conv:messages() / :message_metadata(i) / :add_system_message(text, { metadata = })
+-- job:next_event() / :result() / :request_cancel() / :close()
+--
+-- Scripting. Every child takes its outcome from a queue: `handle.queue(outcome)`
+-- is matched first-in-first-out, `handle.queue_for(label, outcome)` is matched
+-- against the profile name the child was seeded with (its manifest metadata; a
+-- resumed child carries no fresh manifest, so it always draws from the FIFO
+-- queue). An outcome describes the settled turn — `{ status =, output =,
+-- error =, structured_json =, resumable =, id =, settle = }`. A child with
+-- nothing queued completes with a generated id and a generic output, which
+-- keeps tests that only care about what was asked for short.
+--
+-- Settling. There is no event loop here, so a fake job settles on the Nth call
+-- to `job:result()`: `settle = N`, lowest first, which is the same ordering key
+-- the previous fake used for "first" awaits. `uv.pipe()` fails in this harness
+-- (see the stub below), so the job machinery takes its fd-less path and drains
+-- its jobs in place; that is what turns those polls into progress and keeps the
+-- specs plain assert scripts. `request_cancel` settles the next poll as
+-- cancelled, exactly like the binding's pump.
+--
+-- Everything the host was asked to do is recorded on the returned handle:
+-- `spawns` (one record per child agent, in creation order, carrying the
+-- resolve_model arguments, the store directory, the seeded system messages, the
+-- tool declarations it was given and the run_async options), `runs` (run_async
+-- options only, so a gated child is visibly not started yet), `jobs`,
+-- `resolves`, `tools`, `commands`, `models_queries`, `subscriptions`, `mkdirs`,
+-- `max_live` and `polls`. Assertions read those tables directly.
+
+local M = {}
+
+-- ---------------------------------------------------------------------------
+-- luv stand-in, installed once when this module loads
+-- ---------------------------------------------------------------------------
+
+-- The harness has no event loop, so it has no pipes: `uv.pipe()` fails and the
+-- job machinery falls back to draining in place, which is the same path a host
+-- without luv takes. `fs_mkdir` is recorded rather than performed, so a spec can
+-- assert which directories a child store would need without touching the disk.
+-- Everything else delegates to the real luv, so the filesystem cases are
+-- unaffected. This has to happen at load time, not inside install(), because a
+-- module that resolves luv once at load would otherwise capture the real one.
+local made_dirs = {}
+do
+ local ok, real = pcall(require, "luv")
+ if ok and type(real) == "table" then
+ package.loaded.luv = setmetatable({
+ pipe = function()
+ return nil, "the spec harness provides no pipes"
+ end,
+ fs_mkdir = function(path)
+ made_dirs[#made_dirs + 1] = path
+ return true
+ end,
+ }, { __index = real })
+ end
+end
+
+-- A job polled this many times without settling means the drain loop is
+-- spinning; failing beats hanging the whole suite.
+local POLL_LIMIT = 5000
+
+local DEFAULT_SESSION = {
+ session_id = "0198-primary",
+ session_dir = "/tmp/panto-spec-sessions/--Users-travis-Code-panto-subagents--",
+ model = "anthropic:sonnet",
+ reasoning = "medium",
+}
+
+local DEFAULT_OVERVIEW = {
+ model = "anthropic:sonnet",
+ reasoning = "medium",
+ providers = {
+ { name = "anthropic", style = "messages", models = 7 },
+ { name = "openai", style = "responses", models = 12 },
+ },
+}
+
+-- Stands in for the lightuserdata re-registration tag a real decl carries. The
+-- identity is what matters: a child must be given the primary's own tags.
+M.SOURCE = setmetatable({}, { __name = "fake.tool_source" })
+
+-- The primary's tools: two a child inherits, and the four it must not.
+-- agent:tools() sorts by name, so this is the order the rock sees them in.
+local DEFAULT_TOOL_NAMES = {
+ "bash",
+ "read_file",
+ "subagents.lua",
+ "subagents.models",
+ "subagents.run",
+ "subagents.workflow",
+}
+
+local function default_tools()
+ local decls = {}
+ for index, name in ipairs(DEFAULT_TOOL_NAMES) do
+ decls[index] = {
+ name = name,
+ description = name .. " does a thing",
+ schema = { type = "object", properties = {} },
+ _source = M.SOURCE,
+ }
+ end
+ return decls
+end
+
+-- panto installs its own JSON codec as `panto.ext.json`; dkjson is the closest
+-- stand-in available to a bare `lua` process. Absent, the field is simply
+-- missing, exactly as it would be on a host too old to provide it.
+local function json_codec()
+ local ok, dkjson = pcall(require, "dkjson")
+ if not ok or type(dkjson) ~= "table" then
+ return nil
+ end
+ return {
+ encode = function(value)
+ return dkjson.encode(value)
+ end,
+ decode = function(text)
+ local value, _, err = dkjson.decode(text)
+ if err then
+ error(err, 0)
+ end
+ return value
+ end,
+ }
+end
+
+local function copy(source, fallback)
+ local out = {}
+ for key, value in pairs(source or fallback or {}) do
+ out[key] = value
+ end
+ return out
+end
+
+-- ---------------------------------------------------------------------------
+-- Conversations
+-- ---------------------------------------------------------------------------
+
+local conv_mt = {}
+conv_mt.__index = conv_mt
+conv_mt.__name = "fake.conversation"
+
+-- scripted = array of { role =, text = | blocks =, metadata = }. `record`, when
+-- given, is the child record seeded system messages are mirrored onto.
+local function new_conversation(scripted, record)
+ local messages = {}
+ for index, message in ipairs(scripted or {}) do
+ messages[index] = {
+ role = message.role or "user",
+ blocks = message.blocks or { { type = "text", text = message.text or "" } },
+ metadata = message.metadata,
+ }
+ end
+ return setmetatable({ _messages = messages, _record = record }, conv_mt)
+end
+
+-- Faithful to the binding: metadata is not part of a message table, so the only
+-- way to reach it is message_metadata(i).
+function conv_mt:messages()
+ local out = {}
+ for index, message in ipairs(self._messages) do
+ local blocks = {}
+ for block_index, block in ipairs(message.blocks) do
+ blocks[block_index] = copy(block)
+ end
+ out[index] = { role = message.role, blocks = blocks }
+ end
+ return out
+end
+
+function conv_mt:message_metadata(index)
+ local message = self._messages[tonumber(index) or 0]
+ if message == nil then
+ return nil
+ end
+ return message.metadata
+end
+
+function conv_mt:add_system_message(text, opts)
+ if type(text) ~= "string" then
+ error("panto: add_system_message expects a string", 2)
+ end
+ local metadata = opts and opts.metadata
+ if metadata ~= nil and type(metadata) ~= "table" then
+ error("panto: metadata must be a table", 2)
+ end
+ self._messages[#self._messages + 1] = {
+ role = "system",
+ blocks = { { type = "text", text = text } },
+ metadata = metadata,
+ }
+ local record = self._record
+ if record then
+ record.system_messages[#record.system_messages + 1] = { text = text, metadata = metadata }
+ local manifest = metadata and metadata.subagents
+ if type(manifest) == "table" and type(manifest.agent) == "string" then
+ record.label = manifest.agent
+ end
+ end
+end
+
+-- ---------------------------------------------------------------------------
+-- Session stores
+-- ---------------------------------------------------------------------------
+
+local store_mt = {}
+store_mt.__index = store_mt
+store_mt.__name = "fake.store"
+
+function store_mt:resolve(id)
+ local session = self._harness.sessions[id]
+ if session == nil then
+ return nil
+ end
+ return { id = id, message_count = #session, api_style = "messages" }
+end
+
+function store_mt:load(id)
+ local session = self._harness.sessions[id]
+ if session == nil then
+ return nil
+ end
+ return new_conversation(session)
+end
+
+-- ---------------------------------------------------------------------------
+-- Jobs
+-- ---------------------------------------------------------------------------
+
+local job_mt = {}
+job_mt.__index = job_mt
+job_mt.__name = "fake.job"
+
+local function new_job(cfg)
+ return setmetatable({
+ _settle_after = math.max(1, math.floor(tonumber(cfg.settle) or 1)),
+ _events = cfg.events or {},
+ _finish = cfg.finish,
+ _harness = cfg.harness,
+ _polls = 0,
+ _settled = false,
+ _result = nil,
+ _cancel_requested = false,
+ _closed = false,
+ }, job_mt)
+end
+
+function job_mt:next_event()
+ if self._closed or #self._events == 0 then
+ return nil
+ end
+ return table.remove(self._events, 1)
+end
+
+function job_mt:result()
+ if self._closed then
+ -- close() frees the settled result in the binding, so a caller that
+ -- reads it back afterwards sees exactly this.
+ return nil
+ end
+ self._polls = self._polls + 1
+ if self._polls > POLL_LIMIT then
+ error(string.format(
+ "fake job: polled %d times without settling; the drain loop is making no progress on a job with no wake pipe",
+ POLL_LIMIT), 0)
+ end
+ if self._harness then
+ self._harness.polls = self._harness.polls + 1
+ end
+ if self._settled then
+ return self._result
+ end
+ if self._cancel_requested then
+ self._result = { status = "cancelled", error = "cancelled" }
+ elseif self._polls >= self._settle_after then
+ self._result = self._finish(self)
+ else
+ return nil
+ end
+ self._settled = true
+ if self._harness then
+ self._harness.live = self._harness.live - 1
+ end
+ return self._result
+end
+
+function job_mt:request_cancel()
+ self._cancel_requested = true
+end
+
+function job_mt:close()
+ if self._closed then
+ return
+ end
+ self._closed = true
+ self._result = nil
+ self._events = {}
+end
+
+-- job(spec) -> a standalone fake job, for specs that drive the job machinery
+-- directly rather than through a child. spec = { result =, events =, settle = }.
+function M.job(spec)
+ spec = spec or {}
+ return new_job({
+ settle = spec.settle,
+ events = spec.events,
+ finish = function()
+ return spec.result or { status = "completed", text = "done" }
+ end,
+ })
+end
+
+-- ---------------------------------------------------------------------------
+-- Outcomes: which scripted turn a child gets, and what it settles to
+-- ---------------------------------------------------------------------------
+
+local function next_outcome(h, label)
+ local bucket = label and h.labelled[label]
+ if bucket and #bucket > 0 then
+ return table.remove(bucket, 1)
+ end
+ if #h.queued > 0 then
+ return table.remove(h.queued, 1)
+ end
+ return {}
+end
+
+-- Bound as late as possible: the profile label only exists once the child has
+-- been seeded with its manifest system message.
+local function outcome_for(h, record)
+ if record.outcome == nil then
+ record.outcome = next_outcome(h, record.label)
+ end
+ return record.outcome
+end
+
+local function child_id(h, record)
+ if record.session_id then
+ return record.session_id
+ end
+ if record.id == nil then
+ record.id = outcome_for(h, record).id or ("child-" .. record.index)
+ end
+ return record.id
+end
+
+-- The settled turn, in the shape agent:run_async reports it.
+local function settled_result(h, record)
+ local outcome = outcome_for(h, record)
+ local status = outcome.status or "completed"
+ local result = { status = status }
+ local one_shot = record.run and record.run.dispatch_tools == false
+ if status == "completed" then
+ if one_shot then
+ result.text = outcome.output
+ if outcome.structured_json then
+ local decl = record.tool_decls and record.tool_decls[1]
+ result.tool_calls = { {
+ id = "call-" .. record.index,
+ name = outcome.tool_name or (decl and decl.name) or "emit_result",
+ input = outcome.structured_json,
+ } }
+ end
+ else
+ result.text = outcome.output or ("output of " .. tostring(record.label or child_id(h, record)))
+ end
+ else
+ result.error = outcome.error or ("the child " .. status)
+ end
+
+ -- The durable file: a child that never reached its first assistant message
+ -- has none, which is what makes it unresumable.
+ local id = child_id(h, record)
+ if outcome.resumable == false then
+ h.sessions[id] = nil
+ else
+ h.sessions[id] = h.sessions[id] or {}
+ end
+ return result
+end
+
+-- ---------------------------------------------------------------------------
+-- Agents
+-- ---------------------------------------------------------------------------
+
+local agent_mt = {}
+agent_mt.__index = agent_mt
+agent_mt.__name = "fake.agent"
+
+function agent_mt:conversation()
+ return self._conv
+end
+
+function agent_mt:session_id()
+ return child_id(self._harness, self._record)
+end
+
+function agent_mt:tools()
+ local out = {}
+ for index, decl in ipairs(self._record.tool_decls or {}) do
+ out[index] = decl
+ end
+ return out
+end
+
+function agent_mt:set_tools(decls)
+ if self._borrowed then
+ error("panto: set_tools is not supported on a borrowed agent", 2)
+ end
+ if type(decls) ~= "table" then
+ error("panto: set_tools expects an array of declarations", 2)
+ end
+ local names = {}
+ for index, decl in ipairs(decls) do
+ if type(decl) ~= "table" or type(decl.name) ~= "string" then
+ error("panto: tool declaration " .. index .. " has no name", 2)
+ end
+ names[index] = decl.name
+ end
+ self._record.tool_decls = decls
+ self._record.tools = names
+end
+
+function agent_mt:run_async(options)
+ if self._borrowed then
+ return nil, "run_async: not supported on a borrowed agent"
+ end
+ if type(options) ~= "table" then
+ error("panto: run_async expects a table", 2)
+ end
+ if options.prompt == nil and options.blocks == nil then
+ return nil, "run_async: pass a prompt or blocks"
+ end
+ if options.metadata ~= nil and type(options.metadata) ~= "table" then
+ return nil, "run_async: metadata must be a table"
+ end
+
+ local h, record = self._harness, self._record
+ record.run = {
+ prompt = options.prompt,
+ blocks = options.blocks,
+ metadata = options.metadata,
+ dispatch_tools = options.dispatch_tools,
+ wake_fd = options.wake_fd,
+ }
+ record.prompt = options.prompt
+ h.runs[#h.runs + 1] = record.run
+
+ local job = new_job({
+ settle = outcome_for(h, record).settle,
+ events = outcome_for(h, record).events,
+ harness = h,
+ finish = function()
+ return settled_result(h, record)
+ end,
+ })
+ h.jobs[#h.jobs + 1] = job
+ record.job = job
+ h.live = h.live + 1
+ if h.live > h.max_live then
+ h.max_live = h.live
+ end
+ return job
+end
+
+-- ---------------------------------------------------------------------------
+-- install
+-- ---------------------------------------------------------------------------
+
+-- install(opts) -> handle
+--
+-- opts = {
+-- session = { session_id =, session_dir =, model =, reasoning = },
+-- models_response = table | function(query),
+-- primary_tools = array of decl tables (defaults to DEFAULT_TOOL_NAMES),
+-- primary_messages = array of { role =, text = } for the primary conversation,
+-- sessions = { [id] = array of scripted messages } already on disk,
+-- unknown_models = { ["provider:model"] = true } resolve_model rejects,
+-- }
+function M.install(opts)
+ opts = opts or {}
+
+ for index = #made_dirs, 1, -1 do
+ made_dirs[index] = nil
+ end
+
+ -- A partial `session` overrides only the fields it names.
+ local session = copy(DEFAULT_SESSION)
+ for key, value in pairs(opts.session or {}) do
+ session[key] = value
+ end
+
+ local handle = {
+ session = session,
+ models_response = opts.models_response or DEFAULT_OVERVIEW,
+ primary_tools = opts.primary_tools or default_tools(),
+ sessions = opts.sessions or {},
+ unknown_models = opts.unknown_models or {},
+ spawns = {},
+ runs = {},
+ jobs = {},
+ resolves = {},
+ stores = {},
+ tools = {},
+ tools_by_name = {},
+ commands = {},
+ commands_by_name = {},
+ models_queries = {},
+ subscriptions = {},
+ on_by_name = {},
+ mkdirs = made_dirs,
+ queued = {},
+ labelled = {},
+ polls = 0,
+ live = 0,
+ max_live = 0,
+ }
+
+ function handle.queue(outcome)
+ handle.queued[#handle.queued + 1] = outcome or {}
+ end
+
+ function handle.queue_for(label, outcome)
+ local bucket = handle.labelled[label]
+ if bucket == nil then
+ bucket = {}
+ handle.labelled[label] = bucket
+ end
+ bucket[#bucket + 1] = outcome or {}
+ end
+
+ -- A child session that already exists on disk, for the resume cases.
+ function handle.add_session(id, messages)
+ handle.sessions[id] = messages or {}
+ end
+
+ function handle.made_dir(path)
+ for _, made in ipairs(handle.mkdirs) do
+ if made == path then
+ return true
+ end
+ end
+ return false
+ end
+
+ -- resolve_model hands back an opaque config; this is how a spec gets from
+ -- the config a child was built with back to the query that produced it.
+ local resolved_from = setmetatable({}, { __mode = "k" })
+
+ local ext = {}
+
+ function ext.session_info()
+ return copy(handle.session)
+ end
+
+ function ext.resolve_model(query)
+ if type(query) ~= "table" then
+ return nil, "resolve_model: expected a table of arguments"
+ end
+ local request = {
+ model = query.model,
+ reasoning = query.reasoning,
+ tool_choice = query.tool_choice,
+ }
+ handle.resolves[#handle.resolves + 1] = request
+ if type(query.model) ~= "string" or query.model == "" then
+ return nil, "resolve_model: model must be a 'provider:model' string"
+ end
+ if handle.unknown_models[query.model] then
+ return nil, string.format("resolve_model: unknown model '%s'", query.model)
+ end
+ local cfg = {
+ model = query.model,
+ reasoning = query.reasoning,
+ style = "messages",
+ wire_model = query.model:match(":(.+)$") or query.model,
+ }
+ resolved_from[cfg] = request
+ return cfg
+ end
+
+ function ext.models(query)
+ handle.models_queries[#handle.models_queries + 1] = query or {}
+ local response = handle.models_response
+ if type(response) == "function" then
+ return response(query or {})
+ end
+ return response
+ end
+
+ function ext.on(name, fn)
+ handle.subscriptions[#handle.subscriptions + 1] = { name = name, fn = fn }
+ handle.on_by_name[name] = fn
+ end
+
+ -- Fire a subscribed lifecycle handler, the way the host would.
+ function handle.emit(name, event)
+ for _, subscription in ipairs(handle.subscriptions) do
+ if subscription.name == name then
+ subscription.fn(event)
+ end
+ end
+ end
+
+ function ext.register_tool(tool)
+ handle.tools[#handle.tools + 1] = tool
+ handle.tools_by_name[tool.name] = tool
+ end
+
+ function ext.register_command(command)
+ handle.commands[#handle.commands + 1] = command
+ handle.commands_by_name[command.name] = command
+ end
+
+ ext.json = json_codec()
+
+ -- The primary agent, borrowed: readable tools and conversation, nothing else.
+ ext.agent = setmetatable({
+ _borrowed = true,
+ _harness = handle,
+ _record = { index = 0, tool_decls = handle.primary_tools, system_messages = {}, tools = {} },
+ _conv = new_conversation(opts.primary_messages),
+ }, agent_mt)
+
+ local function new_store(kind, arg)
+ local dir = nil
+ if type(arg) == "table" then
+ dir = arg.dir
+ elseif type(arg) == "string" then
+ dir = arg
+ end
+ if kind == "fs" and type(dir) ~= "string" then
+ error("panto.file_system_jsonl_store: missing dir", 2)
+ end
+ handle.stores[#handle.stores + 1] = dir
+ return setmetatable({ dir = dir, kind = kind, _harness = handle }, store_mt)
+ end
+
+ local function new_agent(options)
+ if type(options) ~= "table" then
+ error("panto.agent expects a table", 2)
+ end
+ local record = {
+ index = #handle.spawns + 1,
+ config = options.config,
+ store_dir = type(options.store) == "table" and options.store.dir or nil,
+ session_id = options.session_id,
+ resumed = options.session_id ~= nil,
+ system_messages = {},
+ tools = {},
+ resolve = resolved_from[options.config],
+ }
+ if record.resolve then
+ record.model = record.resolve.model
+ record.reasoning = record.resolve.reasoning
+ record.tool_choice = record.resolve.tool_choice
+ end
+ handle.spawns[record.index] = record
+
+ local conv = options.conversation
+ if conv == nil then
+ conv = new_conversation(nil, record)
+ else
+ conv._record = record
+ end
+ return setmetatable({ _harness = handle, _record = record, _conv = conv }, agent_mt)
+ end
+
+ handle.ext = ext
+ package.loaded.panto = {
+ ext = ext,
+ agent = new_agent,
+ file_system_jsonl_store = function(arg)
+ return new_store("fs", arg)
+ end,
+ null_store = function()
+ return new_store("null", nil)
+ end,
+ }
+
+ function handle.restore()
+ package.loaded.panto = nil
+ end
+
+ return handle
+end
+
+return M