v0.16.0 042538b

subagent_spawn_test.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"path/filepath"
8	"strings"
9	"testing"
10	"time"
11
12	"github.com/bnema/zut/packages/agent/subagents"
13	"github.com/bnema/zut/packages/provider"
14)
15
16type noopSupervisorRunner struct{}
17
18func (noopSupervisorRunner) Run(context.Context, subagents.Sink) error { return nil }
19
20func newTestSupervisor(t *testing.T) *subagents.Supervisor {
21	t.Helper()
22	root := t.TempDir()
23	manager := subagents.New(subagents.Config{
24		Root:     filepath.Join(root, "subagents"),
25		RepoRoot: root,
26		NewRunner: func(*subagents.Agent) subagents.Runner {
27			return noopSupervisorRunner{}
28		},
29	})
30	t.Cleanup(manager.StopAll)
31	return manager
32}
33
34func TestSubagentSpawnGuidanceUsesHostCompletionUpdatesWithoutPolling(t *testing.T) {
35	tool := &SubagentSpawnTool{
36		Supervisor: newTestSupervisor(t),
37		Enabled:    func() bool { return true },
38	}
39	for _, want := range []string{
40		"host-event-driven",
41		"[auto-subagents update]",
42		"bash sleep",
43		"watch",
44		"tail -f",
45		"polling loops",
46		"repeated subagent_status",
47		"dashboard/metadata/event-log/file checks",
48		"unrelated independent tasks",
49		"end/yield your turn",
50	} {
51		if !strings.Contains(tool.Description(), want) {
52			t.Fatalf("spawn description missing %q: %s", want, tool.Description())
53		}
54	}
55
56	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
57	if err != nil {
58		t.Fatal(err)
59	}
60	if res.IsError {
61		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
62	}
63	text := textResult(res.Content)
64	for _, want := range []string{
65		"host-event-driven",
66		"[auto-subagents update]",
67		"the only completion signal",
68		"bash sleep",
69		"watch",
70		"tail -f",
71		"polling loops",
72		"repeated subagent_status",
73		"dashboard/metadata/event-log/file checks",
74		"unrelated independent tasks",
75		"end or yield your turn",
76		"user-requested commands, provider flows, extensions, or tests",
77	} {
78		if !strings.Contains(text, want) {
79			t.Fatalf("spawn result missing %q:\n%s", want, text)
80		}
81	}
82}
83
84func TestSubagentSpawnSchemaKeepsManagerBudgetsOutOfModelControl(t *testing.T) {
85	tool := &SubagentSpawnTool{Supervisor: newTestSupervisor(t)}
86	var schema struct {
87		Properties map[string]json.RawMessage `json:"properties"`
88	}
89	if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
90		t.Fatal(err)
91	}
92	for _, property := range []string{"timeout", "max_turns", "max_steps"} {
93		if _, found := schema.Properties[property]; found {
94			t.Fatalf("spawn schema exposes manager-owned property %q", property)
95		}
96	}
97}
98
99func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
100	tool := &SubagentSpawnTool{
101		Supervisor:       newTestSupervisor(t),
102		Enabled:          func() bool { return true },
103		DefaultModel:     func() string { return "gpt-5" },
104		DefaultProvider:  func() string { return "openai-codex" },
105		DefaultReasoning: func() string { return "medium" },
106	}
107
108	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
109	if err != nil {
110		t.Fatal(err)
111	}
112	if res.IsError {
113		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
114	}
115	details, ok := res.Details.(map[string]any)
116	if !ok {
117		t.Fatalf("details type = %T, want map[string]any", res.Details)
118	}
119	if got := details["model"]; got != "gpt-5" {
120		t.Fatalf("model detail = %v, want gpt-5", got)
121	}
122	if got := details["provider"]; got != "openai-codex" {
123		t.Fatalf("provider detail = %v, want openai-codex", got)
124	}
125	if got := details["reasoning"]; got != "medium" {
126		t.Fatalf("reasoning detail = %v, want medium", got)
127	}
128	if got := details["timeout"]; got != "unlimited" {
129		t.Fatalf("timeout detail = %v, want unlimited", got)
130	}
131	text := textResult(res.Content)
132	if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") {
133		t.Fatalf("result text missing inherited model/provider:\n%s", text)
134	}
135
136	agents := tool.Supervisor.List()
137	if len(agents) != 1 {
138		t.Fatalf("spawned agents = %d, want 1", len(agents))
139	}
140	if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" {
141		t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning)
142	}
143}
144
145func TestSubagentSpawnDetailsUseEffectiveTimeout(t *testing.T) {
146	root := t.TempDir()
147	manager := subagents.New(subagents.Config{
148		Root:     filepath.Join(root, "subagents"),
149		RepoRoot: root,
150		Policy: subagents.SubagentPolicy{
151			DefaultTimeout: 37 * time.Minute,
152		},
153		NewRunner: func(*subagents.Agent) subagents.Runner {
154			return noopSupervisorRunner{}
155		},
156	})
157	t.Cleanup(manager.StopAll)
158	tool := &SubagentSpawnTool{
159		Supervisor: manager,
160		Enabled:    func() bool { return true },
161	}
162
163	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
164	if err != nil {
165		t.Fatal(err)
166	}
167	if res.IsError {
168		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
169	}
170	details, ok := res.Details.(map[string]any)
171	if !ok {
172		t.Fatalf("details type = %T, want map[string]any", res.Details)
173	}
174	if got := details["timeout"]; got != (37 * time.Minute).String() {
175		t.Fatalf("omitted timeout detail = %v, want %s", got, (37 * time.Minute).String())
176	}
177	if _, found := details["max_turns"]; found {
178		t.Fatalf("details retain removed max_turns: %#v", details)
179	}
180	if _, found := details["max_steps"]; found {
181		t.Fatalf("details retain removed max_steps: %#v", details)
182	}
183}
184
185func TestSubagentSpawnRejectsUnknownExecutionLimitFields(t *testing.T) {
186	manager := newTestSupervisor(t)
187	tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
188	for _, field := range []string{"timeout", "max_turns", "max_steps"} {
189		_, err := tool.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"task":"research docs",%q:1}`, field)), nil)
190		if err == nil || !strings.Contains(err.Error(), "unknown field") {
191			t.Fatalf("field %q error = %v, want unknown field", field, err)
192		}
193	}
194	if agents := manager.List(); len(agents) != 0 {
195		t.Fatalf("unknown execution limits spawned agents: %#v", agents)
196	}
197}
198
199func TestSubagentSpawnSelectsProfileAndReasoning(t *testing.T) {
200	tool := &SubagentSpawnTool{
201		Supervisor: newTestSupervisor(t),
202		Enabled:    func() bool { return true },
203		ResolveSubagent: func(name string) (*subagents.Profile, error) {
204			if name != "reviewer" {
205				return nil, nil
206			}
207			return &subagents.Profile{
208				Name:     "reviewer",
209				Model:    "openai-codex/gpt-5.6-luna",
210				Thinking: "low",
211			}, nil
212		},
213	}
214
215	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer","reasoning":"high"}`), nil)
216	if err != nil {
217		t.Fatal(err)
218	}
219	if res.IsError {
220		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
221	}
222	text := textResult(res.Content)
223	for _, want := range []string{"agent: reviewer", "model: gpt-5.6-luna", "provider: openai-codex", "reasoning: high"} {
224		if !strings.Contains(text, want) {
225			t.Fatalf("result text missing %q:\n%s", want, text)
226		}
227	}
228	agents := tool.Supervisor.List()
229	if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" {
230		t.Fatalf("agent profile/reasoning = %#v", agents)
231	}
232	if agents[0].Model != "gpt-5.6-luna" || agents[0].Provider != "openai-codex" {
233		t.Fatalf("agent model/provider = %q/%q, want gpt-5.6-luna/openai-codex", agents[0].Model, agents[0].Provider)
234	}
235}
236
237func TestSubagentSpawnAppliesProfileFastModeRestriction(t *testing.T) {
238	root := t.TempDir()
239	profileFastMode := false
240	f := subagents.New(subagents.Config{
241		Root:     filepath.Join(root, "subagents"),
242		RepoRoot: root,
243		FastMode: true,
244		NewRunner: func(*subagents.Agent) subagents.Runner {
245			return noopSupervisorRunner{}
246		},
247	})
248	t.Cleanup(f.StopAll)
249	tool := &SubagentSpawnTool{
250		Supervisor: f,
251		Enabled:    func() bool { return true },
252		ResolveSubagent: func(name string) (*subagents.Profile, error) {
253			if name != "reviewer" {
254				return nil, nil
255			}
256			return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil
257		},
258	}
259
260	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil)
261	if err != nil {
262		t.Fatal(err)
263	}
264	if res.IsError {
265		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
266	}
267	agents := f.List()
268	if len(agents) != 1 || agents[0].FastMode {
269		t.Fatalf("agent fast mode = %#v, want disabled by profile", agents)
270	}
271	if got := res.Details.(map[string]any)["fast_mode"]; got != false {
272		t.Fatalf("fast_mode detail = %v, want false", got)
273	}
274}
275
276func TestSubagentSpawnSchemaUsesReasoningAndKeepsProfileThinkingInternal(t *testing.T) {
277	var schema struct {
278		Properties map[string]struct {
279			Type        string `json:"type"`
280			Description string `json:"description"`
281		} `json:"properties"`
282	}
283	if err := json.Unmarshal((&SubagentSpawnTool{}).Schema(), &schema); err != nil {
284		t.Fatal(err)
285	}
286	if _, ok := schema.Properties["reasoning"]; !ok {
287		t.Fatal("subagent_spawn schema does not advertise reasoning")
288	}
289	if _, ok := schema.Properties["thinking"]; ok {
290		t.Fatal("subagent_spawn schema exposes profile-only thinking")
291	}
292	if _, ok := schema.Properties["timeout"]; ok {
293		t.Fatal("subagent_spawn schema exposes manager-owned turn timeout")
294	}
295	fastMode, ok := schema.Properties["fast_mode"]
296	if !ok {
297		t.Fatal("subagent_spawn schema does not advertise fast_mode")
298	}
299	if fastMode.Type != "boolean" {
300		t.Fatalf("fast_mode type = %q, want boolean", fastMode.Type)
301	}
302	if !strings.Contains(strings.ToLower(fastMode.Description), "inherit") {
303		t.Fatalf("fast_mode description = %q, want inheritance guidance", fastMode.Description)
304	}
305}
306
307func TestSubagentSpawnExplicitFastModeOverridesProfile(t *testing.T) {
308	for _, tc := range []struct {
309		name        string
310		profileFast bool
311		spawnFast   bool
312	}{
313		{name: "enable", profileFast: false, spawnFast: true},
314		{name: "disable", profileFast: true, spawnFast: false},
315	} {
316		t.Run(tc.name, func(t *testing.T) {
317			profileFast := tc.profileFast
318			tool := &SubagentSpawnTool{
319				Supervisor: newTestSupervisor(t),
320				Enabled:    func() bool { return true },
321				ResolveSubagent: func(name string) (*subagents.Profile, error) {
322					return &subagents.Profile{Name: name, FastMode: &profileFast}, nil
323				},
324			}
325
326			raw := fmt.Sprintf(`{"task":"review auth","agent":"worker","fast_mode":%t}`, tc.spawnFast)
327			res, err := tool.Execute(context.Background(), json.RawMessage(raw), nil)
328			if err != nil {
329				t.Fatal(err)
330			}
331			if res.IsError {
332				t.Fatalf("unexpected tool error: %s", textResult(res.Content))
333			}
334			agents := tool.Supervisor.List()
335			if len(agents) != 1 || agents[0].FastMode != tc.spawnFast {
336				t.Fatalf("agent fast mode = %#v, want %t", agents, tc.spawnFast)
337			}
338		})
339	}
340}
341
342func TestSubagentSpawnProfileFastModeOverridesGlobalOffWithWarning(t *testing.T) {
343	root := t.TempDir()
344	profileFastMode := true
345	f := subagents.New(subagents.Config{
346		Root:     filepath.Join(root, "subagents"),
347		RepoRoot: root,
348		NewRunner: func(*subagents.Agent) subagents.Runner {
349			return noopSupervisorRunner{}
350		},
351	})
352	t.Cleanup(f.StopAll)
353	tool := &SubagentSpawnTool{
354		Supervisor: f,
355		Enabled:    func() bool { return true },
356		ResolveSubagent: func(name string) (*subagents.Profile, error) {
357			if name != "fast-worker" {
358				return nil, nil
359			}
360			return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil
361		},
362	}
363
364	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"fast-worker"}`), nil)
365	if err != nil {
366		t.Fatal(err)
367	}
368	if res.IsError {
369		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
370	}
371	agents := f.List()
372	if len(agents) != 1 || !agents[0].FastMode {
373		t.Fatalf("agent fast mode = %#v, want enabled by profile", agents)
374	}
375	if got := res.Details.(map[string]any)["fast_mode"]; got != true {
376		t.Fatalf("fast_mode detail = %v, want true", got)
377	}
378	if text := textResult(res.Content); !strings.Contains(text, "warning: subagent profile has fast mode enabled, overriding global fast mode off") {
379		t.Fatalf("spawn result missing profile override warning:\n%s", text)
380	}
381}
382
383func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
384	tool := &SubagentSpawnTool{
385		Supervisor: newTestSupervisor(t),
386		Enabled:    func() bool { return true },
387		ResolveSubagent: func(name string) (*subagents.Profile, error) {
388			if name != "reviewer" {
389				return nil, nil
390			}
391			return &subagents.Profile{
392				Name:     "reviewer",
393				Model:    "openai-codex/gpt-5.6-luna",
394				Thinking: "low",
395			}, nil
396		},
397	}
398
399	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil)
400	if err != nil {
401		t.Fatal(err)
402	}
403	if res.IsError {
404		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
405	}
406	agents := tool.Supervisor.List()
407	if len(agents) != 1 || agents[0].Reasoning != "low" {
408		t.Fatalf("agent reasoning = %#v, want low", agents)
409	}
410}
411
412func TestSubagentSpawnRejectsUnknownProfile(t *testing.T) {
413	tool := &SubagentSpawnTool{
414		Supervisor: newTestSupervisor(t),
415		Enabled:    func() bool { return true },
416		ResolveSubagent: func(string) (*subagents.Profile, error) {
417			return nil, nil
418		},
419	}
420	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"missing"}`), nil)
421	if err != nil {
422		t.Fatal(err)
423	}
424	if !res.IsError || !strings.Contains(textResult(res.Content), "unknown subagent profile") {
425		t.Fatalf("result = %#v, want unknown profile error", res)
426	}
427	if got := len(tool.Supervisor.List()); got != 0 {
428		t.Fatalf("spawned agents = %d, want 0", got)
429	}
430}
431
432func TestSubagentSpawnNormalizesMatchingQualifiedModelOverride(t *testing.T) {
433	tool := &SubagentSpawnTool{
434		Supervisor: newTestSupervisor(t),
435		Enabled:    func() bool { return true },
436	}
437
438	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","provider":"openai-codex","model":"openai-codex/gpt-5.6-sol"}`), nil)
439	if err != nil {
440		t.Fatal(err)
441	}
442	if res.IsError {
443		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
444	}
445	agents := tool.Supervisor.List()
446	if len(agents) != 1 {
447		t.Fatalf("spawned agents = %d, want 1", len(agents))
448	}
449	if got := agents[0].Model; got != "gpt-5.6-sol" {
450		t.Fatalf("agent model = %q, want unqualified model ID", got)
451	}
452	if got := agents[0].Provider; got != "openai-codex" {
453		t.Fatalf("agent provider = %q, want openai-codex", got)
454	}
455}
456
457func TestSubagentSpawnRejectsPartialModelProviderOverride(t *testing.T) {
458	tool := &SubagentSpawnTool{
459		Supervisor:      newTestSupervisor(t),
460		Enabled:         func() bool { return true },
461		DefaultModel:    func() string { return "gpt-5" },
462		DefaultProvider: func() string { return "openai-codex" },
463	}
464
465	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","provider":"openai"}`), nil)
466	if err != nil {
467		t.Fatal(err)
468	}
469	if !res.IsError {
470		t.Fatalf("expected partial override to fail")
471	}
472	if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") {
473		t.Fatalf("error text = %q", got)
474	}
475	if got := len(tool.Supervisor.List()); got != 0 {
476		t.Fatalf("spawned agents = %d, want 0", got)
477	}
478}
479
480func textResult(content []provider.Content) string {
481	if len(content) == 0 {
482		return ""
483	}
484	if tb, ok := content[0].(provider.TextBlock); ok {
485		return tb.Text
486	}
487	return ""
488}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close