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}