v0.15.0 6b178f8

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 TestSubagentSpawnSchemaUsesReasoningAndKeepsProfileThinkingInternal(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	if _, ok := schema.Properties["reasoning"]; !ok {
364		t.Fatal("subagent_spawn schema does not advertise reasoning")
365	}
366	if _, ok := schema.Properties["thinking"]; ok {
367		t.Fatal("subagent_spawn schema exposes profile-only thinking")
368	}
369	fastMode, ok := schema.Properties["fast_mode"]
370	if !ok {
371		t.Fatal("subagent_spawn schema does not advertise fast_mode")
372	}
373	if fastMode.Type != "boolean" {
374		t.Fatalf("fast_mode type = %q, want boolean", fastMode.Type)
375	}
376	if !strings.Contains(strings.ToLower(fastMode.Description), "inherit") {
377		t.Fatalf("fast_mode description = %q, want inheritance guidance", fastMode.Description)
378	}
379}
380
381func TestSubagentSpawnExplicitFastModeOverridesProfile(t *testing.T) {
382	for _, tc := range []struct {
383		name        string
384		profileFast bool
385		spawnFast   bool
386	}{
387		{name: "enable", profileFast: false, spawnFast: true},
388		{name: "disable", profileFast: true, spawnFast: false},
389	} {
390		t.Run(tc.name, func(t *testing.T) {
391			profileFast := tc.profileFast
392			tool := &SubagentSpawnTool{
393				Supervisor: newTestSupervisor(t),
394				Enabled:    func() bool { return true },
395				ResolveSubagent: func(name string) (*subagents.Profile, error) {
396					return &subagents.Profile{Name: name, FastMode: &profileFast}, nil
397				},
398			}
399
400			raw := fmt.Sprintf(`{"task":"review auth","agent":"worker","fast_mode":%t}`, tc.spawnFast)
401			res, err := tool.Execute(context.Background(), json.RawMessage(raw), nil)
402			if err != nil {
403				t.Fatal(err)
404			}
405			if res.IsError {
406				t.Fatalf("unexpected tool error: %s", textResult(res.Content))
407			}
408			agents := tool.Supervisor.List()
409			if len(agents) != 1 || agents[0].FastMode != tc.spawnFast {
410				t.Fatalf("agent fast mode = %#v, want %t", agents, tc.spawnFast)
411			}
412		})
413	}
414}
415
416func TestSubagentSpawnProfileFastModeOverridesGlobalOffWithWarning(t *testing.T) {
417	root := t.TempDir()
418	profileFastMode := true
419	f := subagents.New(subagents.Config{
420		Root:     filepath.Join(root, "subagents"),
421		RepoRoot: root,
422		NewRunner: func(*subagents.Agent) subagents.Runner {
423			return noopSupervisorRunner{}
424		},
425	})
426	t.Cleanup(f.StopAll)
427	tool := &SubagentSpawnTool{
428		Supervisor: f,
429		Enabled:    func() bool { return true },
430		ResolveSubagent: func(name string) (*subagents.Profile, error) {
431			if name != "fast-worker" {
432				return nil, nil
433			}
434			return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil
435		},
436	}
437
438	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"fast-worker"}`), 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 := f.List()
446	if len(agents) != 1 || !agents[0].FastMode {
447		t.Fatalf("agent fast mode = %#v, want enabled by profile", agents)
448	}
449	if got := res.Details.(map[string]any)["fast_mode"]; got != true {
450		t.Fatalf("fast_mode detail = %v, want true", got)
451	}
452	if text := textResult(res.Content); !strings.Contains(text, "warning: subagent profile has fast mode enabled, overriding global fast mode off") {
453		t.Fatalf("spawn result missing profile override warning:\n%s", text)
454	}
455}
456
457func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
458	tool := &SubagentSpawnTool{
459		Supervisor: newTestSupervisor(t),
460		Enabled:    func() bool { return true },
461		ResolveSubagent: func(name string) (*subagents.Profile, error) {
462			if name != "reviewer" {
463				return nil, nil
464			}
465			return &subagents.Profile{
466				Name:     "reviewer",
467				Model:    "openai-codex/gpt-5.6-luna",
468				Thinking: "low",
469			}, nil
470		},
471	}
472
473	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil)
474	if err != nil {
475		t.Fatal(err)
476	}
477	if res.IsError {
478		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
479	}
480	agents := tool.Supervisor.List()
481	if len(agents) != 1 || agents[0].Reasoning != "low" {
482		t.Fatalf("agent reasoning = %#v, want low", agents)
483	}
484}
485
486func TestSubagentSpawnRejectsUnknownProfile(t *testing.T) {
487	tool := &SubagentSpawnTool{
488		Supervisor: newTestSupervisor(t),
489		Enabled:    func() bool { return true },
490		ResolveSubagent: func(string) (*subagents.Profile, error) {
491			return nil, nil
492		},
493	}
494	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"missing"}`), nil)
495	if err != nil {
496		t.Fatal(err)
497	}
498	if !res.IsError || !strings.Contains(textResult(res.Content), "unknown subagent profile") {
499		t.Fatalf("result = %#v, want unknown profile error", res)
500	}
501	if got := len(tool.Supervisor.List()); got != 0 {
502		t.Fatalf("spawned agents = %d, want 0", got)
503	}
504}
505
506func TestSubagentSpawnNormalizesMatchingQualifiedModelOverride(t *testing.T) {
507	tool := &SubagentSpawnTool{
508		Supervisor: newTestSupervisor(t),
509		Enabled:    func() bool { return true },
510	}
511
512	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","provider":"openai-codex","model":"openai-codex/gpt-5.6-sol"}`), nil)
513	if err != nil {
514		t.Fatal(err)
515	}
516	if res.IsError {
517		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
518	}
519	agents := tool.Supervisor.List()
520	if len(agents) != 1 {
521		t.Fatalf("spawned agents = %d, want 1", len(agents))
522	}
523	if got := agents[0].Model; got != "gpt-5.6-sol" {
524		t.Fatalf("agent model = %q, want unqualified model ID", got)
525	}
526	if got := agents[0].Provider; got != "openai-codex" {
527		t.Fatalf("agent provider = %q, want openai-codex", got)
528	}
529}
530
531func TestSubagentSpawnRejectsPartialModelProviderOverride(t *testing.T) {
532	tool := &SubagentSpawnTool{
533		Supervisor:      newTestSupervisor(t),
534		Enabled:         func() bool { return true },
535		DefaultModel:    func() string { return "gpt-5" },
536		DefaultProvider: func() string { return "openai-codex" },
537	}
538
539	res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","provider":"openai"}`), nil)
540	if err != nil {
541		t.Fatal(err)
542	}
543	if !res.IsError {
544		t.Fatalf("expected partial override to fail")
545	}
546	if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") {
547		t.Fatalf("error text = %q", got)
548	}
549	if got := len(tool.Supervisor.List()); got != 0 {
550		t.Fatalf("spawned agents = %d, want 0", got)
551	}
552}
553
554func textResult(content []provider.Content) string {
555	if len(content) == 0 {
556		return ""
557	}
558	if tb, ok := content[0].(provider.TextBlock); ok {
559		return tb.Text
560	}
561	return ""
562}

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