1package modes
2
3import (
4 "context"
5 "runtime"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/bnema/zut/packages/agent/subagents"
11)
12
13// newInteractiveForSupervisorTest builds the minimal Interactive scaffolding
14// runSubagents needs. It does NOT call NewInteractive (which would pull in
15// the whole TUI); the runSubagents method only touches cfg.Supervisor, the
16// status mutex, and the subagent dialog, so we hand-build those.
17func newInteractiveForSupervisorTest(t *testing.T) (*Interactive, *subagents.Supervisor) {
18 t.Helper()
19 root := t.TempDir()
20 f := subagents.New(subagents.Config{
21 Root: root,
22 RepoRoot: root,
23 NewRunner: func(a *subagents.Agent) subagents.Runner {
24 return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
25 <-ctx.Done()
26 return ctx.Err()
27 })
28 },
29 })
30 iv := &Interactive{
31 subagentsDialog: newSubagentsDialog(),
32 dirty: make(chan struct{}, 1),
33 }
34 iv.cfg.Supervisor = f
35 return iv, f
36}
37
38// TestRunSupervisorBareDoesNotPanic regression-tests the slice-out-of-range
39// panic that hit when /subagents was typed with no subcommand: runSubagents
40// did args[1:] without checking len(args), which panics as [1:0].
41func TestRunSupervisorBareDoesNotPanic(t *testing.T) {
42 iv, _ := newInteractiveForSupervisorTest(t)
43 defer iv.cfg.Supervisor.StopAll()
44
45 // Bare /subagents: parts[1:] from the dispatcher is an empty slice.
46 iv.runSubagents(context.Background(), nil)
47
48 if !iv.subagentsDialog.Active() {
49 t.Fatal("bare /subagents should open the dashboard")
50 }
51}
52
53func TestRunSupervisorSubcommandsDoNotPanic(t *testing.T) {
54 iv, _ := newInteractiveForSupervisorTest(t)
55 defer iv.cfg.Supervisor.StopAll()
56
57 // Each row is the slice that the dispatcher hands to runSubagents —
58 // i.e. parts[1:] where parts was strings.Fields of the slash
59 // command. Mixing zero-arg and arg'd forms exercises both
60 // branches of the reslice guard.
61 cases := [][]string{
62 {"list"},
63 {"new"},
64 {"new", "fix", "the", "thing"},
65 {"kill"},
66 {"kill", "no-such-id"},
67 {"remove"},
68 {"remove", "no-such-id"},
69 {"send"},
70 {"send", "no-such-id"},
71 {"send", "no-such-id", "hello", "world"},
72 {"bogus"},
73 }
74 for _, args := range cases {
75 func() {
76 defer func() {
77 if r := recover(); r != nil {
78 t.Fatalf("runSubagents(%v) panicked: %v", args, r)
79 }
80 }()
81 iv.runSubagents(context.Background(), args)
82 }()
83 }
84 iv.mu.Lock()
85 statusErr := iv.statusErr
86 iv.mu.Unlock()
87 if !strings.Contains(statusErr, "cancel") || !strings.Contains(statusErr, "wait") {
88 t.Fatalf("unknown-command hint = %q; want cancel and wait", statusErr)
89 }
90}
91
92func TestRunSupervisorRejectsNamedProfileWithoutResolver(t *testing.T) {
93 iv, f := newInteractiveForSupervisorTest(t)
94 defer f.StopAll()
95
96 iv.runSubagents(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"})
97 if !strings.Contains(iv.statusErr, "named subagent profiles are unavailable") {
98 t.Fatalf("status error = %q, want unavailable profile resolver", iv.statusErr)
99 }
100 if got := len(f.List()); got != 0 {
101 t.Fatalf("spawned agents = %d, want 0", got)
102 }
103}
104
105func TestRunSupervisorNewSpawnsAgent(t *testing.T) {
106 iv, f := newInteractiveForSupervisorTest(t)
107 defer f.StopAll()
108
109 iv.runSubagents(context.Background(), []string{"new", "do", "stuff"})
110 agents := f.List()
111 if len(agents) != 1 {
112 t.Fatalf("want 1 agent; got %d", len(agents))
113 }
114 if agents[0].Task != "do stuff" {
115 t.Fatalf("task = %q; want %q", agents[0].Task, "do stuff")
116 }
117}
118
119func TestRunSupervisorNamedProfileAppliesFastModeRestriction(t *testing.T) {
120 root := t.TempDir()
121 profileFastMode := false
122 f := subagents.New(subagents.Config{
123 Root: root,
124 RepoRoot: root,
125 FastMode: true,
126 NewRunner: func(*subagents.Agent) subagents.Runner {
127 return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
128 <-ctx.Done()
129 return ctx.Err()
130 })
131 },
132 })
133 defer f.StopAll()
134 iv := &Interactive{subagentsDialog: newSubagentsDialog(), dirty: make(chan struct{}, 1)}
135 iv.cfg.Supervisor = f
136 iv.cfg.ResolveSubagent = func(name string) (*subagents.Profile, error) {
137 if name != "reviewer" {
138 return nil, nil
139 }
140 return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil
141 }
142
143 iv.runSubagents(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"})
144 agents := f.List()
145 if len(agents) != 1 || agents[0].FastMode {
146 t.Fatalf("agent fast mode = %#v, want disabled by profile", agents)
147 }
148}
149
150// TestRunSupervisorSendDeliversToAgentInbox spins up a real agent with a
151// fake Runner whose only job is to forward inbox lines to a channel,
152// then asserts the /subagents send <id> <text...> path routes through
153// Supervisor.SendUserTurn and lands at the agent verbatim.
154func TestRunSupervisorSendDeliversToAgentInbox(t *testing.T) {
155 if runtime.GOOS == "windows" {
156 t.Skip("subagent inbox transport uses Unix-domain sockets")
157 }
158 root := t.TempDir()
159 recv := make(chan string, 4)
160 ready := make(chan error, 1)
161 f := subagents.New(subagents.Config{
162 Root: root,
163 RepoRoot: root,
164 NewRunner: func(a *subagents.Agent) subagents.Runner {
165 return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
166 // Stand up a real Listener on the agent's inbox path so
167 // SendUserTurn (which dials a unix socket) actually has
168 // something to talk to. The runner-test stubs do the
169 // same; this is the minimum to exercise the wire.
170 ln, err := subagents.Listen(a.InboxPath)
171 ready <- err
172 if err != nil {
173 return err
174 }
175 defer ln.Close()
176 for {
177 select {
178 case <-ctx.Done():
179 return ctx.Err()
180 case line, ok := <-ln.Lines():
181 if !ok {
182 return nil
183 }
184 recv <- line
185 }
186 }
187 })
188 },
189 })
190 defer f.StopAll()
191 iv := &Interactive{subagentsDialog: newSubagentsDialog(), dirty: make(chan struct{}, 1)}
192 iv.cfg.Supervisor = f
193
194 a, err := f.Spawn(context.Background(), "do thing")
195 if err != nil {
196 t.Fatalf("spawn: %v", err)
197 }
198 select {
199 case err := <-ready:
200 if err != nil {
201 t.Fatalf("listen: %v", err)
202 }
203 case <-time.After(5 * time.Second):
204 t.Fatal("timed out waiting for agent inbox listener")
205 }
206
207 // Run /subagents send <id> <text...>. The dispatcher would have
208 // already strings.Fields-ed the input; mirror that here.
209 iv.runSubagents(context.Background(), []string{"send", a.ID, "please", "continue"})
210
211 select {
212 case msg := <-recv:
213 command, parseErr := subagents.ParseCommand(msg)
214 if parseErr != nil {
215 t.Fatalf("agent received invalid command %q: %v", msg, parseErr)
216 }
217 if command.Type != subagents.CommandTurnStart {
218 t.Fatalf("agent received command %q; want %q", command.Type, subagents.CommandTurnStart)
219 }
220 var payload subagents.TurnStartPayload
221 if err := command.DecodePayload(&payload); err != nil {
222 t.Fatalf("decode turn.start payload: %v", err)
223 }
224 if payload.Prompt != "please continue" {
225 t.Fatalf("agent received prompt %q; want %q", payload.Prompt, "please continue")
226 }
227 case <-time.After(2 * time.Second):
228 t.Fatal("timed out waiting for agent to receive the prompt")
229 }
230
231 if iv.statusErr != "" {
232 t.Fatalf("status err set: %q", iv.statusErr)
233 }
234 if iv.statusOK == "" || iv.statusOK[:7] != "sent to" {
235 t.Fatalf("status ok = %q; want \"sent to ...\"", iv.statusOK)
236 }
237}
238
239func TestRunSupervisorWaitHonorsRunContext(t *testing.T) {
240 iv, f := newInteractiveForSupervisorTest(t)
241 defer f.StopAll()
242
243 a, err := f.Spawn(context.Background(), "long-running task")
244 if err != nil {
245 t.Fatal(err)
246 }
247 ctx, cancel := context.WithCancel(context.Background())
248 watcherDone := make(chan struct{})
249 iv.subagentsWaitWatcherDone = func() { close(watcherDone) }
250 iv.runSubagents(ctx, []string{"wait", a.ID})
251 iv.mu.Lock()
252 status := iv.statusOK
253 iv.mu.Unlock()
254 if status != "waiting for "+a.ID {
255 t.Fatalf("initial wait status = %q", status)
256 }
257 cancel()
258
259 // The wait watcher must stop observing the run context and must not
260 // later overwrite the status with a false completion after cleanup.
261 deadline := time.NewTimer(time.Second)
262 defer deadline.Stop()
263 select {
264 case <-watcherDone:
265 case <-deadline.C:
266 t.Fatal("timed out waiting for wait watcher to exit")
267 }
268 iv.mu.Lock()
269 status = iv.statusOK
270 iv.mu.Unlock()
271 if strings.Contains(status, "completed ") {
272 t.Fatalf("wait reported completion after cancellation: %q", status)
273 }
274}
275
276func TestRunSupervisorWaitReportsCompletionBeforeCancellation(t *testing.T) {
277 root := t.TempDir()
278 f := subagents.New(subagents.Config{
279 Root: root,
280 RepoRoot: root,
281 NewRunner: func(*subagents.Agent) subagents.Runner {
282 return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
283 },
284 })
285 defer f.StopAll()
286 iv := &Interactive{subagentsDialog: newSubagentsDialog(), dirty: make(chan struct{}, 1)}
287 iv.cfg.Supervisor = f
288
289 a, err := f.Spawn(context.Background(), "quick task")
290 if err != nil {
291 t.Fatal(err)
292 }
293 watcherDone := make(chan struct{})
294 iv.subagentsWaitWatcherDone = func() { close(watcherDone) }
295 iv.runSubagents(context.Background(), []string{"wait", a.ID})
296 select {
297 case <-watcherDone:
298 case <-time.After(5 * time.Second):
299 t.Fatal("timed out waiting for wait watcher to report completion")
300 }
301
302 iv.mu.Lock()
303 status := iv.statusOK
304 iv.mu.Unlock()
305 if status != "completed "+a.ID {
306 t.Fatalf("wait completion status = %q; want %q", status, "completed "+a.ID)
307 }
308}
309
310func TestNormalizeSpawnReasoning(t *testing.T) {
311 cases := []struct {
312 in, want string
313 ok bool
314 }{
315 {in: "OFF", want: "off", ok: true},
316 {in: "minimal", want: "minimum", ok: true},
317 {in: "maximum", want: "xhigh", ok: true},
318 {in: "MAX", want: "max", ok: true},
319 {in: "bogus", ok: false},
320 }
321 for _, tc := range cases {
322 got, err := normalizeSpawnReasoning(tc.in)
323 if tc.ok {
324 if err != nil || got != tc.want {
325 t.Errorf("normalizeSpawnReasoning(%q) = %q, %v; want %q", tc.in, got, err, tc.want)
326 }
327 continue
328 }
329 if err == nil {
330 t.Errorf("normalizeSpawnReasoning(%q) succeeded with %q; want error", tc.in, got)
331 }
332 }
333}
334
335func TestParseSpawnFlags(t *testing.T) {
336 cases := []struct {
337 in string
338 wantModel, wantProv string
339 wantReasoning, wantSubagent string
340 wantTask string
341 }{
342 {"do x", "", "", "", "", "do x"},
343 {"--model claude do x", "claude", "", "", "", "do x"},
344 {"--model=claude do x", "claude", "", "", "", "do x"},
345 {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
346 {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
347 {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"},
348 {"--agent --reasoning high review auth", "", "", "high", "", "review auth"},
349 {"--reasoning --agent reviewer review auth", "", "", "", "reviewer", "review auth"},
350 {"--thinking=max do x", "", "", "max", "", "do x"},
351 // Only LEADING flags are consumed.
352 {"do --model x", "", "", "", "", "do --model x"},
353 // Missing value: --model with no follow-up token leaves model empty
354 // and the next field starts the task.
355 {"--model", "", "", "", "", ""},
356 }
357 for _, c := range cases {
358 m, p, r, a, task := parseSpawnFlags(c.in)
359 if m != c.wantModel || p != c.wantProv || r != c.wantReasoning || a != c.wantSubagent || task != c.wantTask {
360 t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q,%q,%q); want (%q,%q,%q,%q,%q)",
361 c.in, m, p, r, a, task, c.wantModel, c.wantProv, c.wantReasoning, c.wantSubagent, c.wantTask)
362 }
363 }
364}
365
366func TestSplitIDAndRest(t *testing.T) {
367 cases := []struct {
368 in string
369 wantID string
370 wantText string
371 }{
372 {"", "", ""},
373 {" ", "", ""},
374 {"alpha", "alpha", ""},
375 {"alpha hello world", "alpha", "hello world"},
376 {" alpha hello world ", "alpha", "hello world "},
377 {"alpha\thi", "alpha", "hi"},
378 }
379 for _, c := range cases {
380 gotID, gotText := splitIDAndRest(c.in)
381 if gotID != c.wantID || gotText != c.wantText {
382 t.Errorf("splitIDAndRest(%q) = (%q,%q); want (%q,%q)", c.in, gotID, gotText, c.wantID, c.wantText)
383 }
384 }
385}
386
387func TestRunSupervisorWithoutSupervisorIsNoop(t *testing.T) {
388 iv := &Interactive{
389 subagentsDialog: newSubagentsDialog(),
390 dirty: make(chan struct{}, 1),
391 }
392 // cfg.Supervisor stays nil. The command should set a status err and
393 // otherwise be inert.
394 iv.runSubagents(context.Background(), nil)
395 if iv.subagentsDialog.Active() {
396 t.Fatal("dialog opened despite no subagent")
397 }
398 if iv.statusErr == "" {
399 t.Fatal("expected a status error when subagent is nil")
400 }
401}