v0.2.47 b325477

swarm_slash_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	"github.com/patriceckhart/zot/packages/agent/swarm"
9)
10
11// newInteractiveForSwarmTest builds the minimal Interactive scaffolding
12// runSwarm needs. It does NOT call NewInteractive (which would pull in
13// the whole TUI); the runSwarm method only touches cfg.Swarm, the
14// status mutex, and the swarm dialog, so we hand-build those.
15func newInteractiveForSwarmTest(t *testing.T) (*Interactive, *swarm.Swarm) {
16	t.Helper()
17	root := t.TempDir()
18	f := swarm.New(swarm.Config{
19		Root:     root,
20		RepoRoot: root,
21		NewRunner: func(a *swarm.Agent) swarm.Runner {
22			return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error {
23				<-ctx.Done()
24				return ctx.Err()
25			})
26		},
27	})
28	iv := &Interactive{
29		swarmDialog: newSwarmDialog(),
30		dirty:       make(chan struct{}, 1),
31	}
32	iv.cfg.Swarm = f
33	return iv, f
34}
35
36// TestRunSwarmBareDoesNotPanic regression-tests the slice-out-of-range
37// panic that hit when /swarm was typed with no subcommand: runSwarm
38// did args[1:] without checking len(args), which panics as [1:0].
39func TestRunSwarmBareDoesNotPanic(t *testing.T) {
40	iv, _ := newInteractiveForSwarmTest(t)
41	defer iv.cfg.Swarm.StopAll()
42
43	// Bare /swarm: parts[1:] from the dispatcher is an empty slice.
44	iv.runSwarm(context.Background(), nil)
45
46	if !iv.swarmDialog.Active() {
47		t.Fatal("bare /swarm should open the dashboard")
48	}
49}
50
51func TestRunSwarmSubcommandsDoNotPanic(t *testing.T) {
52	iv, _ := newInteractiveForSwarmTest(t)
53	defer iv.cfg.Swarm.StopAll()
54
55	// Each row is the slice that the dispatcher hands to runSwarm —
56	// i.e. parts[1:] where parts was strings.Fields of the slash
57	// command. Mixing zero-arg and arg'd forms exercises both
58	// branches of the reslice guard.
59	cases := [][]string{
60		{"list"},
61		{"new"},
62		{"new", "fix", "the", "thing"},
63		{"kill"},
64		{"kill", "no-such-id"},
65		{"remove"},
66		{"remove", "no-such-id"},
67		{"send"},
68		{"send", "no-such-id"},
69		{"send", "no-such-id", "hello", "world"},
70		{"bogus"},
71	}
72	for _, args := range cases {
73		func() {
74			defer func() {
75				if r := recover(); r != nil {
76					t.Fatalf("runSwarm(%v) panicked: %v", args, r)
77				}
78			}()
79			iv.runSwarm(context.Background(), args)
80		}()
81	}
82}
83
84func TestRunSwarmNewSpawnsAgent(t *testing.T) {
85	iv, f := newInteractiveForSwarmTest(t)
86	defer f.StopAll()
87
88	iv.runSwarm(context.Background(), []string{"new", "do", "stuff"})
89	agents := f.List()
90	if len(agents) != 1 {
91		t.Fatalf("want 1 agent; got %d", len(agents))
92	}
93	if agents[0].Task != "do stuff" {
94		t.Fatalf("task = %q; want %q", agents[0].Task, "do stuff")
95	}
96}
97
98// TestRunSwarmSendDeliversToAgentInbox spins up a real agent with a
99// fake Runner whose only job is to forward inbox lines to a channel,
100// then asserts the /swarm send <id> <text...> path routes through
101// Swarm.SendUserTurn and lands at the agent verbatim.
102func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) {
103	root := t.TempDir()
104	recv := make(chan string, 4)
105	ready := make(chan error, 1)
106	f := swarm.New(swarm.Config{
107		Root:     root,
108		RepoRoot: root,
109		NewRunner: func(a *swarm.Agent) swarm.Runner {
110			return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error {
111				// Stand up a real Listener on the agent's inbox path so
112				// SendUserTurn (which dials a unix socket) actually has
113				// something to talk to. The runner-test stubs do the
114				// same; this is the minimum to exercise the wire.
115				ln, err := swarm.Listen(a.InboxPath)
116				ready <- err
117				if err != nil {
118					return err
119				}
120				defer ln.Close()
121				for {
122					select {
123					case <-ctx.Done():
124						return ctx.Err()
125					case line, ok := <-ln.Lines():
126						if !ok {
127							return nil
128						}
129						recv <- line
130					}
131				}
132			})
133		},
134	})
135	defer f.StopAll()
136	iv := &Interactive{swarmDialog: newSwarmDialog(), dirty: make(chan struct{}, 1)}
137	iv.cfg.Swarm = f
138
139	a, err := f.Spawn(context.Background(), "do thing")
140	if err != nil {
141		t.Fatalf("spawn: %v", err)
142	}
143	select {
144	case err := <-ready:
145		if err != nil {
146			t.Fatalf("listen: %v", err)
147		}
148	case <-time.After(5 * time.Second):
149		t.Fatal("timed out waiting for agent inbox listener")
150	}
151
152	// Run /swarm send <id> <text...>. The dispatcher would have
153	// already strings.Fields-ed the input; mirror that here.
154	iv.runSwarm(context.Background(), []string{"send", a.ID, "please", "continue"})
155
156	select {
157	case msg := <-recv:
158		want := "user please continue"
159		if msg != want {
160			t.Fatalf("agent received %q; want %q", msg, want)
161		}
162	case <-time.After(2 * time.Second):
163		t.Fatal("timed out waiting for agent to receive the prompt")
164	}
165
166	if iv.statusErr != "" {
167		t.Fatalf("status err set: %q", iv.statusErr)
168	}
169	if iv.statusOK == "" || iv.statusOK[:7] != "sent to" {
170		t.Fatalf("status ok = %q; want \"sent to ...\"", iv.statusOK)
171	}
172}
173
174func TestParseSpawnFlags(t *testing.T) {
175	cases := []struct {
176		in                  string
177		wantModel, wantProv string
178		wantTask            string
179	}{
180		{"do x", "", "", "do x"},
181		{"--model claude do x", "claude", "", "do x"},
182		{"--model=claude do x", "claude", "", "do x"},
183		{"--provider openai --model gpt-5 do x", "gpt-5", "openai", "do x"},
184		{"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "do x"},
185		// Only LEADING flags are consumed.
186		{"do --model x", "", "", "do --model x"},
187		// Missing value: --model with no follow-up token leaves model empty
188		// and the next field starts the task.
189		{"--model", "", "", ""},
190	}
191	for _, c := range cases {
192		m, p, task := parseSpawnFlags(c.in)
193		if m != c.wantModel || p != c.wantProv || task != c.wantTask {
194			t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q); want (%q,%q,%q)",
195				c.in, m, p, task, c.wantModel, c.wantProv, c.wantTask)
196		}
197	}
198}
199
200func TestSplitIDAndRest(t *testing.T) {
201	cases := []struct {
202		in       string
203		wantID   string
204		wantText string
205	}{
206		{"", "", ""},
207		{"  ", "", ""},
208		{"alpha", "alpha", ""},
209		{"alpha hello world", "alpha", "hello world"},
210		{"  alpha   hello   world  ", "alpha", "hello   world  "},
211		{"alpha\thi", "alpha", "hi"},
212	}
213	for _, c := range cases {
214		gotID, gotText := splitIDAndRest(c.in)
215		if gotID != c.wantID || gotText != c.wantText {
216			t.Errorf("splitIDAndRest(%q) = (%q,%q); want (%q,%q)", c.in, gotID, gotText, c.wantID, c.wantText)
217		}
218	}
219}
220
221func TestRunSwarmWithoutSwarmIsNoop(t *testing.T) {
222	iv := &Interactive{
223		swarmDialog: newSwarmDialog(),
224		dirty:       make(chan struct{}, 1),
225	}
226	// cfg.Swarm stays nil. The command should set a status err and
227	// otherwise be inert.
228	iv.runSwarm(context.Background(), nil)
229	if iv.swarmDialog.Active() {
230		t.Fatal("dialog opened despite no swarm")
231	}
232	if iv.statusErr == "" {
233		t.Fatal("expected a status error when swarm is nil")
234	}
235}

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