v0.9.3 0c58a62

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

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