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 TestRequiredWorkerGateRejectsTerminalParentResponseUntilRetrySucceeds(t *testing.T) {
153 attempt := 0
154 cfg := newRuntimeTestConfig(t.TempDir(), t.TempDir(), func(*subagents.Agent) subagents.Runner {
155 attempt++
156 current := attempt
157 return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
158 if current == 1 {
159 return errors.New("review crashed")
160 }
161 return nil
162 })
163 })
164 rt := newSubagentRuntime(cfg)
165 defer func() { _ = rt.Close(context.Background()) }()
166 rt.SetActiveSession("parent-session")
167 worker, err := rt.Supervisor().SpawnReq(context.Background(), subagents.SpawnRequest{
168 Task: "required review",
169 RootSessionID: "parent-session",
170 Required: true,
171 })
172 if err != nil {
173 t.Fatal(err)
174 }
175 worker.Wait()
176
177 parent := &core.Agent{}
178 rt.WireRequiredWorkerGate(parent)
179 turnAllowed, turnReason, turnContext := parent.BeforeTurnContext(context.Background(), 1)
180 if !turnAllowed || turnReason != "" || !strings.Contains(turnContext, worker.ID) || !strings.Contains(turnContext, "subagent_resume") {
181 t.Fatalf("failed worker recovery context = (%v, %q, %q)", turnAllowed, turnReason, turnContext)
182 }
183 toolAllowed, toolReason, _ := parent.BeforeToolExecute(provider.ToolCallBlock{Name: "bash"})
184 if !toolAllowed || toolReason != "" {
185 t.Fatalf("independent tool allowed=%v reason=%q, want parent to remain usable", toolAllowed, toolReason)
186 }
187 toolAllowed, toolReason, _ = parent.BeforeToolExecute(provider.ToolCallBlock{Name: "subagent_resume"})
188 if !toolAllowed || toolReason != "" {
189 t.Fatalf("recovery tool allowed=%v reason=%q, want allowed", toolAllowed, toolReason)
190 }
191 allowed, reason, _ := parent.BeforeAssistantMessage("I opened the pull request")
192 if allowed || !strings.Contains(reason, worker.ID) {
193 t.Fatalf("terminal response allowed=%v reason=%q, want failed required worker gate", allowed, reason)
194 }
195
196 retried, err := rt.Supervisor().ResumeRequiredWithPrompt(context.Background(), worker.ID, "retry review")
197 if err != nil {
198 t.Fatal(err)
199 }
200 retried.Wait()
201 allowed, reason, _ = parent.BeforeAssistantMessage("The required review passed")
202 if !allowed || reason != "" {
203 t.Fatalf("terminal response after retry allowed=%v reason=%q, want allowed", allowed, reason)
204 }
205}