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