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}