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}