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}