v0.9.2 49bea57

supervisor_test.go RawHistoryFolder

1package subagents
2
3import (
4	"context"
5	"errors"
6	"path/filepath"
7	"runtime"
8	"strings"
9	"sync"
10	"testing"
11	"time"
12)
13
14// newTestSupervisor builds a Supervisor rooted in t.TempDir and configured
15// with the Runner factory controlled by the test.
16func newTestSupervisor(t *testing.T, mk func(a *Agent) Runner) *Supervisor {
17	t.Helper()
18	root := t.TempDir()
19	return New(Config{
20		Root:      root,
21		RepoRoot:  root,
22		NewRunner: mk,
23	})
24}
25
26func TestUnqualifiedModelID(t *testing.T) {
27	for _, tc := range []struct {
28		name     string
29		provider string
30		model    string
31		want     string
32	}{
33		{name: "matching provider", provider: "openai-codex", model: "openai-codex/gpt-5.6-sol", want: "gpt-5.6-sol"},
34		{name: "matching provider with whitespace", provider: "openai-codex", model: "  openai-codex/gpt-5.6-sol  ", want: "gpt-5.6-sol"},
35		{name: "other slash-containing model ID", provider: "openrouter", model: "anthropic/claude-sonnet-4-5", want: "anthropic/claude-sonnet-4-5"},
36		{name: "no provider", model: "/custom-model", want: "/custom-model"},
37	} {
38		t.Run(tc.name, func(t *testing.T) {
39			if got := unqualifiedModelID(tc.provider, tc.model); got != tc.want {
40				t.Fatalf("unqualifiedModelID(%q, %q) = %q, want %q", tc.provider, tc.model, got, tc.want)
41			}
42		})
43	}
44}
45
46func TestSpawnRunsAndCompletes(t *testing.T) {
47	ran := make(chan string, 1)
48	f := newTestSupervisor(t, func(a *Agent) Runner {
49		return RunnerFunc(func(ctx context.Context, sink Sink) error {
50			ran <- a.Task
51			sink.Activity("hello")
52			sink.Transcript("line one")
53			sink.Transcript("line two")
54			return nil
55		})
56	})
57	a, err := f.Spawn(context.Background(), "do a thing")
58	if err != nil {
59		t.Fatal(err)
60	}
61	select {
62	case got := <-ran:
63		if got != "do a thing" {
64			t.Fatalf("runner got task %q; want %q", got, "do a thing")
65		}
66	case <-time.After(time.Second):
67		t.Fatal("runner did not start")
68	}
69	a.Wait()
70	if a.Status() != StatusDone {
71		t.Fatalf("status %s; want done", a.Status())
72	}
73	if got := a.Transcript(); len(got) != 2 || got[0] != "line one" || got[1] != "line two" {
74		t.Fatalf("transcript = %q", got)
75	}
76	if !strings.Contains(a.ID, "do-a-thing") {
77		t.Fatalf("id %q missing slug", a.ID)
78	}
79	// Every agent shares the host's RepoRoot.
80	if a.Dir != f.cfg.RepoRoot {
81		t.Fatalf("dir = %q; want repo root %q", a.Dir, f.cfg.RepoRoot)
82	}
83}
84
85// TestSpawnAgentSharesRepoRoot verifies the only-mode-we-support:
86// every spawned agent points its cwd at the parent zut's RepoRoot.
87func TestSpawnAgentSharesRepoRoot(t *testing.T) {
88	f := newTestSupervisor(t, func(a *Agent) Runner {
89		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
90	})
91	a, err := f.Spawn(context.Background(), "share me")
92	if err != nil {
93		t.Fatal(err)
94	}
95	a.Wait()
96	if a.Dir != f.cfg.RepoRoot {
97		t.Fatalf("Dir = %q; want RepoRoot %q", a.Dir, f.cfg.RepoRoot)
98	}
99}
100
101func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) {
102	f := newTestSupervisor(t, func(a *Agent) Runner {
103		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
104	})
105	want := t.TempDir()
106	f.SetRepoRoot(want)
107	a, err := f.Spawn(context.Background(), "use new root")
108	if err != nil {
109		t.Fatal(err)
110	}
111	a.Wait()
112	if a.Dir != want {
113		t.Fatalf("Dir = %q; want updated RepoRoot %q", a.Dir, want)
114	}
115}
116
117func TestSpawnEmptyTaskFails(t *testing.T) {
118	f := newTestSupervisor(t, func(a *Agent) Runner {
119		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
120	})
121	if _, err := f.Spawn(context.Background(), "   "); err == nil {
122		t.Fatal("expected error on empty task")
123	}
124}
125
126func TestRunnerErrorMarksFailed(t *testing.T) {
127	wantErr := errors.New("boom")
128	f := newTestSupervisor(t, func(a *Agent) Runner {
129		return RunnerFunc(func(ctx context.Context, sink Sink) error { return wantErr })
130	})
131	a, _ := f.Spawn(context.Background(), "explode")
132	a.Wait()
133	if a.Status() != StatusFailed {
134		t.Fatalf("status %s; want failed", a.Status())
135	}
136	if !errors.Is(a.Err(), wantErr) {
137		t.Fatalf("err = %v", a.Err())
138	}
139	if !strings.Contains(a.Activity(), "boom") {
140		t.Fatalf("activity = %q; want it to mention the error", a.Activity())
141	}
142}
143
144func TestStopCancelsRunningAgent(t *testing.T) {
145	started := make(chan struct{})
146	f := newTestSupervisor(t, func(a *Agent) Runner {
147		return RunnerFunc(func(ctx context.Context, sink Sink) error {
148			close(started)
149			<-ctx.Done()
150			return ctx.Err()
151		})
152	})
153	a, _ := f.Spawn(context.Background(), "long")
154	<-started
155	if err := f.Stop(a.ID); err != nil {
156		t.Fatal(err)
157	}
158	a.Wait()
159	if a.Status() != StatusKilled {
160		t.Fatalf("status = %s; want killed", a.Status())
161	}
162}
163
164func TestStopContextCallerCancellationDoesNotEndGracePeriod(t *testing.T) {
165	if runtime.GOOS == "windows" {
166		t.Skip("subagent inbox transport uses Unix-domain sockets")
167	}
168	const grace = 250 * time.Millisecond
169	started := make(chan struct{})
170	canceled := make(chan struct{})
171	f := New(Config{
172		Root:     t.TempDir(),
173		RepoRoot: t.TempDir(),
174		Policy: SubagentPolicy{
175			CancelGracePeriod: grace,
176		},
177		NewRunner: func(a *Agent) Runner {
178			return RunnerFunc(func(ctx context.Context, sink Sink) error {
179				listener, err := Listen(a.InboxPath)
180				if err != nil {
181					return err
182				}
183				defer listener.Close()
184				close(started)
185				select {
186				case <-listener.Lines():
187				case <-ctx.Done():
188					close(canceled)
189					return ctx.Err()
190				}
191				<-ctx.Done()
192				close(canceled)
193				return ctx.Err()
194			})
195		},
196	})
197	a, err := f.Spawn(context.Background(), "graceful stop")
198	if err != nil {
199		t.Fatal(err)
200	}
201	select {
202	case <-started:
203	case <-time.After(time.Second):
204		t.Fatal("runner did not start")
205	}
206
207	ctx, cancel := context.WithCancel(context.Background())
208	if err := f.StopContext(ctx, a.ID); err != nil {
209		t.Fatal(err)
210	}
211	cancel()
212
213	early := time.NewTimer(grace / 2)
214	defer early.Stop()
215	select {
216	case <-canceled:
217		t.Fatal("agent canceled when StopContext caller context was canceled")
218	case <-early.C:
219	}
220
221	deadline := time.NewTimer(grace * 2)
222	defer deadline.Stop()
223	select {
224	case <-canceled:
225	case <-deadline.C:
226		t.Fatal("agent was not canceled after the grace period")
227	}
228	a.Wait()
229}
230
231func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T) {
232	if runtime.GOOS == "windows" {
233		t.Skip("subagent inbox transport uses Unix-domain sockets")
234	}
235	path := filepath.Join(shortSocketDir(t), "agent.sock")
236	listener, err := Listen(path)
237	if err != nil {
238		t.Fatal(err)
239	}
240	defer listener.Close()
241
242	f := New(Config{
243		Root: t.TempDir(),
244		Policy: SubagentPolicy{
245			CancelGracePeriod: time.Hour,
246		},
247	})
248	a := &Agent{
249		ID:        "detached-agent",
250		InboxPath: path,
251		inbox:     NewInbox(path),
252		status:    StatusDetached,
253		done:      make(chan struct{}),
254	}
255	defer a.inbox.Close()
256	close(a.done)
257	f.mu.Lock()
258	f.agents[a.ID] = a
259	f.order = append(f.order, a.ID)
260	f.mu.Unlock()
261
262	ctx, cancel := context.WithCancel(context.Background())
263	defer cancel()
264	stopDone := make(chan error, 1)
265	go func() { stopDone <- f.StopContext(ctx, a.ID) }()
266
267	// The listener notification proves Stop reached its shutdown wait. The
268	// operation lock must already be available while that wait is in progress.
269	if msg := <-listener.Lines(); msg == "" {
270		t.Fatal("shutdown command was empty")
271	}
272	operationReleased := make(chan struct{})
273	go func() {
274		f.operationMu.Lock()
275		close(operationReleased)
276		f.operationMu.Unlock()
277	}()
278	<-operationReleased
279
280	cancel()
281	if err := <-stopDone; !errors.Is(err, context.Canceled) {
282		t.Fatalf("StopContext error = %v; want context.Canceled", err)
283	}
284	if got := a.Status(); got != StatusDetached {
285		t.Fatalf("status after canceled stop = %s; want detached", got)
286	}
287}
288
289func TestStopAfterDoneIsNoop(t *testing.T) {
290	f := newTestSupervisor(t, func(a *Agent) Runner {
291		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
292	})
293	a, _ := f.Spawn(context.Background(), "quick")
294	a.Wait()
295	if err := f.Stop(a.ID); err != nil {
296		t.Fatalf("stop after done: %v", err)
297	}
298	if a.Status() != StatusDone {
299		t.Fatalf("status flipped to %s", a.Status())
300	}
301}
302
303func TestGetPrefixMatch(t *testing.T) {
304	f := newTestSupervisor(t, func(a *Agent) Runner {
305		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
306	})
307	a, _ := f.Spawn(context.Background(), "alpha task")
308	a.Wait()
309	// Full id works.
310	if got := f.Get(a.ID); got != a {
311		t.Fatal("get by full id failed")
312	}
313	// Slug prefix works as long as it's unique.
314	if got := f.Get("alpha"); got != a {
315		t.Fatal("get by prefix failed")
316	}
317	// Bogus id returns nil.
318	if got := f.Get("zzz-nope"); got != nil {
319		t.Fatalf("expected nil; got %#v", got)
320	}
321}
322
323func TestRemoveRequiresTerminalState(t *testing.T) {
324	hold := make(chan struct{})
325	f := newTestSupervisor(t, func(a *Agent) Runner {
326		return RunnerFunc(func(ctx context.Context, sink Sink) error {
327			<-hold
328			return nil
329		})
330	})
331	a, _ := f.Spawn(context.Background(), "still going")
332	// Wait for run goroutine to flip to running.
333	for i := 0; i < 100 && a.Status() != StatusRunning; i++ {
334		time.Sleep(time.Millisecond)
335	}
336	if err := f.Remove(a.ID); err == nil {
337		t.Fatal("remove of running agent should fail")
338	}
339	close(hold)
340	a.Wait()
341	if err := f.Remove(a.ID); err != nil {
342		t.Fatalf("remove after done: %v", err)
343	}
344	if got := f.Get(a.ID); got != nil {
345		t.Fatal("agent still present after remove")
346	}
347}
348
349func TestSnapshotIsStableAcrossAccess(t *testing.T) {
350	var wg sync.WaitGroup
351	wg.Add(1)
352	f := newTestSupervisor(t, func(a *Agent) Runner {
353		return RunnerFunc(func(ctx context.Context, sink Sink) error {
354			defer wg.Done()
355			for i := 0; i < 200; i++ {
356				sink.Transcript("chunk")
357				sink.Activity("step")
358			}
359			return nil
360		})
361	})
362	a, _ := f.Spawn(context.Background(), "race")
363	// Hammer Snapshot while the runner is writing; the -race detector
364	// is the real assertion here.
365	stop := make(chan struct{})
366	go func() {
367		for {
368			select {
369			case <-stop:
370				return
371			default:
372				_ = a.Snapshot()
373			}
374		}
375	}()
376	wg.Wait()
377	a.Wait()
378	close(stop)
379	if a.Status() != StatusDone {
380		t.Fatalf("status = %s", a.Status())
381	}
382}
383
384func TestTruncateIsRuneAware(t *testing.T) {
385	input := "αβγδεζη"
386	if got := truncate(input, 6); got != "αβγ..." {
387		t.Fatalf("truncate(%q, 6) = %q; want %q", input, got, "αβγ...")
388	}
389	if got := truncate(input, 3); got != "..." {
390		t.Fatalf("truncate(%q, 3) = %q; want %q", input, got, "...")
391	}
392	if got := truncate(input, 2); got != ".." {
393		t.Fatalf("truncate(%q, 2) = %q; want %q", input, got, "..")
394	}
395}
396
397func TestTaskSlug(t *testing.T) {
398	cases := map[string]string{
399		"fix the login form":                   "fix-the-login-form",
400		"  weird --- spaces!!  ":               "weird-spaces",
401		"":                                     "agent",
402		"a-very-long-task-name-that-overflows": "a-very-long-task-name-th",
403	}
404	for in, want := range cases {
405		if got := taskSlug(in); got != want {
406			t.Errorf("taskSlug(%q) = %q; want %q", in, got, want)
407		}
408	}
409}
410
411func TestSnapshotAllSorted(t *testing.T) {
412	f := newTestSupervisor(t, func(a *Agent) Runner {
413		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
414	})
415	a1, _ := f.Spawn(context.Background(), "first")
416	// Force second spawn into a later nanosecond bucket.
417	time.Sleep(2 * time.Millisecond)
418	a2, _ := f.Spawn(context.Background(), "second")
419	a1.Wait()
420	a2.Wait()
421	snaps := f.SnapshotAll()
422	if len(snaps) != 2 {
423		t.Fatalf("want 2 snapshots; got %d", len(snaps))
424	}
425	if !snaps[0].Started.Before(snaps[1].Started) && !snaps[0].Started.Equal(snaps[1].Started) {
426		t.Fatal("snapshots not in spawn order")
427	}
428}

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