v0.3.0 761f038

subagents_slash_test.go RawHistoryFolder

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	iv.runSubagents(context.Background(), []string{"wait", a.ID})
294	deadline := time.After(time.Second)
295	for {
296		iv.mu.Lock()
297		status := iv.statusOK
298		iv.mu.Unlock()
299		if status == "completed "+a.ID {
300			return
301		}
302		select {
303		case <-deadline:
304			t.Fatalf("wait did not report completion; status = %q", status)
305		case <-time.After(time.Millisecond):
306		}
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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close