v0.2.7 ea58887

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	f := swarm.New(swarm.Config{
106		Root:     root,
107		RepoRoot: root,
108		NewRunner: func(a *swarm.Agent) swarm.Runner {
109			return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error {
110				// Stand up a real Listener on the agent's inbox path so
111				// SendUserTurn (which dials a unix socket) actually has
112				// something to talk to. The runner-test stubs do the
113				// same; this is the minimum to exercise the wire.
114				ln, err := swarm.Listen(a.InboxPath)
115				if err != nil {
116					return err
117				}
118				defer ln.Close()
119				for {
120					select {
121					case <-ctx.Done():
122						return ctx.Err()
123					case line, ok := <-ln.Lines():
124						if !ok {
125							return nil
126						}
127						recv <- line
128					}
129				}
130			})
131		},
132	})
133	defer f.StopAll()
134	iv := &Interactive{swarmDialog: newSwarmDialog(), dirty: make(chan struct{}, 1)}
135	iv.cfg.Swarm = f
136
137	a, err := f.Spawn(context.Background(), "do thing")
138	if err != nil {
139		t.Fatalf("spawn: %v", err)
140	}
141
142	// Run /swarm send <id> <text...>. The dispatcher would have
143	// already strings.Fields-ed the input; mirror that here.
144	iv.runSwarm(context.Background(), []string{"send", a.ID, "please", "continue"})
145
146	select {
147	case msg := <-recv:
148		want := "user please continue"
149		if msg != want {
150			t.Fatalf("agent received %q; want %q", msg, want)
151		}
152	case <-time.After(2 * time.Second):
153		t.Fatal("timed out waiting for agent to receive the prompt")
154	}
155
156	if iv.statusErr != "" {
157		t.Fatalf("status err set: %q", iv.statusErr)
158	}
159	if iv.statusOK == "" || iv.statusOK[:7] != "sent to" {
160		t.Fatalf("status ok = %q; want \"sent to ...\"", iv.statusOK)
161	}
162}
163
164func TestParseSpawnFlags(t *testing.T) {
165	cases := []struct {
166		in                  string
167		wantModel, wantProv string
168		wantTask            string
169	}{
170		{"do x", "", "", "do x"},
171		{"--model claude do x", "claude", "", "do x"},
172		{"--model=claude do x", "claude", "", "do x"},
173		{"--provider openai --model gpt-5 do x", "gpt-5", "openai", "do x"},
174		{"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "do x"},
175		// Only LEADING flags are consumed.
176		{"do --model x", "", "", "do --model x"},
177		// Missing value: --model with no follow-up token leaves model empty
178		// and the next field starts the task.
179		{"--model", "", "", ""},
180	}
181	for _, c := range cases {
182		m, p, task := parseSpawnFlags(c.in)
183		if m != c.wantModel || p != c.wantProv || task != c.wantTask {
184			t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q); want (%q,%q,%q)",
185				c.in, m, p, task, c.wantModel, c.wantProv, c.wantTask)
186		}
187	}
188}
189
190func TestSplitIDAndRest(t *testing.T) {
191	cases := []struct {
192		in       string
193		wantID   string
194		wantText string
195	}{
196		{"", "", ""},
197		{"  ", "", ""},
198		{"alpha", "alpha", ""},
199		{"alpha hello world", "alpha", "hello world"},
200		{"  alpha   hello   world  ", "alpha", "hello   world  "},
201		{"alpha\thi", "alpha", "hi"},
202	}
203	for _, c := range cases {
204		gotID, gotText := splitIDAndRest(c.in)
205		if gotID != c.wantID || gotText != c.wantText {
206			t.Errorf("splitIDAndRest(%q) = (%q,%q); want (%q,%q)", c.in, gotID, gotText, c.wantID, c.wantText)
207		}
208	}
209}
210
211func TestRunSwarmWithoutSwarmIsNoop(t *testing.T) {
212	iv := &Interactive{
213		swarmDialog: newSwarmDialog(),
214		dirty:       make(chan struct{}, 1),
215	}
216	// cfg.Swarm stays nil. The command should set a status err and
217	// otherwise be inert.
218	iv.runSwarm(context.Background(), nil)
219	if iv.swarmDialog.Active() {
220		t.Fatal("dialog opened despite no swarm")
221	}
222	if iv.statusErr == "" {
223		t.Fatal("expected a status error when swarm is nil")
224	}
225}

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