v0.3.80 f3f644f

swarm_slash_test.go RawHistoryFolder

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

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