summaryrefslogtreecommitdiff
path: root/spec/test_run.lua
blob: b2c5783eb78e6d1950fa6aab5ffeb3cf878615e3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
-- subagents/run.lua and subagents/spawn.lua: what a child is built out of, and
-- what the model is told afterwards.
--
-- Every case installs a fresh fake host, so `handle.spawns` holds exactly the
-- children this case produced: one record per child agent, carrying the
-- resolve_model arguments, the store directory, the system messages it was
-- seeded with, the tool declarations it was given and the run_async options.
-- Profile sets are built inline rather than discovered: precedence, not
-- discovery, is what these cases are about, and an explicit set also keeps the
-- 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")

local function has(text, needle)
    assert(type(text) == "string", "expected a string result, got " .. type(text))
    assert(text:find(needle, 1, true), "expected to find " .. needle .. " in:\n" .. tostring(text))
end

local function profile_set()
    local reviewer = {
        name = "reviewer",
        description = "Reviews changes",
        model = "anthropic:sonnet",
        reasoning = "high",
        body = "You are a reviewer.\n",
    }
    local scout = { name = "scout", description = "", body = "" }
    return {
        list = { reviewer, scout },
        by_name = { reviewer = reviewer, scout = scout },
        warnings = {},
    }
end

-- with_host(fn, opts): install the fake, run fn(handle, profiles), restore.
local function with_host(fn, opts)
    local handle = fake.install(opts)
    local ok, err = pcall(fn, handle, profile_set())
    handle.restore()
    if not ok then
        error(err, 0)
    end
end

-- The child conversation a resumed reviewer loads: the manifest on its profile
-- system message, and the model/reasoning it last ran with on its last turn.
local function stored_reviewer()
    return {
        { role = "system", text = spawn.CHILD_ROLE },
        {
            role = "system",
            text = "You are a reviewer.\n",
            metadata = { subagents = { owner = "0198-primary", agent = "reviewer" } },
        },
        {
            role = "user",
            text = "the first turn",
            metadata = { subagents = { model = "openai:gpt-5.6", reasoning = "xhigh" } },
        },
        { role = "assistant", text = "first answer" },
    }
end

return {
    { "neither agent nor id is refused before anything is spawned", function()
        with_host(function(handle, profiles)
            local text = run.handle({ prompt = "do the thing" }, profiles)
            has(text, "Error:")
            has(text, "exactly one of `agent`")
            assert(#handle.spawns == 0, "nothing may be spawned by a rejected call")
            assert(not text:find("id:", 1, true), "a pre-allocation failure has no id")
        end)
    end },

    { "multiple selectors are refused", function()
        with_host(function(handle, profiles)
            local text = run.handle({ agent = "reviewer", id = "0198-x", prompt = "go" }, profiles)
            has(text, "Error:")
            has(text, "exactly one of `agent`")
            assert(#handle.spawns == 0)

            text = run.handle({ agent = "reviewer", system_prompt = "Be concise.", prompt = "go" }, profiles)
            has(text, "exactly one of `agent`")
            assert(#handle.spawns == 0)
        end)
    end },

    { "an empty or missing prompt is refused", function()
        with_host(function(handle, profiles)
            has(run.handle({ agent = "reviewer", prompt = "" }, profiles), "`prompt` must be a non-empty string")
            has(run.handle({ agent = "reviewer", prompt = "   " }, profiles), "`prompt` must be a non-empty string")
            has(run.handle({ agent = "reviewer" }, profiles), "`prompt` must be a non-empty string")
            assert(#handle.spawns == 0)
        end)
    end },

    { "an unknown agent names the known profiles", function()
        with_host(function(handle, profiles)
            local text = run.handle({ agent = "ghost", prompt = "go" }, profiles)
            has(text, "unknown agent 'ghost'")
            has(text, "reviewer, scout")
            assert(#handle.spawns == 0)
        end)
    end },

    { "a new child is seeded with the child-role and profile system messages", function()
        with_host(function(handle, profiles)
            run.handle({ agent = "reviewer", prompt = "Review the auth change." }, profiles)
            assert(#handle.spawns == 1, "expected exactly one child")
            local child = handle.spawns[1]

            assert(child.store_dir == handle.session.session_dir .. "/subagents/" .. handle.session.session_id,
                "wrong child store dir: " .. tostring(child.store_dir))
            assert(child.session_id == nil, "a new child must not name a session")
            assert(child.prompt == "Review the auth change.", tostring(child.prompt))

            local messages = child.system_messages
            assert(type(messages) == "table" and #messages == 2, "expected role + profile messages, saw " .. #messages)
            assert(messages[1].text == spawn.CHILD_ROLE, "the first message is the fixed child role")
            assert(messages[1].metadata == nil, "the role message carries no manifest")
            assert(messages[2].text == "You are a reviewer.\n", "the profile body is the second message")
            local manifest = messages[2].metadata.subagents
            assert(manifest.owner == handle.session.session_id, tostring(manifest.owner))
            assert(manifest.agent == "reviewer", tostring(manifest.agent))
        end)
    end },

    { "an inline system prompt starts a child without a profile", function()
        with_host(function(handle, profiles)
            local text = run.handle({ system_prompt = "Be a focused investigator.", prompt = "Find the cause." }, profiles)
            assert(#handle.spawns == 1)
            local messages = handle.spawns[1].system_messages
            assert(#messages == 2, "expected role and inline prompt")
            assert(messages[1].text == spawn.CHILD_ROLE)
            assert(messages[2].text == "Be a focused investigator.")
            local manifest = messages[2].metadata.subagents
            assert(manifest.agent == "subagent", tostring(manifest.agent))
            assert(manifest.inline == true, "inline prompts must remain identifiable on replay")
            has(text, "agent: subagent")
        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 and dialogue are not copied", function()
        with_host(function(handle, profiles)
            run.handle({ agent = "reviewer", prompt = "go" }, profiles)
            local messages = handle.spawns[1].system_messages
            assert(#messages == 2, "expected only role and profile messages, saw " .. #messages)
            assert(messages[1].text == spawn.CHILD_ROLE, "the child role is first")
            assert(messages[2].text == "You are a reviewer.\n", "the profile defines the child context")
            for _, message in ipairs(messages) do
                assert(message.text ~= "Primary core prompt.", "the primary system prompt must not reach the child")
                assert(message.text ~= "Project context.", "primary system additions must not be copied")
            end
        end, {
            primary_messages = {
                { role = "system", text = "Primary core prompt." },
                { role = "user", text = "the parent dialogue is never copied" },
                { role = "assistant", text = "nor this" },
                { role = "system", text = "Project context." },
            },
        })
    end },

    { "the child store directory is created before the store is opened", function()
        with_host(function(handle, profiles)
            run.handle({ agent = "reviewer", prompt = "go" }, profiles)
            local dir = handle.session.session_dir .. "/subagents/" .. handle.session.session_id
            assert(handle.made_dir(dir), "the child catalog is created, not assumed: " ..
                table.concat(handle.mkdirs, ", "))
            assert(handle.stores[1] == dir, "the store opens on that directory: " .. tostring(handle.stores[1]))
        end)
    end },

    { "a child inherits the primary's tools except the subagents ones", function()
        with_host(function(handle, profiles)
            run.handle({ agent = "reviewer", prompt = "go" }, profiles)
            local child = handle.spawns[1]
            assert(table.concat(child.tools, ",") == "bash,read_file",
                "expected only the non-subagents tools, saw " .. table.concat(child.tools, ","))
            for _, decl in ipairs(child.tool_decls) do
                assert(decl._source == fake.SOURCE, "the re-registration tag must be passed through untouched")
            end
        end)
    end },

    { "the resolved model and reasoning are recorded on the turn", function()
        with_host(function(handle, profiles)
            run.handle({ agent = "reviewer", prompt = "go" }, profiles)
            local metadata = handle.spawns[1].run.metadata
            assert(type(metadata) == "table" and type(metadata.subagents) == "table",
                "every child turn records what it ran on")
            assert(metadata.subagents.model == "anthropic:sonnet", tostring(metadata.subagents.model))
            assert(metadata.subagents.reasoning == "high", tostring(metadata.subagents.reasoning))
            assert(handle.spawns[1].run.dispatch_tools ~= false, "an ordinary child dispatches its tools")
        end)
    end },

    { "the child-role instruction states the contract the design fixes", function()
        local role = spawn.CHILD_ROLE
        has(role, "You are a subagent")
        has(role, "self-contained report")
        has(role, "verbatim")
        has(role, "cannot ask the user questions")
    end },

    { "model and reasoning resolve tool over profile over inherited", function()
        with_host(function(handle, profiles)
            run.handle({ agent = "reviewer", prompt = "a" }, profiles)
            assert(handle.spawns[1].model == "anthropic:sonnet", "the profile model applies")
            assert(handle.spawns[1].reasoning == "high", "the profile reasoning applies")

            run.handle({ agent = "reviewer", prompt = "b", model = "openai:gpt-5.6", reasoning = "xhigh" }, profiles)
            assert(handle.spawns[2].model == "openai:gpt-5.6", "the call overrides the profile")
            assert(handle.spawns[2].reasoning == "xhigh", "the call overrides the profile")

            run.handle({ agent = "reviewer", prompt = "d", reasoning = "low" }, profiles)
            assert(handle.spawns[3].model == "anthropic:sonnet", "model and reasoning resolve independently")
            assert(handle.spawns[3].reasoning == "low")
        end)
    end },

    { "a profile that names neither inherits the primary's pair", function()
        with_host(function(handle, profiles)
            run.handle({ agent = "scout", prompt = "look around" }, profiles)
            assert(handle.spawns[1].model == "openai:gpt-5.6", tostring(handle.spawns[1].model))
            assert(handle.spawns[1].reasoning == "low", tostring(handle.spawns[1].reasoning))
        end, { session = { model = "openai:gpt-5.6", reasoning = "low" } })
    end },

    { "a profile with an empty body contributes no system message", function()
        with_host(function(handle, profiles)
            run.handle({ agent = "scout", prompt = "look around" }, profiles)
            local messages = handle.spawns[1].system_messages
            assert(#messages == 1, "expected only the child-role message, saw " .. #messages)
            assert(messages[1].text == spawn.CHILD_ROLE)
        end)
    end },

    { "a resume loads the stored conversation and seeds nothing", function()
        with_host(function(handle, profiles)
            handle.add_session("0198-child", stored_reviewer())
            handle.queue({ output = "second turn" })

            local text = run.handle({ id = "0198-child", prompt = "now the tests" }, profiles)
            local child = handle.spawns[1]
            assert(child.session_id == "0198-child", tostring(child.session_id))
            assert(child.resumed, "the agent is built on the resolved session")
            assert(#child.system_messages == 0, "the stored conversation is canonical on resume")
            assert(child.store_dir == handle.session.session_dir .. "/subagents/" .. handle.session.session_id)
            has(text, "id: 0198-child")
            has(text, "second turn")
        end)
    end },

    { "a resumed child keeps the model and reasoning of its last turn", function()
        with_host(function(handle, profiles)
            handle.add_session("0198-child", stored_reviewer())
            handle.queue({})
            run.handle({ id = "0198-child", prompt = "carry on" }, profiles)
            assert(handle.spawns[1].model == "openai:gpt-5.6", tostring(handle.spawns[1].model))
            assert(handle.spawns[1].reasoning == "xhigh", tostring(handle.spawns[1].reasoning))
        end)
    end },

    { "a per-turn override beats the stored default and only where it is given", function()
        with_host(function(handle, profiles)
            handle.add_session("0198-child", stored_reviewer())
            handle.queue({})
            run.handle({ id = "0198-child", prompt = "carry on", reasoning = "high" }, profiles)
            assert(handle.spawns[1].reasoning == "high", "the call overrides the stored default")
            assert(handle.spawns[1].model == "openai:gpt-5.6", "the model keeps its last effective value")
        end)
    end },

    -- The binding reports a stored record it cannot decode by raising. A
    -- malformed message is skipped, and nothing here raises out to the tool.
    { "stored metadata that cannot be decoded is skipped, not raised", function()
        with_host(function(handle, profiles)
            local stored = stored_reviewer()
            for _, message in ipairs(stored) do
                if message.metadata then
                    message.metadata = nil
                    message.metadata_error = "message_metadata: invalid JSON"
                end
            end
            handle.add_session("0198-child", stored)
            handle.queue({ output = "second turn" })

            local text = run.handle({ id = "0198-child", prompt = "carry on" }, profiles)
            assert(not text:find("Error:", 1, true), "a malformed record is not a tool error:\n" .. text)
            has(text, "second turn")
            has(text, "agent: ?") -- an unreadable manifest names no agent
            assert(handle.spawns[1].model == "anthropic:sonnet",
                "an unreadable stored default falls back to the primary's model, got " ..
                tostring(handle.spawns[1].model))
        end)
    end },

    { "a resumed child takes its agent name from the manifest", function()
        with_host(function(handle, profiles)
            handle.add_session("0198-child", stored_reviewer())
            handle.queue({ output = "done" })
            local text = run.handle({ id = "0198-child", prompt = "carry on" }, profiles)
            has(text, "agent: reviewer")
        end)
    end },

    { "an id this session never started is refused before anything is built", function()
        with_host(function(handle, profiles)
            local text = run.handle({ id = "0198-nope", prompt = "carry on" }, profiles)
            has(text, "Error:")
            has(text, "unknown subagent id '0198-nope'")
            assert(#handle.spawns == 0, "no child is built for an id that does not resolve")
            assert(#handle.runs == 0, "and no turn is started")
        end)
    end },

    { "a completed result renders every field", function()
        with_host(function(handle, profiles)
            handle.queue({ id = "0198-abc", status = "completed", output = "Found two issues." })
            local text = run.handle({ agent = "reviewer", prompt = "review" }, profiles)
            assert(text == table.concat({
                "id: 0198-abc",
                "agent: reviewer",
                "status: completed",
                "resumable: true",
                "--- output ---",
                "Found two issues.",
            }, "\n"), "unexpected block:\n" .. text)
        end)
    end },

    { "a failed first turn reports the error and is not resumable", function()
        with_host(function(handle, profiles)
            handle.queue({ id = "0198-def", status = "failed", error = "provider refused", resumable = false })
            local text = run.handle({ agent = "reviewer", prompt = "review" }, profiles)
            has(text, "status: failed")
            has(text, "resumable: false")
            has(text, "provider refused")
        end)
    end },

    { "resumability is read back from the store after the turn settles", function()
        with_host(function(handle, profiles)
            handle.queue({ id = "0198-ghi", output = "wrote something" })
            has(run.handle({ agent = "reviewer", prompt = "review" }, profiles), "resumable: true")
            assert(handle.sessions["0198-ghi"], "a durable child leaves a session behind")

            handle.queue({ id = "0198-jkl", output = "died young", resumable = false })
            has(run.handle({ agent = "reviewer", prompt = "review" }, profiles), "resumable: false")
            assert(handle.sessions["0198-jkl"] == nil, "no file, no continuation")
        end)
    end },

    { "a cancelled child settles as cancelled", function()
        with_host(function(handle, profiles)
            handle.queue({ id = "0198-ghi", status = "cancelled", error = "cancelled by the user" })
            local text = run.handle({ agent = "reviewer", prompt = "review" }, profiles)
            has(text, "status: cancelled")
            has(text, "cancelled by the user")
        end)
    end },

    { "a model the host cannot resolve is reported and starts nothing", function()
        with_host(function(handle, profiles)
            local text = run.handle({ agent = "reviewer", prompt = "review", model = "openai:ghost" }, profiles)
            assert(text == "Error: resolve_model: unknown model 'openai:ghost'", text)
            assert(#handle.resolves == 1, "the host decides what a model reference means, not the rock")
            assert(handle.resolves[1].model == "openai:ghost", tostring(handle.resolves[1].model))
            assert(#handle.spawns == 0, "the child is never built")
            assert(#handle.runs == 0, "a rejected child is never started")
        end, { unknown_models = { ["openai:ghost"] = true } })
    end },
}