1package swarm
2
3import (
4 "strings"
5 "testing"
6)
7
8// TestSwarmAgentArgs locks in the exact flag set the subprocess
9// runner uses to start a swarm agent in daemon mode. Past
10// regressions in this area:
11//
12// - "--no-sess" instead of "--no-session" (old print-mode runner):
13// every spawned agent died with "unknown flag" before it could
14// talk to the model.
15//
16// - Forgetting --cwd: the child resolved tools against the parent
17// zot's working directory, defeating the whole point of the
18// worktree isolation.
19//
20// - Forgetting --session: a daemon-mode agent without a session
21// file would lose context between follow-up turns, making
22// "send another message" mostly useless.
23//
24// The test asserts the load-bearing pieces are present in plausible
25// positions. If a flag is renamed, update both the runner and this
26// test so we notice immediately.
27func TestSwarmAgentArgs(t *testing.T) {
28 args := swarmAgentArgs(swarmAgentArgsOpts{
29 Exe: "/path/to/zot",
30 Dir: "/tmp/worktree",
31 SessionPath: "/tmp/state/session.json",
32 InboxPath: "/tmp/state/in.sock",
33 Task: "do the thing",
34 })
35 if len(args) < 7 {
36 t.Fatalf("argv unexpectedly short: %v", args)
37 }
38 if args[0] != "/path/to/zot" {
39 t.Fatalf("argv[0] = %q; want the binary path", args[0])
40 }
41 // The task must come last so anything that looks flag-like in
42 // the task body doesn't get interpreted as a flag.
43 if args[len(args)-1] != "do the thing" {
44 t.Fatalf("task should be last positional; got %v", args)
45 }
46
47 mustHave := map[string]string{
48 "--swarm-agent": "/tmp/state/in.sock",
49 "--session": "/tmp/state/session.json",
50 "--cwd": "/tmp/worktree",
51 }
52 for flag, value := range mustHave {
53 i := indexOf(args, flag)
54 if i < 0 {
55 t.Errorf("argv missing %q: %v", flag, args)
56 continue
57 }
58 if i+1 >= len(args) || args[i+1] != value {
59 t.Errorf("argv %q value = %q; want %q", flag, safeAt(args, i+1), value)
60 }
61 }
62
63 // Reject prior bad flags explicitly so a future revert is caught.
64 joined := strings.Join(args, " ")
65 for _, bad := range []string{"--print", "--no-sess ", "--no-session"} {
66 if strings.Contains(joined, bad) {
67 t.Fatalf("argv contains stale/wrong flag %q: %s", bad, joined)
68 }
69 }
70}
71
72// TestSwarmAgentArgsEmptyTaskOmitsPositional makes sure that when the
73// agent is being adopted (no fresh task) we don't pass an empty
74// positional which the arg parser would treat as a real prompt.
75func TestSwarmAgentArgsEmptyTaskOmitsPositional(t *testing.T) {
76 args := swarmAgentArgs(swarmAgentArgsOpts{
77 Exe: "/zot", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock",
78 })
79 for _, a := range args {
80 if a == "" {
81 t.Fatalf("argv contains an empty positional: %v", args)
82 }
83 }
84 // last arg should be a real flag value, not a stray positional
85 if a := args[len(args)-1]; strings.HasPrefix(a, "--") {
86 t.Fatalf("argv ends on a flag with no value: %v", args)
87 }
88}
89
90// TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a
91// fresh (non-resuming) Agent produces argv that ends with the
92// original task as a positional, so the child runs it as the
93// initial user turn.
94func TestDefaultChildArgsSpawnIncludesTask(t *testing.T) {
95 a := &Agent{Dir: "/wt", Task: "do thing"}
96 args := defaultChildArgs("/zot", a, "/s.json", "/in.sock")
97 if got := args[len(args)-1]; got != "do thing" {
98 t.Fatalf("spawn argv last = %q; want %q\n%v", got, "do thing", args)
99 }
100}
101
102// TestDefaultChildArgsResumeOmitsTask is the regression for the
103// "agent busy; send 'cancel' first" error: when an Agent is being
104// resumed (Resuming==true), the child argv MUST NOT include the
105// original Task as a positional. Otherwise the child fires the task
106// as a fresh user turn on every resume, racing with whatever the
107// user types next via the inbox.
108func TestDefaultChildArgsResumeOmitsTask(t *testing.T) {
109 a := &Agent{Dir: "/wt", Task: "do thing", Resuming: true}
110 args := defaultChildArgs("/zot", a, "/s.json", "/in.sock")
111 for _, v := range args {
112 if v == "do thing" {
113 t.Fatalf("resume argv contains the task; it would re-fire as a duplicate turn\n%v", args)
114 }
115 }
116 // And no trailing positional at all: the last arg should be a
117 // flag value, not a stray empty string.
118 if got := args[len(args)-1]; got == "" {
119 t.Fatalf("resume argv ends with an empty positional: %v", args)
120 }
121 if strings.HasPrefix(args[len(args)-1], "--") {
122 t.Fatalf("resume argv ends on a bare flag (no value): %v", args)
123 }
124}
125
126func indexOf(xs []string, want string) int {
127 for i, x := range xs {
128 if x == want {
129 return i
130 }
131 }
132 return -1
133}
134
135func safeAt(xs []string, i int) string {
136 if i < 0 || i >= len(xs) {
137 return ""
138 }
139 return xs[i]
140}