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 // Exercise the ordering-sensitive path deterministically: /wait can be
294 // invoked after the worker has already completed.
295 a.Wait()
296 watcherDone := make(chan struct{})
297 iv.subagentsWaitWatcherDone = func() { close(watcherDone) }
298 iv.runSubagents(context.Background(), []string{"wait", a.ID})
299 select {
300 case <-watcherDone:
301 case <-time.After(5 * time.Second):
302 t.Fatal("timed out waiting for wait watcher to report completion")
303 }
304
305 iv.mu.Lock()
306 status := iv.statusOK
307 iv.mu.Unlock()
308 if status != "completed "+a.ID {
309 t.Fatalf("wait completion status = %q; want %q", status, "completed "+a.ID)
310 }
311}
312
313func TestNormalizeSpawnReasoning(t *testing.T) {
314 cases := []struct {
315 in, want string
316 ok bool
317 }{
318 {in: "OFF", want: "off", ok: true},
319 {in: "minimal", want: "minimum", ok: true},
320 {in: "maximum", want: "xhigh", ok: true},
321 {in: "MAX", want: "max", ok: true},
322 {in: "bogus", ok: false},
323 }
324 for _, tc := range cases {
325 got, err := normalizeSpawnReasoning(tc.in)
326 if tc.ok {
327 if err != nil || got != tc.want {
328 t.Errorf("normalizeSpawnReasoning(%q) = %q, %v; want %q", tc.in, got, err, tc.want)
329 }
330 continue
331 }
332 if err == nil {
333 t.Errorf("normalizeSpawnReasoning(%q) succeeded with %q; want error", tc.in, got)
334 }
335 }
336}
337
338func TestParseSpawnFlags(t *testing.T) {
339 cases := []struct {
340 in string
341 wantModel, wantProv string
342 wantReasoning, wantSubagent string
343 wantTask string
344 }{
345 {"do x", "", "", "", "", "do x"},
346 {"--model claude do x", "claude", "", "", "", "do x"},
347 {"--model=claude do x", "claude", "", "", "", "do x"},
348 {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
349 {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"},
350 {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"},
351 {"--agent --reasoning high review auth", "", "", "high", "", "review auth"},
352 {"--reasoning --agent reviewer review auth", "", "", "", "reviewer", "review auth"},
353 {"--thinking=max do x", "", "", "max", "", "do x"},
354 // Only LEADING flags are consumed.
355 {"do --model x", "", "", "", "", "do --model x"},
356 // Missing value: --model with no follow-up token leaves model empty
357 // and the next field starts the task.
358 {"--model", "", "", "", "", ""},
359 }
360 for _, c := range cases {
361 m, p, r, a, task := parseSpawnFlags(c.in)
362 if m != c.wantModel || p != c.wantProv || r != c.wantReasoning || a != c.wantSubagent || task != c.wantTask {
363 t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q,%q,%q); want (%q,%q,%q,%q,%q)",
364 c.in, m, p, r, a, task, c.wantModel, c.wantProv, c.wantReasoning, c.wantSubagent, c.wantTask)
365 }
366 }
367}
368
369func TestSplitIDAndRest(t *testing.T) {
370 cases := []struct {
371 in string
372 wantID string
373 wantText string
374 }{
375 {"", "", ""},
376 {" ", "", ""},
377 {"alpha", "alpha", ""},
378 {"alpha hello world", "alpha", "hello world"},
379 {" alpha hello world ", "alpha", "hello world "},
380 {"alpha\thi", "alpha", "hi"},
381 }
382 for _, c := range cases {
383 gotID, gotText := splitIDAndRest(c.in)
384 if gotID != c.wantID || gotText != c.wantText {
385 t.Errorf("splitIDAndRest(%q) = (%q,%q); want (%q,%q)", c.in, gotID, gotText, c.wantID, c.wantText)
386 }
387 }
388}
389
390func TestRunSupervisorWithoutSupervisorIsNoop(t *testing.T) {
391 iv := &Interactive{
392 subagentsDialog: newSubagentsDialog(),
393 dirty: make(chan struct{}, 1),
394 }
395 // cfg.Supervisor stays nil. The command should set a status err and
396 // otherwise be inert.
397 iv.runSubagents(context.Background(), nil)
398 if iv.subagentsDialog.Active() {
399 t.Fatal("dialog opened despite no subagent")
400 }
401 if iv.statusErr == "" {
402 t.Fatal("expected a status error when subagent is nil")
403 }
404}