package modes import ( "context" "testing" "time" "github.com/patriceckhart/zot/packages/agent/swarm" ) // newInteractiveForSwarmTest builds the minimal Interactive scaffolding // runSwarm needs. It does NOT call NewInteractive (which would pull in // the whole TUI); the runSwarm method only touches cfg.Swarm, the // status mutex, and the swarm dialog, so we hand-build those. func newInteractiveForSwarmTest(t *testing.T) (*Interactive, *swarm.Swarm) { t.Helper() root := t.TempDir() f := swarm.New(swarm.Config{ Root: root, RepoRoot: root, NewRunner: func(a *swarm.Agent) swarm.Runner { return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { <-ctx.Done() return ctx.Err() }) }, }) iv := &Interactive{ swarmDialog: newSwarmDialog(), dirty: make(chan struct{}, 1), } iv.cfg.Swarm = f return iv, f } // TestRunSwarmBareDoesNotPanic regression-tests the slice-out-of-range // panic that hit when /swarm was typed with no subcommand: runSwarm // did args[1:] without checking len(args), which panics as [1:0]. func TestRunSwarmBareDoesNotPanic(t *testing.T) { iv, _ := newInteractiveForSwarmTest(t) defer iv.cfg.Swarm.StopAll() // Bare /swarm: parts[1:] from the dispatcher is an empty slice. iv.runSwarm(context.Background(), nil) if !iv.swarmDialog.Active() { t.Fatal("bare /swarm should open the dashboard") } } func TestRunSwarmSubcommandsDoNotPanic(t *testing.T) { iv, _ := newInteractiveForSwarmTest(t) defer iv.cfg.Swarm.StopAll() // Each row is the slice that the dispatcher hands to runSwarm — // i.e. parts[1:] where parts was strings.Fields of the slash // command. Mixing zero-arg and arg'd forms exercises both // branches of the reslice guard. cases := [][]string{ {"list"}, {"new"}, {"new", "fix", "the", "thing"}, {"kill"}, {"kill", "no-such-id"}, {"remove"}, {"remove", "no-such-id"}, {"send"}, {"send", "no-such-id"}, {"send", "no-such-id", "hello", "world"}, {"bogus"}, } for _, args := range cases { func() { defer func() { if r := recover(); r != nil { t.Fatalf("runSwarm(%v) panicked: %v", args, r) } }() iv.runSwarm(context.Background(), args) }() } } func TestRunSwarmNewSpawnsAgent(t *testing.T) { iv, f := newInteractiveForSwarmTest(t) defer f.StopAll() iv.runSwarm(context.Background(), []string{"new", "do", "stuff"}) agents := f.List() if len(agents) != 1 { t.Fatalf("want 1 agent; got %d", len(agents)) } if agents[0].Task != "do stuff" { t.Fatalf("task = %q; want %q", agents[0].Task, "do stuff") } } // TestRunSwarmSendDeliversToAgentInbox spins up a real agent with a // fake Runner whose only job is to forward inbox lines to a channel, // then asserts the /swarm send path routes through // Swarm.SendUserTurn and lands at the agent verbatim. func TestRunSwarmSendDeliversToAgentInbox(t *testing.T) { root := t.TempDir() recv := make(chan string, 4) ready := make(chan error, 1) f := swarm.New(swarm.Config{ Root: root, RepoRoot: root, NewRunner: func(a *swarm.Agent) swarm.Runner { return swarm.RunnerFunc(func(ctx context.Context, sink swarm.Sink) error { // Stand up a real Listener on the agent's inbox path so // SendUserTurn (which dials a unix socket) actually has // something to talk to. The runner-test stubs do the // same; this is the minimum to exercise the wire. ln, err := swarm.Listen(a.InboxPath) ready <- err if err != nil { return err } defer ln.Close() for { select { case <-ctx.Done(): return ctx.Err() case line, ok := <-ln.Lines(): if !ok { return nil } recv <- line } } }) }, }) defer f.StopAll() iv := &Interactive{swarmDialog: newSwarmDialog(), dirty: make(chan struct{}, 1)} iv.cfg.Swarm = f a, err := f.Spawn(context.Background(), "do thing") if err != nil { t.Fatalf("spawn: %v", err) } select { case err := <-ready: if err != nil { t.Fatalf("listen: %v", err) } case <-time.After(5 * time.Second): t.Fatal("timed out waiting for agent inbox listener") } // Run /swarm send . The dispatcher would have // already strings.Fields-ed the input; mirror that here. iv.runSwarm(context.Background(), []string{"send", a.ID, "please", "continue"}) select { case msg := <-recv: want := "user please continue" if msg != want { t.Fatalf("agent received %q; want %q", msg, want) } case <-time.After(2 * time.Second): t.Fatal("timed out waiting for agent to receive the prompt") } if iv.statusErr != "" { t.Fatalf("status err set: %q", iv.statusErr) } if iv.statusOK == "" || iv.statusOK[:7] != "sent to" { t.Fatalf("status ok = %q; want \"sent to ...\"", iv.statusOK) } } func TestParseSpawnFlags(t *testing.T) { cases := []struct { in string wantModel, wantProv string wantTask string }{ {"do x", "", "", "do x"}, {"--model claude do x", "claude", "", "do x"}, {"--model=claude do x", "claude", "", "do x"}, {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "do x"}, {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "do x"}, // Only LEADING flags are consumed. {"do --model x", "", "", "do --model x"}, // Missing value: --model with no follow-up token leaves model empty // and the next field starts the task. {"--model", "", "", ""}, } for _, c := range cases { m, p, task := parseSpawnFlags(c.in) if m != c.wantModel || p != c.wantProv || task != c.wantTask { t.Errorf("parseSpawnFlags(%q) = (%q,%q,%q); want (%q,%q,%q)", c.in, m, p, task, c.wantModel, c.wantProv, c.wantTask) } } } func TestSplitIDAndRest(t *testing.T) { cases := []struct { in string wantID string wantText string }{ {"", "", ""}, {" ", "", ""}, {"alpha", "alpha", ""}, {"alpha hello world", "alpha", "hello world"}, {" alpha hello world ", "alpha", "hello world "}, {"alpha\thi", "alpha", "hi"}, } for _, c := range cases { gotID, gotText := splitIDAndRest(c.in) if gotID != c.wantID || gotText != c.wantText { t.Errorf("splitIDAndRest(%q) = (%q,%q); want (%q,%q)", c.in, gotID, gotText, c.wantID, c.wantText) } } } func TestRunSwarmWithoutSwarmIsNoop(t *testing.T) { iv := &Interactive{ swarmDialog: newSwarmDialog(), dirty: make(chan struct{}, 1), } // cfg.Swarm stays nil. The command should set a status err and // otherwise be inert. iv.runSwarm(context.Background(), nil) if iv.swarmDialog.Active() { t.Fatal("dialog opened despite no swarm") } if iv.statusErr == "" { t.Fatal("expected a status error when swarm is nil") } }