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
61// TestMessageDeltaStreamsAndReplaysWithoutDuplicatingFinalMessage verifies
62// that the durable event stream has the same visible result while live and
63// after history replay.
64func TestMessageDeltaStreamsAndReplaysWithoutDuplicatingFinalMessage(t *testing.T) {
65 events := []Event{
66 NewEvent("message.delta", map[string]any{"delta": "partial "}),
67 NewEvent("message.delta", map[string]any{"delta": "answer"}),
68 NewEvent("assistant_message", map[string]any{"content": []any{
69 map[string]any{"type": "text", "text": "partial answer"},
70 }}),
71 }
72
73 live := &Agent{transcriptLoaded: true}
74 for _, event := range events {
75 applyEventToSink(event, agentSink{a: live})
76 }
77 if got := strings.Join(live.Transcript(), "\n"); got != "partial answer" {
78 t.Fatalf("live transcript = %q, want final assistant message once", got)
79 }
80 if got := live.Snapshot().LastAssistant; got != "partial answer" {
81 t.Fatalf("live assistant output = %q, want %q", got, "partial answer")
82 }
83
84 replayed := &Agent{transcriptLoaded: true}
85 for _, event := range events {
86 replayEventTranscript(replayed, event)
87 }
88 if got := strings.Join(replayed.Transcript(), "\n"); got != "partial answer" {
89 t.Fatalf("replayed transcript = %q, want final assistant message once", got)
90 }
91}
92
93func TestMessageDeltaBoundsLiveProjection(t *testing.T) {
94 a := &Agent{maxOutputBytes: 32, maxOutputLines: 100, transcriptLoaded: true}
95 applyEventToSink(NewEvent("message.delta", map[string]any{"delta": strings.Repeat("x", 64)}), agentSink{a: a})
96 applyEventToSink(NewEvent("message.delta", map[string]any{"delta": "discarded"}), agentSink{a: a})
97
98 a.mu.Lock()
99 streamed := a.streamingAssistantText
100 truncated := a.streamingAssistantTruncated
101 a.mu.Unlock()
102 if !truncated {
103 t.Fatal("streamed output was not marked truncated")
104 }
105 if got := len([]byte(streamed)); got > 32 {
106 t.Fatalf("streamed output length = %d, want at most 32", got)
107 }
108 snapshot := a.Snapshot()
109 if got := snapshot.LastAssistant; got != streamed {
110 t.Fatalf("live assistant output = %q, want bounded stream %q", got, streamed)
111 }
112 if !snapshot.OutputTruncated {
113 t.Fatal("snapshot did not expose truncated streamed output")
114 }
115
116 events := []Event{
117 NewEvent(EventMessageDelta, map[string]any{"delta": "first partial"}),
118 NewEvent("turn_end", map[string]any{"stop": "cancelled"}),
119 NewEvent(EventMessageDelta, map[string]any{"delta": "second partial"}),
120 }
121 want := "first partial\nsecond partial"
122 assertProjection := func(t *testing.T, agent *Agent) {
123 t.Helper()
124 if got := strings.Join(agent.Transcript(), "\n"); got != want {
125 t.Fatalf("transcript = %q, want %q", got, want)
126 }
127 agent.mu.Lock()
128 truncated := agent.streamingAssistantTruncated
129 agent.mu.Unlock()
130 if truncated {
131 t.Fatal("fresh second-turn stream was incorrectly truncated")
132 }
133 }
134
135 live := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
136 for _, event := range events {
137 applyEventToSink(event, agentSink{a: live})
138 }
139 assertProjection(t, live)
140
141 replayed := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
142 replayTranscriptIntoAgent(replayed, events)
143 assertProjection(t, replayed)
144
145 legacy := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
146 replayEventsIntoAgent(legacy, events)
147 assertProjection(t, legacy)
148
149 truncatedTurn := &Agent{maxOutputBytes: 16, maxOutputLines: 100, transcriptLoaded: true}
150 applyEventToSink(NewEvent(EventMessageDelta, map[string]any{"delta": strings.Repeat("x", 64)}), agentSink{a: truncatedTurn})
151 applyEventToSink(NewEvent("turn_end", map[string]any{"stop": "cancelled"}), agentSink{a: truncatedTurn})
152 truncatedTurn.mu.Lock()
153 streamText := truncatedTurn.streamingAssistantText
154 streamTruncated := truncatedTurn.streamingAssistantTruncated
155 truncatedTurn.mu.Unlock()
156 if streamText != "" || streamTruncated {
157 t.Fatalf("stream state after turn boundary = (%q, %v), want empty and not truncated", streamText, streamTruncated)
158 }
159}
160
161func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
162 t.Setenv("OPENAI_API_KEY", "openai-secret")
163 t.Setenv("CUSTOM_PROVIDER_API_KEY", "custom-secret")
164 t.Setenv("HF_TOKEN", "hf-secret")
165 t.Setenv("GITHUB_TOKEN", "github-secret")
166 t.Setenv("CUSTOM_PROVIDER_TOKEN", "token-secret")
167 t.Setenv("CUSTOM_PROVIDER_KEY", "key-secret")
168 t.Setenv("AWS_ACCESS_KEY_ID", "aws-access-secret")
169 t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/service-account.json")
170 t.Setenv("ZUT_SUBAGENT_TEST_VALUE", "not-secret")
171 env := strings.Join(workerEnvironment("openai"), "\n")
172 for _, secret := range []string{
173 "OPENAI_API_KEY=openai-secret",
174 "CUSTOM_PROVIDER_API_KEY=custom-secret",
175 "HF_TOKEN=hf-secret",
176 "GITHUB_TOKEN=github-secret",
177 "CUSTOM_PROVIDER_TOKEN=token-secret",
178 "CUSTOM_PROVIDER_KEY=key-secret",
179 "AWS_ACCESS_KEY_ID=aws-access-secret",
180 "GOOGLE_APPLICATION_CREDENTIALS=/tmp/service-account.json",
181 } {
182 if strings.Contains(env, secret) {
183 t.Fatalf("worker environment leaked %s", secret)
184 }
185 }
186 if !strings.Contains(env, "ZUT_SUBAGENT_TEST_VALUE=not-secret") {
187 t.Fatal("worker environment dropped unrelated variables")
188 }
189
190 t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-secret")
191 bedrockEnv := strings.Join(workerEnvironment("amazon-bedrock"), "\n")
192 if !strings.Contains(bedrockEnv, "AWS_BEARER_TOKEN_BEDROCK=bedrock-secret") {
193 t.Fatal("Bedrock ambient credential chain was unexpectedly removed")
194 }
195 if !strings.Contains(bedrockEnv, "AWS_ACCESS_KEY_ID=aws-access-secret") {
196 t.Fatal("Bedrock AWS access-key credential was unexpectedly removed")
197 }
198}
199
200func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) {
201 t.Setenv("ZUT_SUBAGENT_CREDENTIAL_HELPER", "1")
202 root := t.TempDir()
203 a := &Agent{
204 ID: "credential-test",
205 Dir: root,
206 Provider: "custom",
207 SessionPath: filepath.Join(root, "session.json"),
208 InboxPath: filepath.Join(root, "inbox"),
209 EventLogPath: filepath.Join(root, "events.jsonl"),
210 }
211 calls := 0
212 r := &execRunner{
213 agent: a,
214 Command: []string{os.Args[0], "-test.run=^TestCredentialStdinHelperProcess$"},
215 resolveCredential: func(context.Context, string) (Credential, error) {
216 calls++
217 return Credential{Value: "inherited-secret", Method: "apikey"}, nil
218 },
219 }
220 if err := r.Run(context.Background(), agentSink{a: a}); err != nil {
221 t.Fatal(err)
222 }
223 if calls != 1 {
224 t.Fatalf("credential resolver called %d times, want 1", calls)
225 }
226}
227
228func TestApplyEventPreservesMultilineRolesAndFinalAssistant(t *testing.T) {
229 a := &Agent{}
230 sink := agentSink{a: a}
231 applyEventToSink(NewEvent("user_message", map[string]any{"content": []any{
232 map[string]any{"type": "text", "text": "first line\n\nlast line"},
233 }}), sink)
234 applyEventToSink(NewEvent("assistant_message", map[string]any{"content": []any{
235 map[string]any{"type": "text", "text": "complete\nanswer"},
236 }}), sink)
237
238 snap := a.Snapshot()
239 wantLines := []string{"user: first line", "user: ", "user: last line", "complete", "answer"}
240 if strings.Join(snap.Lines, "\n") != strings.Join(wantLines, "\n") {
241 t.Fatalf("transcript = %#v, want %#v", snap.Lines, wantLines)
242 }
243 if snap.LastAssistant != "complete\nanswer" {
244 t.Fatalf("last assistant = %q", snap.LastAssistant)
245 }
246}
247
248func TestSubagentWorkerArgs(t *testing.T) {
249 args := subagentWorkerArgs(subagentWorkerArgsOpts{
250 Exe: "/path/to/zut",
251 Dir: "/tmp/worktree",
252 SessionPath: "/tmp/state/session.json",
253 InboxPath: "/tmp/state/in.sock",
254 Task: "do the thing",
255 Reasoning: "high",
256 Subagent: "reviewer",
257 })
258 if len(args) < 7 {
259 t.Fatalf("argv unexpectedly short: %v", args)
260 }
261 if args[0] != "/path/to/zut" {
262 t.Fatalf("argv[0] = %q; want the binary path", args[0])
263 }
264 // The task must come last so anything that looks flag-like in
265 // the task body doesn't get interpreted as a flag.
266 if args[len(args)-1] != "do the thing" {
267 t.Fatalf("task should be last positional; got %v", args)
268 }
269
270 mustHave := map[string]string{
271 "--subagent-worker": "/tmp/state/in.sock",
272 "--session": "/tmp/state/session.json",
273 "--cwd": "/tmp/worktree",
274 "--reasoning": "high",
275 "--subagent": "reviewer",
276 }
277 for flag, value := range mustHave {
278 i := indexOf(args, flag)
279 if i < 0 {
280 t.Errorf("argv missing %q: %v", flag, args)
281 continue
282 }
283 if i+1 >= len(args) || args[i+1] != value {
284 t.Errorf("argv %q value = %q; want %q", flag, safeAt(args, i+1), value)
285 }
286 }
287
288 // Reject prior bad flags explicitly so a future revert is caught.
289 joined := strings.Join(args, " ")
290 for _, bad := range []string{"--print", "--no-sess ", "--no-session"} {
291 if strings.Contains(joined, bad) {
292 t.Fatalf("argv contains stale/wrong flag %q: %s", bad, joined)
293 }
294 }
295}
296
297func TestDefaultChildArgsPropagatesWebSearchPolicy(t *testing.T) {
298 allow := defaultChildArgs("zut", &Agent{Task: "task", WebSearchPolicy: WebSearchAllow}, "/session", "/inbox")
299 idx := indexOf(allow, "--web-search-policy")
300 if idx < 0 || safeAt(allow, idx+1) != "allow" {
301 t.Fatalf("allow argv = %v", allow)
302 }
303
304 // A legacy/hand-built Agent has no resolved parent policy and must not
305 // inherit the child process's default-enabled normal CLI setting.
306 deny := defaultChildArgs("zut", &Agent{Task: "task"}, "/session", "/inbox")
307 idx = indexOf(deny, "--web-search-policy")
308 if idx < 0 || safeAt(deny, idx+1) != "deny" {
309 t.Fatalf("deny argv = %v", deny)
310 }
311}
312
313func TestResolveSubagentExecutableUsesCurrentWhenAvailable(t *testing.T) {
314 got, err := resolveSubagentExecutable(context.Background(), "/current/bin/zut", "/old/bin/zut", func(_ context.Context, candidate string) (string, error) {
315 if candidate == "/current/bin/zut" {
316 return candidate, nil
317 }
318 return "", os.ErrNotExist
319 })
320 if err != nil {
321 t.Fatal(err)
322 }
323 if got != "/current/bin/zut" {
324 t.Fatalf("executable = %q, want current executable", got)
325 }
326}
327
328func TestResolveSubagentExecutableFallsBackToPath(t *testing.T) {
329 got, err := resolveSubagentExecutable(context.Background(), "/removed/.local/bin/zut", "/removed/.local/bin/zut", func(_ context.Context, candidate string) (string, error) {
330 if candidate == "zut" {
331 return "/active/go/bin/zut", nil
332 }
333 return "", os.ErrNotExist
334 })
335 if err != nil {
336 t.Fatal(err)
337 }
338 if got != "/active/go/bin/zut" {
339 t.Fatalf("executable = %q, want PATH fallback", got)
340 }
341}
342
343func TestResolveSubagentExecutableCancelsOtherLookupsAfterFirstSuccess(t *testing.T) {
344 slowStarted := make(chan struct{})
345 slowCanceled := make(chan struct{})
346 got, err := resolveSubagentExecutable(context.Background(), "/current/bin/zut", "/old/bin/zut", func(ctx context.Context, candidate string) (string, error) {
347 switch candidate {
348 case "/current/bin/zut":
349 close(slowStarted)
350 <-ctx.Done()
351 close(slowCanceled)
352 return "", ctx.Err()
353 case "zut":
354 <-slowStarted
355 return "/active/go/bin/zut", nil
356 default:
357 return "", os.ErrNotExist
358 }
359 })
360 if err != nil {
361 t.Fatal(err)
362 }
363 if got != "/active/go/bin/zut" {
364 t.Fatalf("executable = %q, want first successful lookup", got)
365 }
366 select {
367 case <-slowCanceled:
368 case <-time.After(time.Second):
369 t.Fatal("slower lookup was not canceled")
370 }
371}
372
373func TestSubagentWorkerArgsPropagatesProviderConnectionSettings(t *testing.T) {
374 args := subagentWorkerArgs(subagentWorkerArgsOpts{
375 Exe: "/zut",
376 Dir: "/work",
377 SessionPath: "/state/session.json",
378 InboxPath: "/state/inbox.sock",
379 BaseURL: "https://gateway.example.test/v1",
380 InsecureTLS: true,
381 })
382 if i := indexOf(args, "--base-url"); i < 0 || safeAt(args, i+1) != "https://gateway.example.test/v1" {
383 t.Fatalf("argv = %v, want --base-url with inherited endpoint", args)
384 }
385 if !containsArg(args, "--insecure") {
386 t.Fatalf("argv = %v, want inherited --insecure", args)
387 }
388 for _, arg := range args {
389 if strings.Contains(arg, "api-key") || strings.Contains(arg, "secret") {
390 t.Fatalf("argv contains credential material: %v", args)
391 }
392 }
393}
394
395func TestDefaultChildArgsPropagatesProviderConnectionSettings(t *testing.T) {
396 a := &Agent{
397 Dir: "/work",
398 BaseURL: "https://gateway.example.test/v1",
399 InsecureTLS: true,
400 }
401 args := defaultChildArgs("/zut", a, "/state/session.json", "/state/inbox.sock")
402 if i := indexOf(args, "--base-url"); i < 0 || safeAt(args, i+1) != a.BaseURL {
403 t.Fatalf("argv = %v, want Agent.BaseURL propagated", args)
404 }
405 if !containsArg(args, "--insecure") {
406 t.Fatalf("argv = %v, want Agent.InsecureTLS propagated", args)
407 }
408}
409
410// TestSubagentWorkerArgsEmptyTaskOmitsPositional makes sure that when the
411// agent is being adopted (no fresh task) we don't pass an empty
412// positional which the arg parser would treat as a real prompt.
413func TestSubagentWorkerArgsEmptyTaskOmitsPositional(t *testing.T) {
414 args := subagentWorkerArgs(subagentWorkerArgsOpts{
415 Exe: "/zut", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock",
416 })
417 for _, a := range args {
418 if a == "" {
419 t.Fatalf("argv contains an empty positional: %v", args)
420 }
421 }
422 // last arg should be a real flag value, not a stray positional
423 if a := args[len(args)-1]; strings.HasPrefix(a, "--") {
424 t.Fatalf("argv ends on a flag with no value: %v", args)
425 }
426 for _, flag := range []string{"--reasoning", "--subagent"} {
427 if indexOf(args, flag) >= 0 {
428 t.Fatalf("empty child options should omit %s: %v", flag, args)
429 }
430 }
431}
432
433// TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a
434// fresh (non-resuming) Agent produces argv that ends with the
435// original task as a positional, so the child runs it as the
436// initial user turn.
437func TestDefaultChildArgsSpawnIncludesTask(t *testing.T) {
438 a := &Agent{Dir: "/wt", Task: "do thing"}
439 args := defaultChildArgs("/zut", a, "/s.json", "/in.sock")
440 if got := args[len(args)-1]; got != "do thing" {
441 t.Fatalf("spawn argv last = %q; want %q\n%v", got, "do thing", args)
442 }
443}
444
445// TestDefaultChildArgsResumeOmitsTask is the regression for the
446// "agent busy; send 'cancel' first" error: when an Agent is being
447// resumed (Resuming==true), the child argv MUST NOT include the
448// original Task as a positional. Otherwise the child fires the task
449// as a fresh user turn on every resume, racing with whatever the
450// user types next via the inbox.
451func TestDefaultChildArgsResumeOmitsTask(t *testing.T) {
452 a := &Agent{Dir: "/wt", Task: "do thing", Resuming: true}
453 args := defaultChildArgs("/zut", a, "/s.json", "/in.sock")
454 for _, v := range args {
455 if v == "do thing" {
456 t.Fatalf("resume argv contains the task; it would re-fire as a duplicate turn\n%v", args)
457 }
458 }
459 // And no trailing positional at all: every final token must be a
460 // recognized boolean flag or a value belonging to a preceding flag,
461 // never a stray empty string or the original task.
462 if got := args[len(args)-1]; got == "" {
463 t.Fatalf("resume argv ends with an empty positional: %v", args)
464 }
465}
466
467func TestDefaultChildArgsResumeUsesFollowUpPrompt(t *testing.T) {
468 a := &Agent{
469 Dir: "/wt",
470 Task: "review the change",
471 Resuming: true,
472 }
473 const followUp = "I applied your feedback. Please review it again."
474 a.setResumePrompt(followUp, time.Now())
475 args := defaultChildArgs("/zut", a, "/s.json", "/in.sock")
476 if got := args[len(args)-1]; got != followUp {
477 t.Fatalf("resume argv last = %q, want follow-up prompt %q\n%v", got, followUp, args)
478 }
479 for _, v := range args[:len(args)-1] {
480 if v == a.Task {
481 t.Fatalf("resume argv contains original task; it would duplicate the first turn\n%v", args)
482 }
483 }
484}
485
486func TestReadyDoesNotMarkInitialDelegatedTurnIdle(t *testing.T) {
487 a := &Agent{Task: "initial delegated task"}
488 a.setTurnState(TurnQueued, "")
489
490 if err := updateAgentFromEvent(a, NewEvent(EventAgentReady, map[string]any{"lifetime_turns": 0, "current_run_turns": 0})); err != nil {
491 t.Fatal(err)
492 }
493 if got := a.TurnState(); got != TurnQueued {
494 t.Fatalf("ready changed turn state to %s, want %s", got, TurnQueued)
495 }
496 if err := updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{
497 "turn_id": "turn-1",
498 "lifetime_turns": 1,
499 "current_run_turns": 1,
500 })); err != nil {
501 t.Fatal(err)
502 }
503 if got := a.TurnState(); got != TurnRunning {
504 t.Fatalf("turn.started changed turn state to %s, want %s", got, TurnRunning)
505 }
506}
507
508func TestUpdateAgentFromEventIgnoresStaleRejectedFollowUp(t *testing.T) {
509 a := &Agent{}
510 a.setResumePrompt("current prompt", time.Now())
511 if err := updateAgentFromEvent(a, NewEvent("error", map[string]any{
512 "code": "turn_rejected", "reason": "busy", "command_id": "stale-command",
513 })); err != nil {
514 t.Fatal(err)
515 }
516 if prompt, _ := a.ResumePromptInfo(); prompt != "current prompt" {
517 t.Fatalf("stale rejection changed active prompt to %q", prompt)
518 }
519}
520
521func TestUpdateAgentFromEventRecoversRejectedFollowUp(t *testing.T) {
522 a := &Agent{}
523 a.setResumePrompt("retry me", time.Now())
524 a.setTurnState(TurnQueued, "turn-1")
525 if err := updateAgentFromEvent(a, NewEvent("error", map[string]any{
526 "code": "turn_rejected",
527 "reason": "busy",
528 "command_id": a.resumePromptCommandID(),
529 })); err != nil {
530 t.Fatal(err)
531 }
532 if got := a.TurnState(); got != TurnIdle {
533 t.Fatalf("rejected turn state = %s, want %s", got, TurnIdle)
534 }
535 if prompt, _ := a.ResumePromptInfo(); prompt != "" {
536 t.Fatalf("rejected prompt remained active: %q", prompt)
537 }
538 queued := a.resumePromptQueueSnapshot()
539 if len(queued) != 1 || queued[0].Prompt != "retry me" {
540 t.Fatalf("rejected prompt queue = %+v, want retry me", queued)
541 }
542}
543
544func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {
545 a := &Agent{}
546 a.setTurnState(TurnRunning, "turn-1")
547 persisted := 0
548 a.persistFn = func(*Agent) error { persisted++; return nil }
549
550 updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"}))
551
552 if got := a.TurnState(); got != TurnRunning {
553 t.Fatalf("turn state = %q, want %q", got, TurnRunning)
554 }
555 if got := a.CurrentTurnID(); got != "turn-1" {
556 t.Fatalf("turn ID = %q, want turn-1", got)
557 }
558 if persisted != 0 {
559 t.Fatalf("persist called %d times for provider/tool-loop turn_end, want 0", persisted)
560 }
561}
562
563func TestUpdateAgentFromEventPersistsTerminalTurnEndWithStep(t *testing.T) {
564 a := &Agent{}
565 persisted := 0
566 a.persistFn = func(*Agent) error { persisted++; return nil }
567
568 updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
569
570 if got := a.TurnState(); got != TurnFailed {
571 t.Fatalf("turn state = %q, want %q", got, TurnFailed)
572 }
573 if persisted != 1 {
574 t.Fatalf("persist called %d times for terminal turn_end, want 1", persisted)
575 }
576}
577
578func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) {
579 called := make(chan struct{}, 1)
580 a := &Agent{}
581 a.SetOnTurnEnd(func(step int, errMsg string) {
582 called <- struct{}{}
583 })
584
585 notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"}))
586
587 select {
588 case <-called:
589 t.Fatal("OnTurnEnd fired for provider/tool-loop turn_end without step")
590 case <-time.After(50 * time.Millisecond):
591 }
592}
593
594func TestNotifyPromptTurnEndFiresForDaemonPromptCompletion(t *testing.T) {
595 type got struct {
596 step int
597 err string
598 }
599 called := make(chan got, 1)
600 a := &Agent{}
601 a.SetOnTurnEnd(func(step int, errMsg string) {
602 called <- got{step: step, err: errMsg}
603 })
604
605 notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
606
607 select {
608 case g := <-called:
609 if g.step != 2 || g.err != "boom" {
610 t.Fatalf("callback = (%d, %q); want (2, boom)", g.step, g.err)
611 }
612 case <-time.After(time.Second):
613 t.Fatal("OnTurnEnd did not fire for prompt-level turn_end with step")
614 }
615}
616
617func TestSetOnTurnEndReplaysPendingNoticesInArrivalOrder(t *testing.T) {
618 a := &Agent{}
619 notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(1)}))
620 notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
621
622 type notice struct {
623 step int
624 err string
625 }
626 var got []notice
627 a.SetOnTurnEnd(func(step int, errMsg string) {
628 got = append(got, notice{step: step, err: errMsg})
629 })
630
631 want := []notice{{step: 1}, {step: 2, err: "boom"}}
632 if len(got) != len(want) {
633 t.Fatalf("replayed %d notices, want %d: %+v", len(got), len(want), got)
634 }
635 for i := range want {
636 if got[i] != want[i] {
637 t.Errorf("replayed notice %d = %+v, want %+v", i, got[i], want[i])
638 }
639 }
640}
641
642func TestUpdateAgentFromEventDoesNotCountProviderTurnStarts(t *testing.T) {
643 a := &Agent{}
644 a.setTurnCounts(4, 2)
645 a.setTurnState(TurnRunning, "turn-1")
646 persisted := 0
647 a.persistFn = func(*Agent) error { persisted++; return nil }
648
649 updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{"step": float64(7), "nested_turn": true}))
650
651 if got := a.LifetimeTurnsValue(); got != 4 {
652 t.Fatalf("lifetime turns = %d, want 4", got)
653 }
654 if got := a.CurrentRunTurnsValue(); got != 2 {
655 t.Fatalf("current-run turns = %d, want 2", got)
656 }
657 if got := a.TurnState(); got != TurnRunning {
658 t.Fatalf("turn state = %s, want %s", got, TurnRunning)
659 }
660 if persisted != 0 {
661 t.Fatalf("provider turn start persisted %d times, want 0", persisted)
662 }
663}
664
665func TestUpdateAgentFromEventPersistsCanonicalTurnCounters(t *testing.T) {
666 stateDir := t.TempDir()
667 a := &Agent{ID: "counter-agent", stateDir: stateDir}
668 a.persistFn = func(agent *Agent) error {
669 if err := writeAgentMeta(stateDir, agent); err != nil {
670 t.Errorf("write agent metadata: %v", err)
671 return err
672 }
673 return nil
674 }
675
676 updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{
677 "turn_id": "turn-3",
678 "lifetime_turns": 3,
679 "current_run_turns": 1,
680 }))
681
682 meta, err := readAgentMeta(stateDir)
683 if err != nil {
684 t.Fatal(err)
685 }
686 if meta.LifetimeTurns != 3 || meta.CurrentRunTurns != 1 {
687 t.Fatalf("persisted counters = (%d, %d), want (3, 1)", meta.LifetimeTurns, meta.CurrentRunTurns)
688 }
689 if got := a.Snapshot(); got.LifetimeTurns != 3 || got.CurrentRunTurns != 1 {
690 t.Fatalf("snapshot counters = (%d, %d), want (3, 1)", got.LifetimeTurns, got.CurrentRunTurns)
691 }
692}
693
694func TestRecordPersistenceErrorPreservesFirstFailure(t *testing.T) {
695 a := &Agent{}
696 a.recordPersistenceError(fmt.Errorf("first failure"))
697 a.recordPersistenceError(fmt.Errorf("later failure"))
698 if got := a.Snapshot().Err; !strings.Contains(got, "first failure") || strings.Contains(got, "later failure") {
699 t.Fatalf("recorded persistence error = %q, want only first failure", got)
700 }
701}
702
703func TestRestoreResumePromptPreservesCommandIdentity(t *testing.T) {
704 a := &Agent{}
705 a.setResumePrompt("old prompt", time.Now())
706 oldID := a.resumePromptCommandID()
707 previous := a.setResumePrompt("new prompt", time.Now())
708 a.restoreResumePrompt(previous)
709 if got := a.resumePromptCommandID(); got != oldID {
710 t.Fatalf("restored command ID = %q, want %q", got, oldID)
711 }
712}
713
714func TestEventMatchesPendingResumeRejectsIdentityLessRejection(t *testing.T) {
715 a := &Agent{resumePromptID: "command-1"}
716 if eventMatchesPendingResume(a, Event{Type: "error", Data: map[string]any{"code": "turn_rejected"}}) {
717 t.Fatal("identity-less turn rejection matched a pending prompt")
718 }
719 if !eventMatchesPendingResume(a, Event{Type: EventTurnStarted, Data: map[string]any{}}) {
720 t.Fatal("identity-less legacy turn.started did not match a pending prompt")
721 }
722 if !eventMatchesPendingResume(a, Event{Type: "error", Data: map[string]any{"code": "turn_rejected", "command_id": "command-1"}}) {
723 t.Fatal("matching turn rejection did not match a pending prompt")
724 }
725}
726
727func TestEventCounterRejectsNegativeFractionalAndOverflowValues(t *testing.T) {
728 cases := []struct {
729 name string
730 value any
731 ok bool
732 }{
733 {name: "valid integer", value: float64(3), ok: true},
734 {name: "negative integer", value: int64(-1)},
735 {name: "fractional float", value: 1.5},
736 {name: "negative float", value: -1.0},
737 {name: "fractional json number", value: json.Number("1.5")},
738 {name: "overflow float", value: float64(^uint(0) >> 1)},
739 }
740 for _, tc := range cases {
741 t.Run(tc.name, func(t *testing.T) {
742 got, ok := eventCounter(map[string]any{"counter": tc.value}, "counter")
743 if ok != tc.ok {
744 t.Fatalf("eventCounter ok = %t, want %t (value %v, got %d)", ok, tc.ok, tc.value, got)
745 }
746 })
747 }
748}
749
750func indexOf(xs []string, want string) int {
751 for i, x := range xs {
752 if x == want {
753 return i
754 }
755 }
756 return -1
757}
758
759func safeAt(xs []string, i int) string {
760 if i < 0 || i >= len(xs) {
761 return ""
762 }
763 return xs[i]
764}