v0.13.0 befd698

supervisor_test.go RawHistoryFolder

1package subagents
2
3import (
4	"context"
5	"errors"
6	"net"
7	"os"
8	"path/filepath"
9	"runtime"
10	"strings"
11	"sync"
12	"testing"
13	"time"
14)
15
16// newTestSupervisor builds a Supervisor rooted in t.TempDir and configured
17// with the Runner factory controlled by the test.
18func newTestSupervisor(t *testing.T, mk func(a *Agent) Runner) *Supervisor {
19	t.Helper()
20	root := t.TempDir()
21	return New(Config{
22		Root:      root,
23		RepoRoot:  root,
24		NewRunner: mk,
25	})
26}
27
28func TestUnqualifiedModelID(t *testing.T) {
29	for _, tc := range []struct {
30		name     string
31		provider string
32		model    string
33		want     string
34	}{
35		{name: "matching provider", provider: "openai-codex", model: "openai-codex/gpt-5.6-sol", want: "gpt-5.6-sol"},
36		{name: "matching provider with whitespace", provider: "openai-codex", model: "  openai-codex/gpt-5.6-sol  ", want: "gpt-5.6-sol"},
37		{name: "other slash-containing model ID", provider: "openrouter", model: "anthropic/claude-sonnet-4-5", want: "anthropic/claude-sonnet-4-5"},
38		{name: "no provider", model: "/custom-model", want: "/custom-model"},
39	} {
40		t.Run(tc.name, func(t *testing.T) {
41			if got := unqualifiedModelID(tc.provider, tc.model); got != tc.want {
42				t.Fatalf("unqualifiedModelID(%q, %q) = %q, want %q", tc.provider, tc.model, got, tc.want)
43			}
44		})
45	}
46}
47
48func TestSpawnTimeoutDoesNotLimitResumableWorkerLifetime(t *testing.T) {
49	observedDeadline := make(chan bool, 1)
50	f := newTestSupervisor(t, func(a *Agent) Runner {
51		return RunnerFunc(func(ctx context.Context, _ Sink) error {
52			_, hasDeadline := ctx.Deadline()
53			observedDeadline <- hasDeadline
54			<-ctx.Done()
55			return ctx.Err()
56		})
57	})
58	a, err := f.SpawnReq(context.Background(), SpawnRequest{
59		Task:    "remain resumable",
60		Timeout: time.Minute,
61	})
62	if err != nil {
63		t.Fatal(err)
64	}
65	if hasDeadline := <-observedDeadline; hasDeadline {
66		t.Fatal("worker lifetime context has a deadline; turn timeout must not expire an idle resumable worker")
67	}
68	if a.Timeout != time.Minute {
69		t.Fatalf("agent timeout = %s, want %s", a.Timeout, time.Minute)
70	}
71	f.StopAll()
72	a.Wait()
73}
74
75func TestSpawnRunsAndCompletes(t *testing.T) {
76	ran := make(chan string, 1)
77	f := newTestSupervisor(t, func(a *Agent) Runner {
78		return RunnerFunc(func(ctx context.Context, sink Sink) error {
79			ran <- a.Task
80			sink.Activity("hello")
81			sink.Transcript("line one")
82			sink.Transcript("line two")
83			return nil
84		})
85	})
86	a, err := f.Spawn(context.Background(), "do a thing")
87	if err != nil {
88		t.Fatal(err)
89	}
90	select {
91	case got := <-ran:
92		if got != "do a thing" {
93			t.Fatalf("runner got task %q; want %q", got, "do a thing")
94		}
95	case <-time.After(time.Second):
96		t.Fatal("runner did not start")
97	}
98	a.Wait()
99	if a.Status() != StatusDone {
100		t.Fatalf("status %s; want done", a.Status())
101	}
102	if got := a.Transcript(); len(got) != 2 || got[0] != "line one" || got[1] != "line two" {
103		t.Fatalf("transcript = %q", got)
104	}
105	if !strings.Contains(a.ID, "do-a-thing") {
106		t.Fatalf("id %q missing slug", a.ID)
107	}
108	// Every agent shares the host's RepoRoot.
109	if a.Dir != f.cfg.RepoRoot {
110		t.Fatalf("dir = %q; want repo root %q", a.Dir, f.cfg.RepoRoot)
111	}
112}
113
114// TestSpawnAgentSharesRepoRoot verifies the only-mode-we-support:
115// every spawned agent points its cwd at the parent zut's RepoRoot.
116func TestSpawnAgentSharesRepoRoot(t *testing.T) {
117	f := newTestSupervisor(t, func(a *Agent) Runner {
118		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
119	})
120	a, err := f.Spawn(context.Background(), "share me")
121	if err != nil {
122		t.Fatal(err)
123	}
124	a.Wait()
125	if a.Dir != f.cfg.RepoRoot {
126		t.Fatalf("Dir = %q; want RepoRoot %q", a.Dir, f.cfg.RepoRoot)
127	}
128}
129
130func TestSetRepoRootAffectsSubsequentSpawns(t *testing.T) {
131	f := newTestSupervisor(t, func(a *Agent) Runner {
132		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
133	})
134	want := t.TempDir()
135	f.SetRepoRoot(want)
136	a, err := f.Spawn(context.Background(), "use new root")
137	if err != nil {
138		t.Fatal(err)
139	}
140	a.Wait()
141	if a.Dir != want {
142		t.Fatalf("Dir = %q; want updated RepoRoot %q", a.Dir, want)
143	}
144}
145
146func TestSpawnEmptyTaskFails(t *testing.T) {
147	f := newTestSupervisor(t, func(a *Agent) Runner {
148		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
149	})
150	if _, err := f.Spawn(context.Background(), "   "); err == nil {
151		t.Fatal("expected error on empty task")
152	}
153}
154
155func TestRunnerErrorMarksFailed(t *testing.T) {
156	wantErr := errors.New("boom")
157	f := newTestSupervisor(t, func(a *Agent) Runner {
158		return RunnerFunc(func(ctx context.Context, sink Sink) error { return wantErr })
159	})
160	a, _ := f.Spawn(context.Background(), "explode")
161	a.Wait()
162	if a.Status() != StatusFailed {
163		t.Fatalf("status %s; want failed", a.Status())
164	}
165	if !errors.Is(a.Err(), wantErr) {
166		t.Fatalf("err = %v", a.Err())
167	}
168	if !strings.Contains(a.Activity(), "boom") {
169		t.Fatalf("activity = %q; want it to mention the error", a.Activity())
170	}
171}
172
173func TestStopCancelsRunningAgent(t *testing.T) {
174	started := make(chan struct{})
175	f := newTestSupervisor(t, func(a *Agent) Runner {
176		return RunnerFunc(func(ctx context.Context, sink Sink) error {
177			close(started)
178			<-ctx.Done()
179			return ctx.Err()
180		})
181	})
182	a, _ := f.Spawn(context.Background(), "long")
183	<-started
184	if err := f.Stop(a.ID); err != nil {
185		t.Fatal(err)
186	}
187	a.Wait()
188	if a.Status() != StatusKilled {
189		t.Fatalf("status = %s; want killed", a.Status())
190	}
191}
192
193func TestStopPublishesShutdownOriginBeforeConcurrentFinalization(t *testing.T) {
194	started := make(chan struct{})
195	finish := make(chan struct{})
196	finalizing := make(chan struct{})
197	f := newTestSupervisor(t, func(a *Agent) Runner {
198		return RunnerFunc(func(context.Context, Sink) error {
199			close(started)
200			<-finish
201			close(finalizing)
202			return nil
203		})
204	})
205	a, err := f.Spawn(context.Background(), "finish while stopping")
206	if err != nil {
207		t.Fatal(err)
208	}
209	<-started
210
211	// Hold lifecycle finalization after Runner.Run returns. Shutdown must publish
212	// its origin before exposing StatusKilled, without relying on finalization
213	// ordering to fill the durable result correctly.
214	a.lifecycleMu.Lock()
215	lifecycleLocked := true
216	finishClosed := false
217	defer func() {
218		if !finishClosed {
219			close(finish)
220		}
221		if lifecycleLocked {
222			a.lifecycleMu.Unlock()
223		}
224	}()
225	stopDone := make(chan error, 1)
226	go func() { stopDone <- f.stop(context.Background(), a.ID, ShutdownOriginSession) }()
227
228	deadline := time.Now().Add(time.Second)
229	for a.Status() != StatusKilled {
230		if time.Now().After(deadline) {
231			t.Fatal("shutdown did not publish killed status")
232		}
233		runtime.Gosched()
234	}
235	a.mu.Lock()
236	origin := a.shutdownOrigin
237	a.mu.Unlock()
238	if origin != ShutdownOriginSession {
239		t.Fatalf("shutdown origin = %q when killed became observable, want %q", origin, ShutdownOriginSession)
240	}
241
242	close(finish)
243	finishClosed = true
244	<-finalizing
245	deadline = time.Now().Add(time.Second)
246	for {
247		a.mu.Lock()
248		finalizationStarted := !a.finished.IsZero()
249		a.mu.Unlock()
250		if finalizationStarted {
251			break
252		}
253		if time.Now().After(deadline) {
254			t.Fatal("runner finalization did not start")
255		}
256		runtime.Gosched()
257	}
258	a.lifecycleMu.Unlock()
259	lifecycleLocked = false
260	if err := <-stopDone; err != nil {
261		t.Fatal(err)
262	}
263	a.Wait()
264
265	result := a.Result()
266	if result == nil || result.Status != ResultCanceled || result.ShutdownOrigin != ShutdownOriginSession {
267		t.Fatalf("final result = %#v, want attributed session cancellation", result)
268	}
269	if result.Error == nil || result.Error.Code != "shutdown" {
270		t.Fatalf("final result error = %#v, want shutdown", result.Error)
271	}
272}
273
274func TestStopByRootSessionLeavesOtherSessionsRunning(t *testing.T) {
275	started := make(chan string, 2)
276	stopped := make(chan string, 2)
277	f := newTestSupervisor(t, func(a *Agent) Runner {
278		return RunnerFunc(func(ctx context.Context, _ Sink) error {
279			started <- a.ID
280			<-ctx.Done()
281			stopped <- a.ID
282			return ctx.Err()
283		})
284	})
285	t.Cleanup(f.StopAll)
286
287	f.SetActiveSession("session-a")
288	a, err := f.Spawn(context.Background(), "session A task")
289	if err != nil {
290		t.Fatal(err)
291	}
292	f.SetActiveSession("session-b")
293	b, err := f.Spawn(context.Background(), "session B task")
294	if err != nil {
295		t.Fatal(err)
296	}
297
298	for range 2 {
299		<-started
300	}
301	if err := f.StopByRootSession("session-a"); err != nil {
302		t.Fatal(err)
303	}
304	if got := <-stopped; got != a.ID {
305		t.Fatalf("stopped agent = %q, want session A agent %q", got, a.ID)
306	}
307	select {
308	case got := <-stopped:
309		t.Fatalf("session cleanup also stopped %q", got)
310	default:
311	}
312	if got := b.Status(); got != StatusRunning {
313		t.Fatalf("session B status = %s, want running", got)
314	}
315}
316
317func TestSupervisorShutdownCommandsIdentifyOrigin(t *testing.T) {
318	if runtime.GOOS == "windows" {
319		t.Skip("subagent inbox transport uses Unix-domain sockets")
320	}
321	for _, tc := range []struct {
322		name   string
323		origin ShutdownOrigin
324		stop   func(*Supervisor, *Agent) error
325	}{
326		{name: "targeted", origin: ShutdownOriginTargeted, stop: func(f *Supervisor, a *Agent) error { return f.Stop(a.ID) }},
327		{name: "session", origin: ShutdownOriginSession, stop: func(f *Supervisor, _ *Agent) error { return f.StopByRootSession("session-a") }},
328		{name: "process", origin: ShutdownOriginProcess, stop: func(f *Supervisor, _ *Agent) error { return f.StopAllContext(context.Background()) }},
329	} {
330		t.Run(tc.name, func(t *testing.T) {
331			started := make(chan struct{})
332			commands := make(chan Envelope, 1)
333			f := newTestSupervisor(t, func(a *Agent) Runner {
334				return RunnerFunc(func(ctx context.Context, _ Sink) error {
335					listener, err := Listen(a.InboxPath)
336					if err != nil {
337						return err
338					}
339					defer listener.Close()
340					close(started)
341					select {
342					case line := <-listener.Lines():
343						command, err := ParseCommand(line)
344						if err != nil {
345							return err
346						}
347						commands <- command
348						return nil
349					case <-ctx.Done():
350						return ctx.Err()
351					}
352				})
353			})
354			f.SetActiveSession("session-a")
355			a, err := f.Spawn(context.Background(), "origin test")
356			if err != nil {
357				t.Fatal(err)
358			}
359			<-started
360			if err := tc.stop(f, a); err != nil {
361				t.Fatal(err)
362			}
363			command := <-commands
364			var payload AgentShutdownPayload
365			if err := command.DecodePayload(&payload); err != nil {
366				t.Fatal(err)
367			}
368			if payload.Origin != tc.origin {
369				t.Fatalf("shutdown origin = %q, want %q", payload.Origin, tc.origin)
370			}
371			a.Wait()
372		})
373	}
374}
375
376func TestStopContextCallerCancellationDoesNotEndGracePeriod(t *testing.T) {
377	if runtime.GOOS == "windows" {
378		t.Skip("subagent inbox transport uses Unix-domain sockets")
379	}
380	const grace = 250 * time.Millisecond
381	started := make(chan struct{})
382	canceled := make(chan struct{})
383	f := New(Config{
384		Root:     t.TempDir(),
385		RepoRoot: t.TempDir(),
386		Policy: SubagentPolicy{
387			CancelGracePeriod: grace,
388		},
389		NewRunner: func(a *Agent) Runner {
390			return RunnerFunc(func(ctx context.Context, sink Sink) error {
391				listener, err := Listen(a.InboxPath)
392				if err != nil {
393					return err
394				}
395				defer listener.Close()
396				close(started)
397				select {
398				case <-listener.Lines():
399				case <-ctx.Done():
400					close(canceled)
401					return ctx.Err()
402				}
403				<-ctx.Done()
404				close(canceled)
405				return ctx.Err()
406			})
407		},
408	})
409	a, err := f.Spawn(context.Background(), "graceful stop")
410	if err != nil {
411		t.Fatal(err)
412	}
413	select {
414	case <-started:
415	case <-time.After(time.Second):
416		t.Fatal("runner did not start")
417	}
418
419	ctx, cancel := context.WithCancel(context.Background())
420	if err := f.StopContext(ctx, a.ID); err != nil {
421		t.Fatal(err)
422	}
423	cancel()
424
425	early := time.NewTimer(grace / 2)
426	defer early.Stop()
427	select {
428	case <-canceled:
429		t.Fatal("agent canceled when StopContext caller context was canceled")
430	case <-early.C:
431	}
432
433	deadline := time.NewTimer(grace * 2)
434	defer deadline.Stop()
435	select {
436	case <-canceled:
437	case <-deadline.C:
438		t.Fatal("agent was not canceled after the grace period")
439	}
440	a.Wait()
441}
442
443func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T) {
444	if runtime.GOOS == "windows" {
445		t.Skip("subagent inbox transport uses Unix-domain sockets")
446	}
447	path := filepath.Join(shortSocketDir(t), "agent.sock")
448	listener, err := Listen(path)
449	if err != nil {
450		t.Fatal(err)
451	}
452	defer listener.Close()
453
454	f := New(Config{
455		Root: t.TempDir(),
456		Policy: SubagentPolicy{
457			CancelGracePeriod: time.Hour,
458		},
459	})
460	a := &Agent{
461		ID:        "detached-agent",
462		InboxPath: path,
463		inbox:     NewInbox(path),
464		status:    StatusDetached,
465		done:      make(chan struct{}),
466	}
467	defer a.inbox.Close()
468	close(a.done)
469	f.mu.Lock()
470	f.agents[a.ID] = a
471	f.order = append(f.order, a.ID)
472	f.mu.Unlock()
473
474	ctx, cancel := context.WithCancel(context.Background())
475	defer cancel()
476	stopDone := make(chan error, 1)
477	go func() { stopDone <- f.StopContext(ctx, a.ID) }()
478
479	// The listener notification proves Stop reached its shutdown wait. The
480	// operation lock must already be available while that wait is in progress.
481	if msg := <-listener.Lines(); msg == "" {
482		t.Fatal("shutdown command was empty")
483	}
484	operationReleased := make(chan struct{})
485	go func() {
486		f.operationMu.Lock()
487		close(operationReleased)
488		f.operationMu.Unlock()
489	}()
490	<-operationReleased
491
492	cancel()
493	if err := <-stopDone; !errors.Is(err, context.Canceled) {
494		t.Fatalf("StopContext error = %v; want context.Canceled", err)
495	}
496	if got := a.Status(); got != StatusDetached {
497		t.Fatalf("status after canceled stop = %s; want detached", got)
498	}
499}
500
501func TestStopDetachedWorkerClosesWaiters(t *testing.T) {
502	if runtime.GOOS == "windows" {
503		t.Skip("subagent inbox transport uses Unix-domain sockets")
504	}
505	path := filepath.Join(shortSocketDir(t), "agent.sock")
506	listener, err := Listen(path)
507	if err != nil {
508		t.Fatal(err)
509	}
510	t.Cleanup(func() { _ = listener.Close() })
511
512	root := t.TempDir()
513	f := New(Config{Root: root, RepoRoot: root})
514	stateDir := f.agentStateDir("detached-agent")
515	if err := os.MkdirAll(stateDir, 0o700); err != nil {
516		t.Fatal(err)
517	}
518	a := &Agent{
519		ID:        "detached-agent",
520		stateDir:  stateDir,
521		InboxPath: path,
522		inbox:     NewInbox(path),
523		status:    StatusDetached,
524		done:      make(chan struct{}),
525	}
526	defer a.inbox.Close()
527	f.mu.Lock()
528	f.agents[a.ID] = a
529	f.order = append(f.order, a.ID)
530	f.mu.Unlock()
531
532	stopped := make(chan error, 1)
533	go func() { stopped <- f.StopContext(context.Background(), a.ID) }()
534	select {
535	case msg := <-listener.Lines():
536		if msg == "" {
537			t.Fatal("shutdown command was empty")
538		}
539	case <-time.After(time.Second):
540		t.Fatal("detached worker did not receive shutdown")
541	}
542	if err := listener.Close(); err != nil {
543		t.Fatal(err)
544	}
545	select {
546	case err := <-stopped:
547		if err != nil {
548			t.Fatalf("stop detached worker: %v", err)
549		}
550	case <-time.After(time.Second):
551		t.Fatal("detached worker stop did not finish")
552	}
553	select {
554	case <-a.done:
555	case <-time.After(time.Second):
556		t.Fatal("detached worker stop did not close waiters")
557	}
558	result, err := readTurnResult(stateDir)
559	if err != nil {
560		t.Fatal(err)
561	}
562	if result.AgentID != a.ID || result.Status != ResultCanceled {
563		t.Fatalf("detached worker result = agent %q status %q, want agent %q status %q", result.AgentID, result.Status, a.ID, ResultCanceled)
564	}
565}
566
567func TestStopDetachedWorkerHonorsCanceledShutdownSend(t *testing.T) {
568	if runtime.GOOS == "windows" {
569		t.Skip("subagent inbox transport uses Unix-domain sockets")
570	}
571	path := filepath.Join(shortSocketDir(t), "agent.sock")
572	listener, err := Listen(path)
573	if err != nil {
574		t.Fatal(err)
575	}
576	defer listener.Close()
577
578	local, remote := net.Pipe()
579	defer remote.Close()
580	conn := &blockingWriteConn{
581		Conn:    local,
582		started: make(chan struct{}),
583		release: make(chan struct{}),
584	}
585	inbox := NewInbox(path)
586	inbox.conn = conn
587	defer inbox.Close()
588
589	f := New(Config{Root: t.TempDir()})
590	a := &Agent{
591		ID:        "detached-cancel",
592		InboxPath: path,
593		inbox:     inbox,
594		status:    StatusDetached,
595		done:      make(chan struct{}),
596	}
597	f.mu.Lock()
598	f.agents[a.ID] = a
599	f.order = append(f.order, a.ID)
600	f.mu.Unlock()
601
602	ctx, cancel := context.WithCancel(context.Background())
603	stopped := make(chan error, 1)
604	go func() { stopped <- f.StopContext(ctx, a.ID) }()
605	select {
606	case <-conn.started:
607	case <-time.After(time.Second):
608		t.Fatal("detached worker shutdown send did not start")
609	}
610	cancel()
611	select {
612	case err := <-stopped:
613		if !errors.Is(err, context.Canceled) {
614			t.Fatalf("stop detached worker error = %v, want context canceled", err)
615		}
616	case <-time.After(time.Second):
617		t.Fatal("detached worker shutdown send did not honor cancellation")
618	}
619}
620
621func TestStopAfterDoneIsNoop(t *testing.T) {
622	f := newTestSupervisor(t, func(a *Agent) Runner {
623		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
624	})
625	a, _ := f.Spawn(context.Background(), "quick")
626	a.Wait()
627	if err := f.Stop(a.ID); err != nil {
628		t.Fatalf("stop after done: %v", err)
629	}
630	if a.Status() != StatusDone {
631		t.Fatalf("status flipped to %s", a.Status())
632	}
633}
634
635func TestGetPrefixMatch(t *testing.T) {
636	f := newTestSupervisor(t, func(a *Agent) Runner {
637		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
638	})
639	a, _ := f.Spawn(context.Background(), "alpha task")
640	a.Wait()
641	// Full id works.
642	if got := f.Get(a.ID); got != a {
643		t.Fatal("get by full id failed")
644	}
645	// Slug prefix works as long as it's unique.
646	if got := f.Get("alpha"); got != a {
647		t.Fatal("get by prefix failed")
648	}
649	// Bogus id returns nil.
650	if got := f.Get("zzz-nope"); got != nil {
651		t.Fatalf("expected nil; got %#v", got)
652	}
653}
654
655func TestRemoveRequiresTerminalState(t *testing.T) {
656	hold := make(chan struct{})
657	f := newTestSupervisor(t, func(a *Agent) Runner {
658		return RunnerFunc(func(ctx context.Context, sink Sink) error {
659			<-hold
660			return nil
661		})
662	})
663	a, _ := f.Spawn(context.Background(), "still going")
664	// Wait for run goroutine to flip to running.
665	for i := 0; i < 100 && a.Status() != StatusRunning; i++ {
666		time.Sleep(time.Millisecond)
667	}
668	if err := f.Remove(a.ID); err == nil {
669		t.Fatal("remove of running agent should fail")
670	}
671	close(hold)
672	a.Wait()
673	if err := f.Remove(a.ID); err != nil {
674		t.Fatalf("remove after done: %v", err)
675	}
676	if got := f.Get(a.ID); got != nil {
677		t.Fatal("agent still present after remove")
678	}
679}
680
681func TestSnapshotIsStableAcrossAccess(t *testing.T) {
682	var wg sync.WaitGroup
683	wg.Add(1)
684	f := newTestSupervisor(t, func(a *Agent) Runner {
685		return RunnerFunc(func(ctx context.Context, sink Sink) error {
686			defer wg.Done()
687			for i := 0; i < 200; i++ {
688				sink.Transcript("chunk")
689				sink.Activity("step")
690			}
691			return nil
692		})
693	})
694	a, _ := f.Spawn(context.Background(), "race")
695	// Hammer Snapshot while the runner is writing; the -race detector
696	// is the real assertion here.
697	stop := make(chan struct{})
698	go func() {
699		for {
700			select {
701			case <-stop:
702				return
703			default:
704				_ = a.Snapshot()
705			}
706		}
707	}()
708	wg.Wait()
709	a.Wait()
710	close(stop)
711	if a.Status() != StatusDone {
712		t.Fatalf("status = %s", a.Status())
713	}
714}
715
716func TestTruncateIsRuneAware(t *testing.T) {
717	input := "αβγδεζη"
718	if got := truncate(input, 6); got != "αβγ..." {
719		t.Fatalf("truncate(%q, 6) = %q; want %q", input, got, "αβγ...")
720	}
721	if got := truncate(input, 3); got != "..." {
722		t.Fatalf("truncate(%q, 3) = %q; want %q", input, got, "...")
723	}
724	if got := truncate(input, 2); got != ".." {
725		t.Fatalf("truncate(%q, 2) = %q; want %q", input, got, "..")
726	}
727}
728
729func TestTaskSlug(t *testing.T) {
730	cases := map[string]string{
731		"fix the login form":                   "fix-the-login-form",
732		"  weird --- spaces!!  ":               "weird-spaces",
733		"":                                     "agent",
734		"a-very-long-task-name-that-overflows": "a-very-long-task-name-th",
735	}
736	for in, want := range cases {
737		if got := taskSlug(in); got != want {
738			t.Errorf("taskSlug(%q) = %q; want %q", in, got, want)
739		}
740	}
741}
742
743func TestSnapshotAllSorted(t *testing.T) {
744	f := newTestSupervisor(t, func(a *Agent) Runner {
745		return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
746	})
747	a1, _ := f.Spawn(context.Background(), "first")
748	// Force second spawn into a later nanosecond bucket.
749	time.Sleep(2 * time.Millisecond)
750	a2, _ := f.Spawn(context.Background(), "second")
751	a1.Wait()
752	a2.Wait()
753	snaps := f.SnapshotAll()
754	if len(snaps) != 2 {
755		t.Fatalf("want 2 snapshots; got %d", len(snaps))
756	}
757	if !snaps[0].Started.Before(snaps[1].Started) && !snaps[0].Started.Equal(snaps[1].Started) {
758		t.Fatal("snapshots not in spawn order")
759	}
760}

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 2

Tags 477

↑↓ moveenter openesc close