v0.12.2 1adbe2f

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 TestSubagentSpawnSchemaAdvertisesEffectiveMaxTurns(t *testing.T) {
85	for _, tc := range []struct {
86		name   string
87		policy int
88		want   int
89	}{
90		{name: "default", want: 3},
91		{name: "negative-default", policy: -1, want: 3},
92		{name: "configured", policy: 7, want: 7},
93	} {
94		t.Run(tc.name, func(t *testing.T) {
95			root := t.TempDir()
96			manager := subagents.New(subagents.Config{
97				Root:     filepath.Join(root, "subagents"),
98				RepoRoot: root,
99				Policy:   subagents.SubagentPolicy{MaxTurns: tc.policy},
100				NewRunner: func(*subagents.Agent) subagents.Runner {
101					return noopSupervisorRunner{}
102				},
103			})
104			t.Cleanup(manager.StopAll)
105			tool := &SubagentSpawnTool{Supervisor: manager}
106
107			var schema struct {
108				Properties struct {
109					MaxTurns struct {
110						Minimum     int    `json:"minimum"`
111						Maximum     int    `json:"maximum"`
112						Description string `json:"description"`
113					} `json:"max_turns"`
114				} `json:"properties"`
115			}
116			if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
117				t.Fatal(err)
118			}
119			maxTurns := schema.Properties.MaxTurns
120			if maxTurns.Minimum != 1 {
121				t.Fatalf("max_turns minimum = %d, want 1", maxTurns.Minimum)
122			}
123			if maxTurns.Maximum != tc.want {
124				t.Fatalf("max_turns maximum = %d, want %d", maxTurns.Maximum, tc.want)
125			}
126			if !strings.Contains(strings.ToLower(maxTurns.Description), "omit") {
127				t.Fatalf("max_turns description = %q, want omit-to-default guidance", maxTurns.Description)
128			}
129		})
130	}
131}
132
133func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
134	tool := &SubagentSpawnTool{
135		Supervisor:       newTestSupervisor(t),
136		Enabled:          func() bool { return true },
137		DefaultModel:     func() string { return "gpt-5" },
138		DefaultProvider:  func() string { return "openai-codex" },
139		DefaultReasoning: func() string { return "medium" },
140	}
141
142	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
143	if err != nil {
144		t.Fatal(err)
145	}
146	if res.IsError {
147		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
148	}
149	details, ok := res.Details.(map[string]any)
150	if !ok {
151		t.Fatalf("details type = %T, want map[string]any", res.Details)
152	}
153	if got := details["model"]; got != "gpt-5" {
154		t.Fatalf("model detail = %v, want gpt-5", got)
155	}
156	if got := details["provider"]; got != "openai-codex" {
157		t.Fatalf("provider detail = %v, want openai-codex", got)
158	}
159	if got := details["reasoning"]; got != "medium" {
160		t.Fatalf("reasoning detail = %v, want medium", got)
161	}
162	text := textResult(res.Content)
163	if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") {
164		t.Fatalf("result text missing inherited model/provider:\n%s", text)
165	}
166
167	agents := tool.Supervisor.List()
168	if len(agents) != 1 {
169		t.Fatalf("spawned agents = %d, want 1", len(agents))
170	}
171	if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" {
172		t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning)
173	}
174	if agents[0].MaxTurns != 3 {
175		t.Fatalf("omitted max_turns = %d, want default 3", agents[0].MaxTurns)
176	}
177}
178
179func TestSubagentSpawnDetailsUseEffectiveTimeoutAndTurns(t *testing.T) {
180	root := t.TempDir()
181	manager := subagents.New(subagents.Config{
182		Root:     filepath.Join(root, "subagents"),
183		RepoRoot: root,
184		Policy: subagents.SubagentPolicy{
185			DefaultTimeout: 37 * time.Minute,
186			MaxTurns:       7,
187		},
188		NewRunner: func(*subagents.Agent) subagents.Runner {
189			return noopSupervisorRunner{}
190		},
191	})
192	t.Cleanup(manager.StopAll)
193	tool := &SubagentSpawnTool{
194		Supervisor: manager,
195		Enabled:    func() bool { return true },
196	}
197
198	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
199	if err != nil {
200		t.Fatal(err)
201	}
202	if res.IsError {
203		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
204	}
205	details, ok := res.Details.(map[string]any)
206	if !ok {
207		t.Fatalf("details type = %T, want map[string]any", res.Details)
208	}
209	if got := details["timeout"]; got != (37 * time.Minute).String() {
210		t.Fatalf("omitted timeout detail = %v, want %s", got, (37 * time.Minute).String())
211	}
212	if got := details["max_turns"]; got != 7 {
213		t.Fatalf("max_turns detail = %v, want 7", got)
214	}
215}
216
217func TestSubagentSpawnRejectsExplicitZeroMaxTurns(t *testing.T) {
218	tool := &SubagentSpawnTool{
219		Supervisor: newTestSupervisor(t),
220		Enabled:    func() bool { return true },
221	}
222
223	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","max_turns":0}`), nil)
224	if err != nil {
225		t.Fatal(err)
226	}
227	if !res.IsError || !strings.Contains(textResult(res.Content), "max_turns must be positive") {
228		t.Fatalf("result = %#v, want max_turns validation error", res)
229	}
230	if got := len(tool.Supervisor.List()); got != 0 {
231		t.Fatalf("spawned agents = %d, want 0", got)
232	}
233}
234
235func TestSubagentSpawnRejectsMaxTurnsAbovePolicy(t *testing.T) {
236	for _, tc := range []struct {
237		name      string
238		policy    int
239		wantLimit string
240	}{
241		{name: "default", wantLimit: "3"},
242		{name: "configured", policy: 7, wantLimit: "7"},
243	} {
244		t.Run(tc.name, func(t *testing.T) {
245			root := t.TempDir()
246			manager := subagents.New(subagents.Config{
247				Root:     filepath.Join(root, "subagents"),
248				RepoRoot: root,
249				Policy:   subagents.SubagentPolicy{MaxTurns: tc.policy},
250				NewRunner: func(*subagents.Agent) subagents.Runner {
251					return noopSupervisorRunner{}
252				},
253			})
254			t.Cleanup(manager.StopAll)
255			tool := &SubagentSpawnTool{
256				Supervisor: manager,
257				Enabled:    func() bool { return true },
258			}
259
260			res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","max_turns":8}`), nil)
261			if err != nil {
262				t.Fatal(err)
263			}
264			text := textResult(res.Content)
265			want := "max_turns must be 1 through " + tc.wantLimit
266			if !res.IsError || !strings.Contains(text, want) || !strings.Contains(text, "omit") {
267				t.Fatalf("result = %#v, want model-visible policy guidance", res)
268			}
269			if got := len(tool.Supervisor.List()); got != 0 {
270				t.Fatalf("spawned agents = %d, want 0", got)
271			}
272		})
273	}
274}
275
276func TestSubagentSpawnSelectsProfileAndReasoning(t *testing.T) {
277	tool := &SubagentSpawnTool{
278		Supervisor: newTestSupervisor(t),
279		Enabled:    func() bool { return true },
280		ResolveSubagent: func(name string) (*subagents.Profile, error) {
281			if name != "reviewer" {
282				return nil, nil
283			}
284			return &subagents.Profile{
285				Name:     "reviewer",
286				Model:    "openai-codex/gpt-5.6-luna",
287				Thinking: "low",
288			}, nil
289		},
290	}
291
292	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer","reasoning":"high"}`), nil)
293	if err != nil {
294		t.Fatal(err)
295	}
296	if res.IsError {
297		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
298	}
299	text := textResult(res.Content)
300	for _, want := range []string{"agent: reviewer", "model: gpt-5.6-luna", "provider: openai-codex", "reasoning: high"} {
301		if !strings.Contains(text, want) {
302			t.Fatalf("result text missing %q:\n%s", want, text)
303		}
304	}
305	agents := tool.Supervisor.List()
306	if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" {
307		t.Fatalf("agent profile/reasoning = %#v", agents)
308	}
309	if agents[0].Model != "gpt-5.6-luna" || agents[0].Provider != "openai-codex" {
310		t.Fatalf("agent model/provider = %q/%q, want gpt-5.6-luna/openai-codex", agents[0].Model, agents[0].Provider)
311	}
312}
313
314func TestSubagentSpawnAppliesProfileFastModeRestriction(t *testing.T) {
315	root := t.TempDir()
316	profileFastMode := false
317	f := subagents.New(subagents.Config{
318		Root:     filepath.Join(root, "subagents"),
319		RepoRoot: root,
320		FastMode: true,
321		NewRunner: func(*subagents.Agent) subagents.Runner {
322			return noopSupervisorRunner{}
323		},
324	})
325	t.Cleanup(f.StopAll)
326	tool := &SubagentSpawnTool{
327		Supervisor: f,
328		Enabled:    func() bool { return true },
329		ResolveSubagent: func(name string) (*subagents.Profile, error) {
330			if name != "reviewer" {
331				return nil, nil
332			}
333			return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil
334		},
335	}
336
337	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil)
338	if err != nil {
339		t.Fatal(err)
340	}
341	if res.IsError {
342		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
343	}
344	agents := f.List()
345	if len(agents) != 1 || agents[0].FastMode {
346		t.Fatalf("agent fast mode = %#v, want disabled by profile", agents)
347	}
348	if got := res.Details.(map[string]any)["fast_mode"]; got != false {
349		t.Fatalf("fast_mode detail = %v, want false", got)
350	}
351}
352
353func TestSubagentSpawnSchemaAdvertisesFastModeOverride(t *testing.T) {
354	var schema struct {
355		Properties map[string]struct {
356			Type        string `json:"type"`
357			Description string `json:"description"`
358		} `json:"properties"`
359	}
360	if err := json.Unmarshal((&SubagentSpawnTool{}).Schema(), &schema); err != nil {
361		t.Fatal(err)
362	}
363	fastMode, ok := schema.Properties["fast_mode"]
364	if !ok {
365		t.Fatal("subagent_spawn schema does not advertise fast_mode")
366	}
367	if fastMode.Type != "boolean" {
368		t.Fatalf("fast_mode type = %q, want boolean", fastMode.Type)
369	}
370	if !strings.Contains(strings.ToLower(fastMode.Description), "inherit") {
371		t.Fatalf("fast_mode description = %q, want inheritance guidance", fastMode.Description)
372	}
373}
374
375func TestSubagentSpawnExplicitFastModeOverridesProfile(t *testing.T) {
376	for _, tc := range []struct {
377		name        string
378		profileFast bool
379		spawnFast   bool
380	}{
381		{name: "enable", profileFast: false, spawnFast: true},
382		{name: "disable", profileFast: true, spawnFast: false},
383	} {
384		t.Run(tc.name, func(t *testing.T) {
385			profileFast := tc.profileFast
386			tool := &SubagentSpawnTool{
387				Supervisor: newTestSupervisor(t),
388				Enabled:    func() bool { return true },
389				ResolveSubagent: func(name string) (*subagents.Profile, error) {
390					return &subagents.Profile{Name: name, FastMode: &profileFast}, nil
391				},
392			}
393
394			raw := fmt.Sprintf(`{"task":"review auth","agent":"worker","fast_mode":%t}`, tc.spawnFast)
395			res, err := tool.Execute(context.Background(), json.RawMessage(raw), nil)
396			if err != nil {
397				t.Fatal(err)
398			}
399			if res.IsError {
400				t.Fatalf("unexpected tool error: %s", textResult(res.Content))
401			}
402			agents := tool.Supervisor.List()
403			if len(agents) != 1 || agents[0].FastMode != tc.spawnFast {
404				t.Fatalf("agent fast mode = %#v, want %t", agents, tc.spawnFast)
405			}
406		})
407	}
408}
409
410func TestSubagentSpawnProfileFastModeOverridesGlobalOffWithWarning(t *testing.T) {
411	root := t.TempDir()
412	profileFastMode := true
413	f := subagents.New(subagents.Config{
414		Root:     filepath.Join(root, "subagents"),
415		RepoRoot: root,
416		NewRunner: func(*subagents.Agent) subagents.Runner {
417			return noopSupervisorRunner{}
418		},
419	})
420	t.Cleanup(f.StopAll)
421	tool := &SubagentSpawnTool{
422		Supervisor: f,
423		Enabled:    func() bool { return true },
424		ResolveSubagent: func(name string) (*subagents.Profile, error) {
425			if name != "fast-worker" {
426				return nil, nil
427			}
428			return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil
429		},
430	}
431
432	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"fast-worker"}`), nil)
433	if err != nil {
434		t.Fatal(err)
435	}
436	if res.IsError {
437		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
438	}
439	agents := f.List()
440	if len(agents) != 1 || !agents[0].FastMode {
441		t.Fatalf("agent fast mode = %#v, want enabled by profile", agents)
442	}
443	if got := res.Details.(map[string]any)["fast_mode"]; got != true {
444		t.Fatalf("fast_mode detail = %v, want true", got)
445	}
446	if text := textResult(res.Content); !strings.Contains(text, "warning: subagent profile has fast mode enabled, overriding global fast mode off") {
447		t.Fatalf("spawn result missing profile override warning:\n%s", text)
448	}
449}
450
451func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
452	tool := &SubagentSpawnTool{
453		Supervisor: newTestSupervisor(t),
454		Enabled:    func() bool { return true },
455		ResolveSubagent: func(name string) (*subagents.Profile, error) {
456			if name != "reviewer" {
457				return nil, nil
458			}
459			return &subagents.Profile{
460				Name:     "reviewer",
461				Model:    "openai-codex/gpt-5.6-luna",
462				Thinking: "low",
463			}, nil
464		},
465	}
466
467	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil)
468	if err != nil {
469		t.Fatal(err)
470	}
471	if res.IsError {
472		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
473	}
474	agents := tool.Supervisor.List()
475	if len(agents) != 1 || agents[0].Reasoning != "low" {
476		t.Fatalf("agent reasoning = %#v, want low", agents)
477	}
478}
479
480func TestSubagentSpawnRejectsUnknownProfile(t *testing.T) {
481	tool := &SubagentSpawnTool{
482		Supervisor: newTestSupervisor(t),
483		Enabled:    func() bool { return true },
484		ResolveSubagent: func(string) (*subagents.Profile, error) {
485			return nil, nil
486		},
487	}
488	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"missing"}`), nil)
489	if err != nil {
490		t.Fatal(err)
491	}
492	if !res.IsError || !strings.Contains(textResult(res.Content), "unknown subagent profile") {
493		t.Fatalf("result = %#v, want unknown profile error", res)
494	}
495	if got := len(tool.Supervisor.List()); got != 0 {
496		t.Fatalf("spawned agents = %d, want 0", got)
497	}
498}
499
500func TestSubagentSpawnNormalizesMatchingQualifiedModelOverride(t *testing.T) {
501	tool := &SubagentSpawnTool{
502		Supervisor: newTestSupervisor(t),
503		Enabled:    func() bool { return true },
504	}
505
506	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","provider":"openai-codex","model":"openai-codex/gpt-5.6-sol"}`), nil)
507	if err != nil {
508		t.Fatal(err)
509	}
510	if res.IsError {
511		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
512	}
513	agents := tool.Supervisor.List()
514	if len(agents) != 1 {
515		t.Fatalf("spawned agents = %d, want 1", len(agents))
516	}
517	if got := agents[0].Model; got != "gpt-5.6-sol" {
518		t.Fatalf("agent model = %q, want unqualified model ID", got)
519	}
520	if got := agents[0].Provider; got != "openai-codex" {
521		t.Fatalf("agent provider = %q, want openai-codex", got)
522	}
523}
524
525func TestSubagentSpawnRejectsPartialModelProviderOverride(t *testing.T) {
526	tool := &SubagentSpawnTool{
527		Supervisor:      newTestSupervisor(t),
528		Enabled:         func() bool { return true },
529		DefaultModel:    func() string { return "gpt-5" },
530		DefaultProvider: func() string { return "openai-codex" },
531	}
532
533	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","provider":"openai"}`), nil)
534	if err != nil {
535		t.Fatal(err)
536	}
537	if !res.IsError {
538		t.Fatalf("expected partial override to fail")
539	}
540	if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") {
541		t.Fatalf("error text = %q", got)
542	}
543	if got := len(tool.Supervisor.List()); got != 0 {
544		t.Fatalf("spawned agents = %d, want 0", got)
545	}
546}
547
548func textResult(content []provider.Content) string {
549	if len(content) == 0 {
550		return ""
551	}
552	if tb, ok := content[0].(provider.TextBlock); ok {
553		return tb.Text
554	}
555	return ""
556}

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 476

↑↓ moveenter openesc close