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