1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/bnema/zut/packages/agent/subagents"
14 "github.com/bnema/zut/packages/core"
15)
16
17func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) {
18 root := t.TempDir()
19 started := make(chan struct{})
20 release := make(chan struct{})
21 manager := subagents.New(subagents.Config{
22 Root: filepath.Join(root, "subagents"),
23 RepoRoot: root,
24 NewRunner: func(*subagents.Agent) subagents.Runner {
25 return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
26 close(started)
27 select {
28 case <-release:
29 return nil
30 case <-ctx.Done():
31 return ctx.Err()
32 }
33 })
34 },
35 })
36 t.Cleanup(manager.StopAll)
37
38 spawned := make(chan bool, 1)
39 tool := &SubagentSpawnTool{
40 Supervisor: manager,
41 Enabled: func() bool { return true },
42 OnSpawned: func(_ *subagents.Agent, _ string, required bool) {
43 spawned <- required
44 },
45 }
46 result := make(chan core.ToolResult, 1)
47 errCh := make(chan error, 1)
48 go func() {
49 got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil)
50 if err != nil {
51 errCh <- err
52 return
53 }
54 result <- got
55 }()
56
57 <-started
58 select {
59 case got := <-result:
60 t.Fatalf("required spawn returned while worker was running: %s", textResult(got.Content))
61 case err := <-errCh:
62 t.Fatalf("required spawn failed while worker was running: %v", err)
63 default:
64 }
65 select {
66 case required := <-spawned:
67 if !required {
68 t.Fatal("required callback was reported as optional")
69 }
70 default:
71 t.Fatal("required spawn callback was not delivered")
72 }
73
74 close(release)
75 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
76 defer cancel()
77 select {
78 case err := <-errCh:
79 t.Fatalf("required spawn failed: %v", err)
80 case got := <-result:
81 text := textResult(got.Content)
82 if got.IsError || !strings.Contains(text, "required") || !strings.Contains(text, "satisfied") {
83 t.Fatalf("required result = %#v\n%s", got, text)
84 }
85 if details, ok := got.Details.(map[string]any); !ok || details["required"] != true || details["requirement_state"] != "satisfied" {
86 t.Fatalf("required details = %#v", got.Details)
87 }
88 case <-ctx.Done():
89 t.Fatal("required spawn did not return after worker completion")
90 }
91}
92
93func TestRequiredSubagentResumeRetriesUnmetWorkAsRequired(t *testing.T) {
94 root := t.TempDir()
95 attempt := 0
96 manager := subagents.New(subagents.Config{
97 Root: filepath.Join(root, "subagents"),
98 RepoRoot: root,
99 NewRunner: func(*subagents.Agent) subagents.Runner {
100 attempt++
101 current := attempt
102 return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
103 if current == 1 {
104 return errors.New("first review failed")
105 }
106 return nil
107 })
108 },
109 })
110 t.Cleanup(manager.StopAll)
111 spawn := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
112 first, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","required":true}`), nil)
113 if err != nil {
114 t.Fatal(err)
115 }
116 if !first.IsError {
117 t.Fatalf("first required result should remain unmet: %s", textResult(first.Content))
118 }
119 worker := manager.List()[0]
120 worker.Wait()
121
122 var callbackRequired bool
123 resume := &SubagentResumeTool{
124 Supervisor: manager,
125 Enabled: func() bool { return true },
126 BeforeResumed: func(_ *subagents.Agent, _ string, required bool) func() {
127 callbackRequired = required
128 return nil
129 },
130 }
131 got, err := resume.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"agent_id":%q,"prompt":"retry"}`, worker.ID)), nil)
132 if err != nil {
133 t.Fatal(err)
134 }
135 if got.IsError || !callbackRequired || !strings.Contains(textResult(got.Content), "satisfied") {
136 t.Fatalf("required retry = %#v callback_required=%v\n%s", got, callbackRequired, textResult(got.Content))
137 }
138}
139
140func TestIndeterminateRequiredWorkNeedsExplicitUserReconciliation(t *testing.T) {
141 root := t.TempDir()
142 started := make(chan struct{})
143 first := subagents.New(subagents.Config{
144 Root: filepath.Join(root, "subagents"), RepoRoot: root,
145 NewRunner: func(*subagents.Agent) subagents.Runner {
146 return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
147 close(started)
148 <-ctx.Done()
149 return ctx.Err()
150 })
151 },
152 })
153 a, err := first.SpawnReq(context.Background(), subagents.SpawnRequest{Task: "non-idempotent release action", Required: true})
154 if err != nil {
155 t.Fatal(err)
156 }
157 <-started
158 t.Cleanup(first.StopAll)
159
160 runs := 0
161 second := subagents.New(subagents.Config{
162 Root: filepath.Join(root, "subagents"), RepoRoot: root,
163 NewRunner: func(*subagents.Agent) subagents.Runner {
164 runs++
165 return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
166 },
167 })
168 t.Cleanup(second.StopAll)
169 if _, errs := second.Reload(); len(errs) != 0 {
170 t.Fatalf("reload errors: %v", errs)
171 }
172 resume := &SubagentResumeTool{Supervisor: second, Enabled: func() bool { return true }}
173 got, err := resume.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"agent_id":%q,"prompt":"retry"}`, a.ID)), nil)
174 if err != nil {
175 t.Fatal(err)
176 }
177 if !got.IsError || runs != 0 || !strings.Contains(textResult(got.Content), "user must inspect") {
178 t.Fatalf("automatic indeterminate retry = %#v runs=%d\n%s", got, runs, textResult(got.Content))
179 }
180 first.StopAll()
181
182 confirmed, err := second.RestartTask(context.Background(), a.ID)
183 if err != nil {
184 t.Fatalf("explicit user restart: %v", err)
185 }
186 confirmed.Wait()
187 if runs != 1 || confirmed.Snapshot().Requirement.State != subagents.RequirementSatisfied {
188 t.Fatalf("explicit reconciliation runs=%d requirement=%+v", runs, confirmed.Snapshot().Requirement)
189 }
190}
191
192func TestRequiredSubagentFailureStaysUnmet(t *testing.T) {
193 root := t.TempDir()
194 manager := subagents.New(subagents.Config{
195 Root: filepath.Join(root, "subagents"),
196 RepoRoot: root,
197 NewRunner: func(*subagents.Agent) subagents.Runner {
198 return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
199 return errors.New("finalizer failed")
200 })
201 },
202 })
203 t.Cleanup(manager.StopAll)
204 tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
205
206 got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil)
207 if err != nil {
208 t.Fatal(err)
209 }
210 if !strings.Contains(textResult(got.Content), "failed") {
211 t.Fatalf("required failure was not surfaced: %s", textResult(got.Content))
212 }
213 agents := manager.List()
214 if len(agents) != 1 || !agents[0].Snapshot().Requirement.Unmet() {
215 t.Fatalf("failed required worker was not retained as unmet: %#v", agents)
216 }
217 status := &SubagentStatusTool{Supervisor: manager, Enabled: func() bool { return true }}
218 public, err := status.Execute(context.Background(), json.RawMessage(`{}`), nil)
219 if err != nil {
220 t.Fatal(err)
221 }
222 publicText := textResult(public.Content)
223 if !strings.Contains(publicText, `"error_code":"failed"`) || strings.Contains(publicText, "finalizer failed") {
224 t.Fatalf("public requirement status exposed raw diagnostics: %s", publicText)
225 }
226}