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}