v0.1.80 63e28ad

swarm_slash_test.go RawHistoryFolder

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

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