subagent_requirement_gate_test.go RawHistoryFolder
1package agent
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8
9 "github.com/bnema/zut/packages/agent/subagents"
10 "github.com/bnema/zut/packages/core"
11 "github.com/bnema/zut/packages/provider"
12)
13
14func TestRequiredWorkerGateKeepsParentFreeWhileWorkerRuns(t *testing.T) {
15 started := make(chan struct{})
16 release := make(chan struct{})
17 cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
18 return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
19 close(started)
20 select {
21 case <-release:
22 return nil
23 case <-ctx.Done():
24 return ctx.Err()
25 }
26 })
27 })
28 rt := newSubagentRuntime(cfg)
29 defer func() { _ = rt.Close(context.Background()) }()
30 rt.SetActiveSession("parent-session")
31 a, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
32 Task: "required review",
33 RootSessionID: "parent-session",
34 Required: true,
35 })
36 if err != nil {
37 t.Fatal(err)
38 }
39 <-started
40
41 parent := &core.Agent{}
42 rt.WireRequiredWorkerGate(parent)
43 allowed, reason, turnContext := parent.BeforeTurnContext(context.Background(), 1)
44 if !allowed || reason != "" || turnContext != "" {
45 t.Fatalf("parent turn while worker runs = (%v, %q, %q), want immediate access", allowed, reason, turnContext)
46 }
47 if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" {
48 t.Fatalf("independent parent tool allowed=%v reason=%q", allowed, reason)
49 }
50 if allowed, reason, _ := parent.BeforeAssistantMessage("premature final answer"); allowed || !strings.Contains(reason, a.ID) {
51 t.Fatalf("terminal parent answer allowed=%v reason=%q, want deferred finalization", allowed, reason)
52 }
53
54 close(release)
55 a.Wait()
56 allowed, reason, turnContext = parent.BeforeTurnContext(context.Background(), 2)
57 if !allowed || reason != "" || !strings.Contains(turnContext, a.ID) {
58 t.Fatalf("completion context = (%v, %q, %q), want required outcome", allowed, reason, turnContext)
59 }
60}
61
62func TestRequiredWorkerUpdateIsAcknowledgedOnlyAfterParentCompletion(t *testing.T) {
63 cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
64 return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
65 })
66 rt := newSubagentRuntime(cfg)
67 defer func() { _ = rt.Close(context.Background()) }()
68 rt.SetActiveSession("parent-session")
69 worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
70 Task: "required validation", RootSessionID: "parent-session", Required: true,
71 })
72 if err != nil {
73 t.Fatal(err)
74 }
75 worker.Wait()
76 parent := &core.Agent{}
77 rt.WireRequiredWorkerGate(parent)
78
79 allowed, reason, update := parent.BeforeTurnContext(context.Background(), 1)
80 if !allowed || reason != "" || !strings.Contains(update, worker.ID) {
81 t.Fatalf("first required context allowed=%v reason=%q update=%q", allowed, reason, update)
82 }
83 if worker.Snapshot().Requirement.Notified {
84 t.Fatal("before-turn context was acknowledged before parent completion")
85 }
86 allowed, reason, update = parent.BeforeTurnContext(context.Background(), 1)
87 if !allowed || reason != "" || !strings.Contains(update, worker.ID) {
88 t.Fatalf("replayed required context allowed=%v reason=%q update=%q", allowed, reason, update)
89 }
90 if allowed, reason, _ := parent.BeforeAssistantMessage("consumed the required result"); !allowed || reason != "" {
91 t.Fatalf("parent completion allowed=%v reason=%q", allowed, reason)
92 }
93 if !worker.Snapshot().Requirement.Notified {
94 t.Fatal("successful parent completion did not acknowledge required context")
95 }
96}
97
98func TestRequiredWorkerGateFailsClosedOnReloadError(t *testing.T) {
99 cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
100 return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
101 })
102 rt := newSubagentRuntime(cfg)
103 defer func() { _ = rt.Close(context.Background()) }()
104 worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{Task: "persisted requirement", Required: true})
105 if err != nil {
106 t.Fatal(err)
107 }
108 worker.Wait()
109 ready := make(chan struct{})
110 close(ready)
111 rt.SetRequiredWorkerReady(ready, func() error { return errors.New("malformed persisted worker") })
112 parent := &core.Agent{}
113 rt.WireRequiredWorkerGate(parent)
114 allowed, reason, _ := parent.BeforeTurnContext(context.Background(), 1)
115 if allowed || reason != "loading persisted subagents failed: malformed persisted worker; resolve the supervisor reload error before continuing" {
116 t.Fatalf("reload gate allowed=%v reason=%q, want fail-closed reload error", allowed, reason)
117 }
118}
119
120func TestRequiredWorkerGateTreatsManualTerminalRemovalAsWaiver(t *testing.T) {
121 cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
122 return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
123 return errors.New("worker failed")
124 })
125 })
126 rt := newSubagentRuntime(cfg)
127 defer func() { _ = rt.Close(context.Background()) }()
128 rt.SetActiveSession("parent-session")
129 worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
130 Task: "required validation", RootSessionID: "parent-session", Required: true,
131 })
132 if err != nil {
133 t.Fatal(err)
134 }
135 worker.Wait()
136 parent := &core.Agent{}
137 rt.WireRequiredWorkerGate(parent)
138 if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" {
139 t.Fatalf("unmet requirement blocked independent tool: allowed=%v reason=%q", allowed, reason)
140 }
141 if allowed, _, _ := parent.BeforeAssistantMessage("done"); allowed {
142 t.Fatal("unmet requirement allowed terminal answer")
143 }
144 if err := rt.Supervisor().Remove(worker.ID); err != nil {
145 t.Fatalf("explicit user removal waiver: %v", err)
146 }
147 if allowed, reason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"}); !allowed || reason != "" {
148 t.Fatalf("tool after explicit waiver allowed=%v reason=%q", allowed, reason)
149 }
150}
151
152func TestRequiredWorkerGateWaitsForEveryRequiredWorker(t *testing.T) {
153 releases := []chan struct{}{make(chan struct{}), make(chan struct{})}
154 started := make(chan struct{}, len(releases))
155 runnerIndex := 0
156 cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
157 index := runnerIndex
158 runnerIndex++
159 return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
160 started <- struct{}{}
161 select {
162 case <-releases[index]:
163 return nil
164 case <-ctx.Done():
165 return ctx.Err()
166 }
167 })
168 })
169 rt := newSubagentRuntime(cfg)
170 defer func() { _ = rt.Close(context.Background()) }()
171 rt.SetActiveSession("parent-session")
172 first, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
173 Task: "first required check", RootSessionID: "parent-session", Required: true,
174 })
175 if err != nil {
176 t.Fatal(err)
177 }
178 second, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
179 Task: "second required check", RootSessionID: "parent-session", Required: true,
180 })
181 if err != nil {
182 t.Fatal(err)
183 }
184 <-started
185 <-started
186
187 parent := &core.Agent{}
188 rt.WireRequiredWorkerGate(parent)
189 close(releases[0])
190 first.Wait()
191 if allowed, reason, _ := parent.BeforeAssistantMessage("first worker completed"); allowed || !strings.Contains(reason, second.ID) {
192 t.Fatalf("terminal response after one required worker = (%v, %q), want remaining worker block", allowed, reason)
193 }
194
195 close(releases[1])
196 second.Wait()
197 if allowed, reason, _ := parent.BeforeAssistantMessage("both workers completed"); !allowed || reason != "" {
198 t.Fatalf("terminal response after all required workers = (%v, %q), want allowed", allowed, reason)
199 }
200}
201
202func TestRequiredWorkerGateRejectsTerminalParentResponseUntilRetrySucceeds(t *testing.T) {
203 attempt := 0
204 cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
205 attempt++
206 current := attempt
207 return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
208 if current == 1 {
209 return errors.New("review crashed")
210 }
211 return nil
212 })
213 })
214 rt := newSubagentRuntime(cfg)
215 defer func() { _ = rt.Close(context.Background()) }()
216 rt.SetActiveSession("parent-session")
217 worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
218 Task: "required review",
219 RootSessionID: "parent-session",
220 Required: true,
221 })
222 if err != nil {
223 t.Fatal(err)
224 }
225 worker.Wait()
226
227 parent := &core.Agent{}
228 rt.WireRequiredWorkerGate(parent)
229 turnAllowed, turnReason, turnContext := parent.BeforeTurnContext(context.Background(), 1)
230 if !turnAllowed || turnReason != "" || !strings.Contains(turnContext, worker.ID) || !strings.Contains(turnContext, "subagent_resume") {
231 t.Fatalf("failed worker recovery context = (%v, %q, %q)", turnAllowed, turnReason, turnContext)
232 }
233 toolAllowed, toolReason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"})
234 if !toolAllowed || toolReason != "" {
235 t.Fatalf("independent tool allowed=%v reason=%q, want parent to remain usable", toolAllowed, toolReason)
236 }
237 toolAllowed, toolReason, _ = parent.BeforeToolExecute(provider.ToolCallBlock{Name: "subagent_resume"})
238 if !toolAllowed || toolReason != "" {
239 t.Fatalf("recovery tool allowed=%v reason=%q, want allowed", toolAllowed, toolReason)
240 }
241 allowed, reason, _ := parent.BeforeAssistantMessage("I opened the pull request")
242 if allowed || !strings.Contains(reason, worker.ID) {
243 t.Fatalf("terminal response allowed=%v reason=%q, want failed required worker gate", allowed, reason)
244 }
245
246 retried, err := rt.Supervisor().ResumeRequiredWithPrompt(context.Background(), worker.ID, "retry review")
247 if err != nil {
248 t.Fatal(err)
249 }
250 retried.Wait()
251 allowed, reason, _ = parent.BeforeAssistantMessage("The required review passed")
252 if !allowed || reason != "" {
253 t.Fatalf("terminal response after retry allowed=%v reason=%q, want allowed", allowed, reason)
254 }
255}