1package agent
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8
9 "github.com/bnema/zut/packages/agent/subagents"
10)
11
12type fakeCompletionBatches struct {
13 batches [][]subagents.Completion
14 errors []error
15 waits int
16}
17
18type endlessCompletionBatches struct{ waits int }
19
20func (f *endlessCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) {
21 f.waits++
22 return []subagents.Completion{{AgentID: "worker", Status: "completed"}}, nil
23}
24
25func (f *fakeCompletionBatches) WaitIdle(context.Context) ([]subagents.Completion, error) {
26 index := f.waits
27 f.waits++
28 if index < len(f.errors) && f.errors[index] != nil {
29 return nil, f.errors[index]
30 }
31 if index >= len(f.batches) {
32 return nil, nil
33 }
34 return f.batches[index], nil
35}
36
37func TestRunHeadlessContinuationNoWorkerPath(t *testing.T) {
38 tracker := &fakeCompletionBatches{batches: [][]subagents.Completion{{}}}
39 called := false
40 got, err := runHeadlessContinuation(context.Background(), tracker, "initial synthesis", func(context.Context, string) (string, error) {
41 called = true
42 return "unexpected", nil
43 })
44 if err != nil {
45 t.Fatal(err)
46 }
47 if called || got != "initial synthesis" {
48 t.Fatalf("no-worker result = %q, called=%v", got, called)
49 }
50}
51
52func TestRunHeadlessContinuationBatchesFailuresAndSecondWave(t *testing.T) {
53 tracker := &fakeCompletionBatches{batches: [][]subagents.Completion{
54 {{AgentID: "worker-a", Status: "completed", Task: "inspect"}, {AgentID: "worker-b", Status: "failed", Task: "test", Error: "provider failed"}},
55 {{AgentID: "worker-c", Status: "completed", Task: "dependent"}},
56 {},
57 }}
58 var prompts []string
59 got, err := runHeadlessContinuation(context.Background(), tracker, "initial", func(_ context.Context, prompt string) (string, error) {
60 prompts = append(prompts, prompt)
61 return "synthesis " + string(rune('0'+len(prompts))), nil
62 })
63 if err != nil {
64 t.Fatal(err)
65 }
66 if got != "synthesis 2" || len(prompts) != 2 {
67 t.Fatalf("result=%q prompts=%d, want second-wave synthesis", got, len(prompts))
68 }
69 if !strings.Contains(prompts[0], "worker-b") || !strings.Contains(prompts[0], "provider failed") {
70 t.Fatalf("first completion batch omitted failed-worker evidence: %q", prompts[0])
71 }
72 if !strings.Contains(prompts[1], "worker-c") || strings.Contains(prompts[1], "worker-a") {
73 t.Fatalf("second completion batch was not isolated: %q", prompts[1])
74 }
75 if tracker.waits != 3 {
76 t.Fatalf("wait count=%d, want completion batches followed by empty terminal batch", tracker.waits)
77 }
78}
79
80func TestRunHeadlessContinuationStopsAtWaveLimit(t *testing.T) {
81 tracker := &endlessCompletionBatches{}
82 calls := 0
83 got, err := runHeadlessContinuation(context.Background(), tracker, "initial", func(context.Context, string) (string, error) {
84 calls++
85 return "synthesis", nil
86 })
87 if err == nil || !strings.Contains(err.Error(), "maximum of 32 completion waves") {
88 t.Fatalf("error = %v, want wave-limit error", err)
89 }
90 if got != "synthesis" || calls != maxHeadlessCompletionWaves || tracker.waits != maxHeadlessCompletionWaves+1 {
91 t.Fatalf("result=%q calls=%d waits=%d", got, calls, tracker.waits)
92 }
93}
94
95func TestRunHeadlessContinuationCancellation(t *testing.T) {
96 ctx, cancel := context.WithCancel(context.Background())
97 cancel()
98 want := errors.New("canceled")
99 tracker := &fakeCompletionBatches{errors: []error{want}}
100 got, err := runHeadlessContinuation(ctx, tracker, "initial", func(context.Context, string) (string, error) {
101 t.Fatal("parent should not run after cancellation")
102 return "", nil
103 })
104 var waitErr *headlessCompletionWaitError
105 if !errors.Is(err, want) || !errors.As(err, &waitErr) || got != "initial" {
106 t.Fatalf("result=(%q,%v), wait error=%v, want initial and cancellation wait error", got, err, waitErr)
107 }
108}
109
110func TestRunHeadlessContinuationParentErrorDoesNotRetry(t *testing.T) {
111 tracker := &fakeCompletionBatches{batches: [][]subagents.Completion{{{AgentID: "worker", Status: "completed"}}}}
112 want := errors.New("parent failed")
113 calls := 0
114 got, err := runHeadlessContinuation(context.Background(), tracker, "initial", func(context.Context, string) (string, error) {
115 calls++
116 return "partial", want
117 })
118 if !errors.Is(err, want) || got != "partial" || calls != 1 {
119 t.Fatalf("result=(%q,%v), calls=%d, want one non-retried parent failure", got, err, calls)
120 }
121}