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}