v0.15.0 6b178f8

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close