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}