1package modes
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/patriceckhart/zot/internal/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}