diff options
Diffstat (limited to 'spec/fake_ext.lua')
| -rw-r--r-- | spec/fake_ext.lua | 709 |
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 |
