v0.11.2 619aabb

runner_test.go RawHistoryFolder

1package subagents
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10	"testing"
11	"time"
12)
13
14// TestSubagentWorkerArgs locks in the exact flag set the subprocess
15// runner uses to start a subagent agent in daemon mode. Past
16// regressions in this area:
17//
18//   - "--no-sess" instead of "--no-session" (old print-mode runner):
19//     every spawned agent died with "unknown flag" before it could
20//     talk to the model.
21//
22//   - Forgetting --cwd: the child resolved tools against the parent
23//     zut's working directory, defeating the whole point of the
24//     worktree isolation.
25//
26//   - Forgetting --session: a daemon-mode agent without a session
27//     file would lose context between follow-up turns, making
28//     "send another message" mostly useless.
29//
30// The test asserts the load-bearing pieces are present in plausible
31// positions. If a flag is renamed, update both the runner and this
32// test so we notice immediately.
33func TestCredentialStdinHelperProcess(t *testing.T) {
34	if os.Getenv("ZUT_SUBAGENT_CREDENTIAL_HELPER") != "1" {
35		return
36	}
37	if os.Getenv("ZUT_SUBAGENT_CREDENTIAL_STDIN") != "1" {
38		os.Exit(2)
39	}
40	var credential Credential
41	if err := json.NewDecoder(os.Stdin).Decode(&credential); err != nil {
42		os.Exit(3)
43	}
44	if credential.Value != "inherited-secret" || credential.Method != "apikey" {
45		os.Exit(4)
46	}
47	for _, arg := range os.Args {
48		if strings.Contains(arg, credential.Value) {
49			os.Exit(5)
50		}
51	}
52	for _, env := range os.Environ() {
53		if strings.Contains(env, credential.Value) {
54			os.Exit(6)
55		}
56	}
57	fmt.Println(`{"type":"agent_stopped","data":{"reason":"completed"}}`)
58	os.Exit(0)
59}
60
61func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
62	t.Setenv("OPENAI_API_KEY", "openai-secret")
63	t.Setenv("CUSTOM_PROVIDER_API_KEY", "custom-secret")
64	t.Setenv("HF_TOKEN", "hf-secret")
65	t.Setenv("GITHUB_TOKEN", "github-secret")
66	t.Setenv("CUSTOM_PROVIDER_TOKEN", "token-secret")
67	t.Setenv("CUSTOM_PROVIDER_KEY", "key-secret")
68	t.Setenv("AWS_ACCESS_KEY_ID", "aws-access-secret")
69	t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/service-account.json")
70	t.Setenv("ZUT_SUBAGENT_TEST_VALUE", "not-secret")
71	env := strings.Join(workerEnvironment("openai"), "\n")
72	for _, secret := range []string{
73		"OPENAI_API_KEY=openai-secret",
74		"CUSTOM_PROVIDER_API_KEY=custom-secret",
75		"HF_TOKEN=hf-secret",
76		"GITHUB_TOKEN=github-secret",
77		"CUSTOM_PROVIDER_TOKEN=token-secret",
78		"CUSTOM_PROVIDER_KEY=key-secret",
79		"AWS_ACCESS_KEY_ID=aws-access-secret",
80		"GOOGLE_APPLICATION_CREDENTIALS=/tmp/service-account.json",
81	} {
82		if strings.Contains(env, secret) {
83			t.Fatalf("worker environment leaked %s", secret)
84		}
85	}
86	if !strings.Contains(env, "ZUT_SUBAGENT_TEST_VALUE=not-secret") {
87		t.Fatal("worker environment dropped unrelated variables")
88	}
89
90	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-secret")
91	bedrockEnv := strings.Join(workerEnvironment("amazon-bedrock"), "\n")
92	if !strings.Contains(bedrockEnv, "AWS_BEARER_TOKEN_BEDROCK=bedrock-secret") {
93		t.Fatal("Bedrock ambient credential chain was unexpectedly removed")
94	}
95	if !strings.Contains(bedrockEnv, "AWS_ACCESS_KEY_ID=aws-access-secret") {
96		t.Fatal("Bedrock AWS access-key credential was unexpectedly removed")
97	}
98}
99
100func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) {
101	t.Setenv("ZUT_SUBAGENT_CREDENTIAL_HELPER", "1")
102	root := t.TempDir()
103	a := &Agent{
104		ID:           "credential-test",
105		Dir:          root,
106		Provider:     "custom",
107		SessionPath:  filepath.Join(root, "session.json"),
108		InboxPath:    filepath.Join(root, "inbox"),
109		EventLogPath: filepath.Join(root, "events.jsonl"),
110	}
111	calls := 0
112	r := &execRunner{
113		agent:   a,
114		Command: []string{os.Args[0], "-test.run=^TestCredentialStdinHelperProcess$"},
115		resolveCredential: func(context.Context, string) (Credential, error) {
116			calls++
117			return Credential{Value: "inherited-secret", Method: "apikey"}, nil
118		},
119	}
120	if err := r.Run(context.Background(), agentSink{a: a}); err != nil {
121		t.Fatal(err)
122	}
123	if calls != 1 {
124		t.Fatalf("credential resolver called %d times, want 1", calls)
125	}
126}
127
128func TestApplyEventPreservesMultilineRolesAndFinalAssistant(t *testing.T) {
129	a := &Agent{}
130	sink := agentSink{a: a}
131	applyEventToSink(NewEvent("user_message", map[string]any{"content": []any{
132		map[string]any{"type": "text", "text": "first line\n\nlast line"},
133	}}), sink)
134	applyEventToSink(NewEvent("assistant_message", map[string]any{"content": []any{
135		map[string]any{"type": "text", "text": "complete\nanswer"},
136	}}), sink)
137
138	snap := a.Snapshot()
139	wantLines := []string{"user: first line", "user: ", "user: last line", "complete", "answer"}
140	if strings.Join(snap.Lines, "\n") != strings.Join(wantLines, "\n") {
141		t.Fatalf("transcript = %#v, want %#v", snap.Lines, wantLines)
142	}
143	if snap.LastAssistant != "complete\nanswer" {
144		t.Fatalf("last assistant = %q", snap.LastAssistant)
145	}
146}
147
148func TestSubagentWorkerArgs(t *testing.T) {
149	args := subagentWorkerArgs(subagentWorkerArgsOpts{
150		Exe:         "/path/to/zut",
151		Dir:         "/tmp/worktree",
152		SessionPath: "/tmp/state/session.json",
153		InboxPath:   "/tmp/state/in.sock",
154		Task:        "do the thing",
155		Reasoning:   "high",
156		Subagent:    "reviewer",
157	})
158	if len(args) < 7 {
159		t.Fatalf("argv unexpectedly short: %v", args)
160	}
161	if args[0] != "/path/to/zut" {
162		t.Fatalf("argv[0] = %q; want the binary path", args[0])
163	}
164	// The task must come last so anything that looks flag-like in
165	// the task body doesn't get interpreted as a flag.
166	if args[len(args)-1] != "do the thing" {
167		t.Fatalf("task should be last positional; got %v", args)
168	}
169
170	mustHave := map[string]string{
171		"--subagent-worker": "/tmp/state/in.sock",
172		"--session":         "/tmp/state/session.json",
173		"--cwd":             "/tmp/worktree",
174		"--reasoning":       "high",
175		"--subagent":        "reviewer",
176	}
177	for flag, value := range mustHave {
178		i := indexOf(args, flag)
179		if i < 0 {
180			t.Errorf("argv missing %q: %v", flag, args)
181			continue
182		}
183		if i+1 >= len(args) || args[i+1] != value {
184			t.Errorf("argv %q value = %q; want %q", flag, safeAt(args, i+1), value)
185		}
186	}
187
188	// Reject prior bad flags explicitly so a future revert is caught.
189	joined := strings.Join(args, " ")
190	for _, bad := range []string{"--print", "--no-sess ", "--no-session"} {
191		if strings.Contains(joined, bad) {
192			t.Fatalf("argv contains stale/wrong flag %q: %s", bad, joined)
193		}
194	}
195}
196
197func TestDefaultChildArgsPropagatesWebSearchPolicy(t *testing.T) {
198	allow := defaultChildArgs("zut", &Agent{Task: "task", WebSearchPolicy: WebSearchAllow}, "/session", "/inbox")
199	idx := indexOf(allow, "--web-search-policy")
200	if idx < 0 || safeAt(allow, idx+1) != "allow" {
201		t.Fatalf("allow argv = %v", allow)
202	}
203
204	// A legacy/hand-built Agent has no resolved parent policy and must not
205	// inherit the child process's default-enabled normal CLI setting.
206	deny := defaultChildArgs("zut", &Agent{Task: "task"}, "/session", "/inbox")
207	idx = indexOf(deny, "--web-search-policy")
208	if idx < 0 || safeAt(deny, idx+1) != "deny" {
209		t.Fatalf("deny argv = %v", deny)
210	}
211}
212
213func TestResolveSubagentExecutableUsesCurrentWhenAvailable(t *testing.T) {
214	got, err := resolveSubagentExecutable(context.Background(), "/current/bin/zut", "/old/bin/zut", func(_ context.Context, candidate string) (string, error) {
215		if candidate == "/current/bin/zut" {
216			return candidate, nil
217		}
218		return "", os.ErrNotExist
219	})
220	if err != nil {
221		t.Fatal(err)
222	}
223	if got != "/current/bin/zut" {
224		t.Fatalf("executable = %q, want current executable", got)
225	}
226}
227
228func TestResolveSubagentExecutableFallsBackToPath(t *testing.T) {
229	got, err := resolveSubagentExecutable(context.Background(), "/removed/.local/bin/zut", "/removed/.local/bin/zut", func(_ context.Context, candidate string) (string, error) {
230		if candidate == "zut" {
231			return "/active/go/bin/zut", nil
232		}
233		return "", os.ErrNotExist
234	})
235	if err != nil {
236		t.Fatal(err)
237	}
238	if got != "/active/go/bin/zut" {
239		t.Fatalf("executable = %q, want PATH fallback", got)
240	}
241}
242
243func TestResolveSubagentExecutableCancelsOtherLookupsAfterFirstSuccess(t *testing.T) {
244	slowStarted := make(chan struct{})
245	slowCanceled := make(chan struct{})
246	got, err := resolveSubagentExecutable(context.Background(), "/current/bin/zut", "/old/bin/zut", func(ctx context.Context, candidate string) (string, error) {
247		switch candidate {
248		case "/current/bin/zut":
249			close(slowStarted)
250			<-ctx.Done()
251			close(slowCanceled)
252			return "", ctx.Err()
253		case "zut":
254			<-slowStarted
255			return "/active/go/bin/zut", nil
256		default:
257			return "", os.ErrNotExist
258		}
259	})
260	if err != nil {
261		t.Fatal(err)
262	}
263	if got != "/active/go/bin/zut" {
264		t.Fatalf("executable = %q, want first successful lookup", got)
265	}
266	select {
267	case <-slowCanceled:
268	case <-time.After(time.Second):
269		t.Fatal("slower lookup was not canceled")
270	}
271}
272
273func TestSubagentWorkerArgsPropagatesProviderConnectionSettings(t *testing.T) {
274	args := subagentWorkerArgs(subagentWorkerArgsOpts{
275		Exe:         "/zut",
276		Dir:         "/work",
277		SessionPath: "/state/session.json",
278		InboxPath:   "/state/inbox.sock",
279		BaseURL:     "https://gateway.example.test/v1",
280		InsecureTLS: true,
281	})
282	if i := indexOf(args, "--base-url"); i < 0 || safeAt(args, i+1) != "https://gateway.example.test/v1" {
283		t.Fatalf("argv = %v, want --base-url with inherited endpoint", args)
284	}
285	if !containsArg(args, "--insecure") {
286		t.Fatalf("argv = %v, want inherited --insecure", args)
287	}
288	for _, arg := range args {
289		if strings.Contains(arg, "api-key") || strings.Contains(arg, "secret") {
290			t.Fatalf("argv contains credential material: %v", args)
291		}
292	}
293}
294
295func TestDefaultChildArgsPropagatesProviderConnectionSettings(t *testing.T) {
296	a := &Agent{
297		Dir:         "/work",
298		BaseURL:     "https://gateway.example.test/v1",
299		InsecureTLS: true,
300	}
301	args := defaultChildArgs("/zut", a, "/state/session.json", "/state/inbox.sock")
302	if i := indexOf(args, "--base-url"); i < 0 || safeAt(args, i+1) != a.BaseURL {
303		t.Fatalf("argv = %v, want Agent.BaseURL propagated", args)
304	}
305	if !containsArg(args, "--insecure") {
306		t.Fatalf("argv = %v, want Agent.InsecureTLS propagated", args)
307	}
308}
309
310// TestSubagentWorkerArgsEmptyTaskOmitsPositional makes sure that when the
311// agent is being adopted (no fresh task) we don't pass an empty
312// positional which the arg parser would treat as a real prompt.
313func TestSubagentWorkerArgsEmptyTaskOmitsPositional(t *testing.T) {
314	args := subagentWorkerArgs(subagentWorkerArgsOpts{
315		Exe: "/zut", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock",
316	})
317	for _, a := range args {
318		if a == "" {
319			t.Fatalf("argv contains an empty positional: %v", args)
320		}
321	}
322	// last arg should be a real flag value, not a stray positional
323	if a := args[len(args)-1]; strings.HasPrefix(a, "--") {
324		t.Fatalf("argv ends on a flag with no value: %v", args)
325	}
326	for _, flag := range []string{"--reasoning", "--subagent"} {
327		if indexOf(args, flag) >= 0 {
328			t.Fatalf("empty child options should omit %s: %v", flag, args)
329		}
330	}
331}
332
333// TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a
334// fresh (non-resuming) Agent produces argv that ends with the
335// original task as a positional, so the child runs it as the
336// initial user turn.
337func TestDefaultChildArgsSpawnIncludesTask(t *testing.T) {
338	a := &Agent{Dir: "/wt", Task: "do thing"}
339	args := defaultChildArgs("/zut", a, "/s.json", "/in.sock")
340	if got := args[len(args)-1]; got != "do thing" {
341		t.Fatalf("spawn argv last = %q; want %q\n%v", got, "do thing", args)
342	}
343}
344
345// TestDefaultChildArgsResumeOmitsTask is the regression for the
346// "agent busy; send 'cancel' first" error: when an Agent is being
347// resumed (Resuming==true), the child argv MUST NOT include the
348// original Task as a positional. Otherwise the child fires the task
349// as a fresh user turn on every resume, racing with whatever the
350// user types next via the inbox.
351func TestDefaultChildArgsResumeOmitsTask(t *testing.T) {
352	a := &Agent{Dir: "/wt", Task: "do thing", Resuming: true}
353	args := defaultChildArgs("/zut", a, "/s.json", "/in.sock")
354	for _, v := range args {
355		if v == "do thing" {
356			t.Fatalf("resume argv contains the task; it would re-fire as a duplicate turn\n%v", args)
357		}
358	}
359	// And no trailing positional at all: every final token must be a
360	// recognized boolean flag or a value belonging to a preceding flag,
361	// never a stray empty string or the original task.
362	if got := args[len(args)-1]; got == "" {
363		t.Fatalf("resume argv ends with an empty positional: %v", args)
364	}
365}
366
367func TestDefaultChildArgsResumeUsesFollowUpPrompt(t *testing.T) {
368	a := &Agent{
369		Dir:      "/wt",
370		Task:     "review the change",
371		Resuming: true,
372	}
373	const followUp = "I applied your feedback. Please review it again."
374	a.setResumePrompt(followUp, time.Now())
375	args := defaultChildArgs("/zut", a, "/s.json", "/in.sock")
376	if got := args[len(args)-1]; got != followUp {
377		t.Fatalf("resume argv last = %q, want follow-up prompt %q\n%v", got, followUp, args)
378	}
379	for _, v := range args[:len(args)-1] {
380		if v == a.Task {
381			t.Fatalf("resume argv contains original task; it would duplicate the first turn\n%v", args)
382		}
383	}
384}
385
386func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {
387	a := &Agent{}
388	a.setTurnState(TurnRunning, "turn-1")
389	persisted := 0
390	a.persistFn = func(*Agent) { persisted++ }
391
392	updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"}))
393
394	if got := a.TurnState(); got != TurnRunning {
395		t.Fatalf("turn state = %q, want %q", got, TurnRunning)
396	}
397	if got := a.CurrentTurnID(); got != "turn-1" {
398		t.Fatalf("turn ID = %q, want turn-1", got)
399	}
400	if persisted != 0 {
401		t.Fatalf("persist called %d times for provider/tool-loop turn_end, want 0", persisted)
402	}
403}
404
405func TestUpdateAgentFromEventPersistsTerminalTurnEndWithStep(t *testing.T) {
406	a := &Agent{}
407	persisted := 0
408	a.persistFn = func(*Agent) { persisted++ }
409
410	updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
411
412	if got := a.TurnState(); got != TurnFailed {
413		t.Fatalf("turn state = %q, want %q", got, TurnFailed)
414	}
415	if persisted != 1 {
416		t.Fatalf("persist called %d times for terminal turn_end, want 1", persisted)
417	}
418}
419
420func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) {
421	called := make(chan struct{}, 1)
422	a := &Agent{}
423	a.SetOnTurnEnd(func(step int, errMsg string) {
424		called <- struct{}{}
425	})
426
427	notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"}))
428
429	select {
430	case <-called:
431		t.Fatal("OnTurnEnd fired for provider/tool-loop turn_end without step")
432	case <-time.After(50 * time.Millisecond):
433	}
434}
435
436func TestNotifyPromptTurnEndFiresForDaemonPromptCompletion(t *testing.T) {
437	type got struct {
438		step int
439		err  string
440	}
441	called := make(chan got, 1)
442	a := &Agent{}
443	a.SetOnTurnEnd(func(step int, errMsg string) {
444		called <- got{step: step, err: errMsg}
445	})
446
447	notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
448
449	select {
450	case g := <-called:
451		if g.step != 2 || g.err != "boom" {
452			t.Fatalf("callback = (%d, %q); want (2, boom)", g.step, g.err)
453		}
454	case <-time.After(time.Second):
455		t.Fatal("OnTurnEnd did not fire for prompt-level turn_end with step")
456	}
457}
458
459func TestSetOnTurnEndReplaysPendingNoticesInArrivalOrder(t *testing.T) {
460	a := &Agent{}
461	notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(1)}))
462	notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
463
464	type notice struct {
465		step int
466		err  string
467	}
468	var got []notice
469	a.SetOnTurnEnd(func(step int, errMsg string) {
470		got = append(got, notice{step: step, err: errMsg})
471	})
472
473	want := []notice{{step: 1}, {step: 2, err: "boom"}}
474	if len(got) != len(want) {
475		t.Fatalf("replayed %d notices, want %d: %+v", len(got), len(want), got)
476	}
477	for i := range want {
478		if got[i] != want[i] {
479			t.Errorf("replayed notice %d = %+v, want %+v", i, got[i], want[i])
480		}
481	}
482}
483
484func indexOf(xs []string, want string) int {
485	for i, x := range xs {
486		if x == want {
487			return i
488		}
489	}
490	return -1
491}
492
493func safeAt(xs []string, i int) string {
494	if i < 0 || i >= len(xs) {
495		return ""
496	}
497	return xs[i]
498}

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 477

↑↓ moveenter openesc close