v0.13.0 befd698

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}

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