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