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 TestSubagentSpawnSchemaKeepsManagerBudgetsOutOfModelControl(t *testing.T) {
85 tool := &SubagentSpawnTool{Supervisor: newTestSupervisor(t)}
86 var schema struct {
87 Properties map[string]json.RawMessage `json:"properties"`
88 }
89 if err := json.Unmarshal(tool.Schema(), &schema); err != nil {
90 t.Fatal(err)
91 }
92 for _, property := range []string{"timeout", "max_turns", "max_steps"} {
93 if _, found := schema.Properties[property]; found {
94 t.Fatalf("spawn schema exposes manager-owned property %q", property)
95 }
96 }
97}
98
99func TestSubagentSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
100 tool := &SubagentSpawnTool{
101 Supervisor: newTestSupervisor(t),
102 Enabled: func() bool { return true },
103 DefaultModel: func() string { return "gpt-5" },
104 DefaultProvider: func() string { return "openai-codex" },
105 DefaultReasoning: func() string { return "medium" },
106 }
107
108 res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
109 if err != nil {
110 t.Fatal(err)
111 }
112 if res.IsError {
113 t.Fatalf("unexpected tool error: %s", textResult(res.Content))
114 }
115 details, ok := res.Details.(map[string]any)
116 if !ok {
117 t.Fatalf("details type = %T, want map[string]any", res.Details)
118 }
119 if got := details["model"]; got != "gpt-5" {
120 t.Fatalf("model detail = %v, want gpt-5", got)
121 }
122 if got := details["provider"]; got != "openai-codex" {
123 t.Fatalf("provider detail = %v, want openai-codex", got)
124 }
125 if got := details["reasoning"]; got != "medium" {
126 t.Fatalf("reasoning detail = %v, want medium", got)
127 }
128 if got := details["timeout"]; got != "unlimited" {
129 t.Fatalf("timeout detail = %v, want unlimited", got)
130 }
131 text := textResult(res.Content)
132 if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") || !strings.Contains(text, "reasoning: medium") {
133 t.Fatalf("result text missing inherited model/provider:\n%s", text)
134 }
135
136 agents := tool.Supervisor.List()
137 if len(agents) != 1 {
138 t.Fatalf("spawned agents = %d, want 1", len(agents))
139 }
140 if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" || agents[0].Reasoning != "medium" {
141 t.Fatalf("agent model/provider/reasoning = %q/%q/%q, want gpt-5/openai-codex/medium", agents[0].Model, agents[0].Provider, agents[0].Reasoning)
142 }
143}
144
145func TestSubagentSpawnDetailsUseEffectiveTimeout(t *testing.T) {
146 root := t.TempDir()
147 manager := subagents.New(subagents.Config{
148 Root: filepath.Join(root, "subagents"),
149 RepoRoot: root,
150 Policy: subagents.SubagentPolicy{
151 DefaultTimeout: 37 * time.Minute,
152 },
153 NewRunner: func(*subagents.Agent) subagents.Runner {
154 return noopSupervisorRunner{}
155 },
156 })
157 t.Cleanup(manager.StopAll)
158 tool := &SubagentSpawnTool{
159 Supervisor: manager,
160 Enabled: func() bool { return true },
161 }
162
163 res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
164 if err != nil {
165 t.Fatal(err)
166 }
167 if res.IsError {
168 t.Fatalf("unexpected tool error: %s", textResult(res.Content))
169 }
170 details, ok := res.Details.(map[string]any)
171 if !ok {
172 t.Fatalf("details type = %T, want map[string]any", res.Details)
173 }
174 if got := details["timeout"]; got != (37 * time.Minute).String() {
175 t.Fatalf("omitted timeout detail = %v, want %s", got, (37 * time.Minute).String())
176 }
177 if _, found := details["max_turns"]; found {
178 t.Fatalf("details retain removed max_turns: %#v", details)
179 }
180 if _, found := details["max_steps"]; found {
181 t.Fatalf("details retain removed max_steps: %#v", details)
182 }
183}
184
185func TestSubagentSpawnRejectsUnknownExecutionLimitFields(t *testing.T) {
186 manager := newTestSupervisor(t)
187 tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
188 for _, field := range []string{"timeout", "max_turns", "max_steps"} {
189 _, err := tool.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"task":"research docs",%q:1}`, field)), nil)
190 if err == nil || !strings.Contains(err.Error(), "unknown field") {
191 t.Fatalf("field %q error = %v, want unknown field", field, err)
192 }
193 }
194 if agents := manager.List(); len(agents) != 0 {
195 t.Fatalf("unknown execution limits spawned agents: %#v", agents)
196 }
197}
198
199func TestSubagentSpawnSelectsProfileAndReasoning(t *testing.T) {
200 tool := &SubagentSpawnTool{
201 Supervisor: newTestSupervisor(t),
202 Enabled: func() bool { return true },
203 ResolveSubagent: func(name string) (*subagents.Profile, error) {
204 if name != "reviewer" {
205 return nil, nil
206 }
207 return &subagents.Profile{
208 Name: "reviewer",
209 Model: "openai-codex/gpt-5.6-luna",
210 Thinking: "low",
211 }, nil
212 },
213 }
214
215 res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer","reasoning":"high"}`), nil)
216 if err != nil {
217 t.Fatal(err)
218 }
219 if res.IsError {
220 t.Fatalf("unexpected tool error: %s", textResult(res.Content))
221 }
222 text := textResult(res.Content)
223 for _, want := range []string{"agent: reviewer", "model: gpt-5.6-luna", "provider: openai-codex", "reasoning: high"} {
224 if !strings.Contains(text, want) {
225 t.Fatalf("result text missing %q:\n%s", want, text)
226 }
227 }
228 agents := tool.Supervisor.List()
229 if len(agents) != 1 || agents[0].Subagent != "reviewer" || agents[0].Reasoning != "high" {
230 t.Fatalf("agent profile/reasoning = %#v", agents)
231 }
232 if agents[0].Model != "gpt-5.6-luna" || agents[0].Provider != "openai-codex" {
233 t.Fatalf("agent model/provider = %q/%q, want gpt-5.6-luna/openai-codex", agents[0].Model, agents[0].Provider)
234 }
235}
236
237func TestSubagentSpawnAppliesProfileFastModeRestriction(t *testing.T) {
238 root := t.TempDir()
239 profileFastMode := false
240 f := subagents.New(subagents.Config{
241 Root: filepath.Join(root, "subagents"),
242 RepoRoot: root,
243 FastMode: true,
244 NewRunner: func(*subagents.Agent) subagents.Runner {
245 return noopSupervisorRunner{}
246 },
247 })
248 t.Cleanup(f.StopAll)
249 tool := &SubagentSpawnTool{
250 Supervisor: f,
251 Enabled: func() bool { return true },
252 ResolveSubagent: func(name string) (*subagents.Profile, error) {
253 if name != "reviewer" {
254 return nil, nil
255 }
256 return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil
257 },
258 }
259
260 res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil)
261 if err != nil {
262 t.Fatal(err)
263 }
264 if res.IsError {
265 t.Fatalf("unexpected tool error: %s", textResult(res.Content))
266 }
267 agents := f.List()
268 if len(agents) != 1 || agents[0].FastMode {
269 t.Fatalf("agent fast mode = %#v, want disabled by profile", agents)
270 }
271 if got := res.Details.(map[string]any)["fast_mode"]; got != false {
272 t.Fatalf("fast_mode detail = %v, want false", got)
273 }
274}
275
276func TestSubagentSpawnSchemaUsesReasoningAndKeepsProfileThinkingInternal(t *testing.T) {
277 var schema struct {
278 Properties map[string]struct {
279 Type string `json:"type"`
280 Description string `json:"description"`
281 } `json:"properties"`
282 }
283 if err := json.Unmarshal((&SubagentSpawnTool{}).Schema(), &schema); err != nil {
284 t.Fatal(err)
285 }
286 if _, ok := schema.Properties["reasoning"]; !ok {
287 t.Fatal("subagent_spawn schema does not advertise reasoning")
288 }
289 if _, ok := schema.Properties["thinking"]; ok {
290 t.Fatal("subagent_spawn schema exposes profile-only thinking")
291 }
292 if _, ok := schema.Properties["timeout"]; ok {
293 t.Fatal("subagent_spawn schema exposes manager-owned turn timeout")
294 }
295 fastMode, ok := schema.Properties["fast_mode"]
296 if !ok {
297 t.Fatal("subagent_spawn schema does not advertise fast_mode")
298 }
299 if fastMode.Type != "boolean" {
300 t.Fatalf("fast_mode type = %q, want boolean", fastMode.Type)
301 }
302 if !strings.Contains(strings.ToLower(fastMode.Description), "inherit") {
303 t.Fatalf("fast_mode description = %q, want inheritance guidance", fastMode.Description)
304 }
305}
306
307func TestSubagentSpawnExplicitFastModeOverridesProfile(t *testing.T) {
308 for _, tc := range []struct {
309 name string
310 profileFast bool
311 spawnFast bool
312 }{
313 {name: "enable", profileFast: false, spawnFast: true},
314 {name: "disable", profileFast: true, spawnFast: false},
315 } {
316 t.Run(tc.name, func(t *testing.T) {
317 profileFast := tc.profileFast
318 tool := &SubagentSpawnTool{
319 Supervisor: newTestSupervisor(t),
320 Enabled: func() bool { return true },
321 ResolveSubagent: func(name string) (*subagents.Profile, error) {
322 return &subagents.Profile{Name: name, FastMode: &profileFast}, nil
323 },
324 }
325
326 raw := fmt.Sprintf(`{"task":"review auth","agent":"worker","fast_mode":%t}`, tc.spawnFast)
327 res, err := tool.Execute(context.Background(), json.RawMessage(raw), nil)
328 if err != nil {
329 t.Fatal(err)
330 }
331 if res.IsError {
332 t.Fatalf("unexpected tool error: %s", textResult(res.Content))
333 }
334 agents := tool.Supervisor.List()
335 if len(agents) != 1 || agents[0].FastMode != tc.spawnFast {
336 t.Fatalf("agent fast mode = %#v, want %t", agents, tc.spawnFast)
337 }
338 })
339 }
340}
341
342func TestSubagentSpawnProfileFastModeOverridesGlobalOffWithWarning(t *testing.T) {
343 root := t.TempDir()
344 profileFastMode := true
345 f := subagents.New(subagents.Config{
346 Root: filepath.Join(root, "subagents"),
347 RepoRoot: root,
348 NewRunner: func(*subagents.Agent) subagents.Runner {
349 return noopSupervisorRunner{}
350 },
351 })
352 t.Cleanup(f.StopAll)
353 tool := &SubagentSpawnTool{
354 Supervisor: f,
355 Enabled: func() bool { return true },
356 ResolveSubagent: func(name string) (*subagents.Profile, error) {
357 if name != "fast-worker" {
358 return nil, nil
359 }
360 return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil
361 },
362 }
363
364 res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"fast-worker"}`), nil)
365 if err != nil {
366 t.Fatal(err)
367 }
368 if res.IsError {
369 t.Fatalf("unexpected tool error: %s", textResult(res.Content))
370 }
371 agents := f.List()
372 if len(agents) != 1 || !agents[0].FastMode {
373 t.Fatalf("agent fast mode = %#v, want enabled by profile", agents)
374 }
375 if got := res.Details.(map[string]any)["fast_mode"]; got != true {
376 t.Fatalf("fast_mode detail = %v, want true", got)
377 }
378 if text := textResult(res.Content); !strings.Contains(text, "warning: subagent profile has fast mode enabled, overriding global fast mode off") {
379 t.Fatalf("spawn result missing profile override warning:\n%s", text)
380 }
381}
382
383func TestSubagentSpawnUsesProfileReasoningWhenOmitted(t *testing.T) {
384 tool := &SubagentSpawnTool{
385 Supervisor: newTestSupervisor(t),
386 Enabled: func() bool { return true },
387 ResolveSubagent: func(name string) (*subagents.Profile, error) {
388 if name != "reviewer" {
389 return nil, nil
390 }
391 return &subagents.Profile{
392 Name: "reviewer",
393 Model: "openai-codex/gpt-5.6-luna",
394 Thinking: "low",
395 }, nil
396 },
397 }
398
399 res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil)
400 if err != nil {
401 t.Fatal(err)
402 }
403 if res.IsError {
404 t.Fatalf("unexpected tool error: %s", textResult(res.Content))
405 }
406 agents := tool.Supervisor.List()
407 if len(agents) != 1 || agents[0].Reasoning != "low" {
408 t.Fatalf("agent reasoning = %#v, want low", agents)
409 }
410}
411
412func TestSubagentSpawnRejectsUnknownProfile(t *testing.T) {
413 tool := &SubagentSpawnTool{
414 Supervisor: newTestSupervisor(t),
415 Enabled: func() bool { return true },
416 ResolveSubagent: func(string) (*subagents.Profile, error) {
417 return nil, nil
418 },
419 }
420 res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"missing"}`), nil)
421 if err != nil {
422 t.Fatal(err)
423 }
424 if !res.IsError || !strings.Contains(textResult(res.Content), "unknown subagent profile") {
425 t.Fatalf("result = %#v, want unknown profile error", res)
426 }
427 if got := len(tool.Supervisor.List()); got != 0 {
428 t.Fatalf("spawned agents = %d, want 0", got)
429 }
430}
431
432func TestSubagentSpawnNormalizesMatchingQualifiedModelOverride(t *testing.T) {
433 tool := &SubagentSpawnTool{
434 Supervisor: newTestSupervisor(t),
435 Enabled: func() bool { return true },
436 }
437
438 res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","provider":"openai-codex","model":"openai-codex/gpt-5.6-sol"}`), 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 := tool.Supervisor.List()
446 if len(agents) != 1 {
447 t.Fatalf("spawned agents = %d, want 1", len(agents))
448 }
449 if got := agents[0].Model; got != "gpt-5.6-sol" {
450 t.Fatalf("agent model = %q, want unqualified model ID", got)
451 }
452 if got := agents[0].Provider; got != "openai-codex" {
453 t.Fatalf("agent provider = %q, want openai-codex", got)
454 }
455}
456
457func TestSubagentSpawnRejectsPartialModelProviderOverride(t *testing.T) {
458 tool := &SubagentSpawnTool{
459 Supervisor: newTestSupervisor(t),
460 Enabled: func() bool { return true },
461 DefaultModel: func() string { return "gpt-5" },
462 DefaultProvider: func() string { return "openai-codex" },
463 }
464
465 res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","provider":"openai"}`), nil)
466 if err != nil {
467 t.Fatal(err)
468 }
469 if !res.IsError {
470 t.Fatalf("expected partial override to fail")
471 }
472 if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") {
473 t.Fatalf("error text = %q", got)
474 }
475 if got := len(tool.Supervisor.List()); got != 0 {
476 t.Fatalf("spawned agents = %d, want 0", got)
477 }
478}
479
480func textResult(content []provider.Content) string {
481 if len(content) == 0 {
482 return ""
483 }
484 if tb, ok := content[0].(provider.TextBlock); ok {
485 return tb.Text
486 }
487 return ""
488}