v0.11.4 020512d

subagent_spawn_test.go RawHistoryFolder

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

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