v0.3.7 ee6f014

runner_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close