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}