v0.13.0 befd698

subagent_required_test.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"path/filepath"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/bnema/zut/packages/agent/subagents"
14	"github.com/bnema/zut/packages/core"
15)
16
17func TestRequiredSubagentSpawnBlocksUntilTerminalOutcome(t *testing.T) {
18	root := t.TempDir()
19	started := make(chan struct{})
20	release := make(chan struct{})
21	manager := subagents.New(subagents.Config{
22		Root:     filepath.Join(root, "subagents"),
23		RepoRoot: root,
24		NewRunner: func(*subagents.Agent) subagents.Runner {
25			return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
26				close(started)
27				select {
28				case <-release:
29					return nil
30				case <-ctx.Done():
31					return ctx.Err()
32				}
33			})
34		},
35	})
36	t.Cleanup(manager.StopAll)
37
38	spawned := make(chan bool, 1)
39	tool := &SubagentSpawnTool{
40		Supervisor: manager,
41		Enabled:    func() bool { return true },
42		OnSpawned: func(_ *subagents.Agent, _ string, required bool) {
43			spawned <- required
44		},
45	}
46	result := make(chan core.ToolResult, 1)
47	errCh := make(chan error, 1)
48	go func() {
49		got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil)
50		if err != nil {
51			errCh <- err
52			return
53		}
54		result <- got
55	}()
56
57	<-started
58	select {
59	case got := <-result:
60		t.Fatalf("required spawn returned while worker was running: %s", textResult(got.Content))
61	case err := <-errCh:
62		t.Fatalf("required spawn failed while worker was running: %v", err)
63	default:
64	}
65	select {
66	case required := <-spawned:
67		if !required {
68			t.Fatal("required callback was reported as optional")
69		}
70	default:
71		t.Fatal("required spawn callback was not delivered")
72	}
73
74	close(release)
75	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
76	defer cancel()
77	select {
78	case err := <-errCh:
79		t.Fatalf("required spawn failed: %v", err)
80	case got := <-result:
81		text := textResult(got.Content)
82		if got.IsError || !strings.Contains(text, "required") || !strings.Contains(text, "satisfied") {
83			t.Fatalf("required result = %#v\n%s", got, text)
84		}
85		if details, ok := got.Details.(map[string]any); !ok || details["required"] != true || details["requirement_state"] != "satisfied" {
86			t.Fatalf("required details = %#v", got.Details)
87		}
88	case <-ctx.Done():
89		t.Fatal("required spawn did not return after worker completion")
90	}
91}
92
93func TestRequiredSubagentResumeRetriesUnmetWorkAsRequired(t *testing.T) {
94	root := t.TempDir()
95	attempt := 0
96	manager := subagents.New(subagents.Config{
97		Root:     filepath.Join(root, "subagents"),
98		RepoRoot: root,
99		NewRunner: func(*subagents.Agent) subagents.Runner {
100			attempt++
101			current := attempt
102			return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
103				if current == 1 {
104					return errors.New("first review failed")
105				}
106				return nil
107			})
108		},
109	})
110	t.Cleanup(manager.StopAll)
111	spawn := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
112	first, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","required":true}`), nil)
113	if err != nil {
114		t.Fatal(err)
115	}
116	if !first.IsError {
117		t.Fatalf("first required result should remain unmet: %s", textResult(first.Content))
118	}
119	worker := manager.List()[0]
120	worker.Wait()
121
122	var callbackRequired bool
123	resume := &SubagentResumeTool{
124		Supervisor: manager,
125		Enabled:    func() bool { return true },
126		BeforeResumed: func(_ *subagents.Agent, _ string, required bool) func() {
127			callbackRequired = required
128			return nil
129		},
130	}
131	got, err := resume.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"agent_id":%q,"prompt":"retry"}`, worker.ID)), nil)
132	if err != nil {
133		t.Fatal(err)
134	}
135	if got.IsError || !callbackRequired || !strings.Contains(textResult(got.Content), "satisfied") {
136		t.Fatalf("required retry = %#v callback_required=%v\n%s", got, callbackRequired, textResult(got.Content))
137	}
138}
139
140func TestIndeterminateRequiredWorkNeedsExplicitUserReconciliation(t *testing.T) {
141	root := t.TempDir()
142	started := make(chan struct{})
143	first := subagents.New(subagents.Config{
144		Root: filepath.Join(root, "subagents"), RepoRoot: root,
145		NewRunner: func(*subagents.Agent) subagents.Runner {
146			return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
147				close(started)
148				<-ctx.Done()
149				return ctx.Err()
150			})
151		},
152	})
153	a, err := first.SpawnReq(context.Background(), subagents.SpawnRequest{Task: "non-idempotent release action", Required: true})
154	if err != nil {
155		t.Fatal(err)
156	}
157	<-started
158	t.Cleanup(first.StopAll)
159
160	runs := 0
161	second := subagents.New(subagents.Config{
162		Root: filepath.Join(root, "subagents"), RepoRoot: root,
163		NewRunner: func(*subagents.Agent) subagents.Runner {
164			runs++
165			return subagents.RunnerFunc(func(context.Context, subagents.Sink) error { return nil })
166		},
167	})
168	t.Cleanup(second.StopAll)
169	if _, errs := second.Reload(); len(errs) != 0 {
170		t.Fatalf("reload errors: %v", errs)
171	}
172	resume := &SubagentResumeTool{Supervisor: second, Enabled: func() bool { return true }}
173	got, err := resume.Execute(context.Background(), json.RawMessage(fmt.Sprintf(`{"agent_id":%q,"prompt":"retry"}`, a.ID)), nil)
174	if err != nil {
175		t.Fatal(err)
176	}
177	if !got.IsError || runs != 0 || !strings.Contains(textResult(got.Content), "user must inspect") {
178		t.Fatalf("automatic indeterminate retry = %#v runs=%d\n%s", got, runs, textResult(got.Content))
179	}
180	first.StopAll()
181
182	confirmed, err := second.RestartTask(context.Background(), a.ID)
183	if err != nil {
184		t.Fatalf("explicit user restart: %v", err)
185	}
186	confirmed.Wait()
187	if runs != 1 || confirmed.Snapshot().Requirement.State != subagents.RequirementSatisfied {
188		t.Fatalf("explicit reconciliation runs=%d requirement=%+v", runs, confirmed.Snapshot().Requirement)
189	}
190}
191
192func TestRequiredSubagentFailureStaysUnmet(t *testing.T) {
193	root := t.TempDir()
194	manager := subagents.New(subagents.Config{
195		Root:     filepath.Join(root, "subagents"),
196		RepoRoot: root,
197		NewRunner: func(*subagents.Agent) subagents.Runner {
198			return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
199				return errors.New("finalizer failed")
200			})
201		},
202	})
203	t.Cleanup(manager.StopAll)
204	tool := &SubagentSpawnTool{Supervisor: manager, Enabled: func() bool { return true }}
205
206	got, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"finish the release","required":true}`), nil)
207	if err != nil {
208		t.Fatal(err)
209	}
210	if !strings.Contains(textResult(got.Content), "failed") {
211		t.Fatalf("required failure was not surfaced: %s", textResult(got.Content))
212	}
213	agents := manager.List()
214	if len(agents) != 1 || !agents[0].Snapshot().Requirement.Unmet() {
215		t.Fatalf("failed required worker was not retained as unmet: %#v", agents)
216	}
217	status := &SubagentStatusTool{Supervisor: manager, Enabled: func() bool { return true }}
218	public, err := status.Execute(context.Background(), json.RawMessage(`{}`), nil)
219	if err != nil {
220		t.Fatal(err)
221	}
222	publicText := textResult(public.Content)
223	if !strings.Contains(publicText, `"error_code":"failed"`) || strings.Contains(publicText, "finalizer failed") {
224		t.Fatalf("public requirement status exposed raw diagnostics: %s", publicText)
225	}
226}

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