v0.11.2 619aabb

auto_subagents_policy_test.go RawHistoryFolder

1package modes
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/agent/tools"
12	"github.com/bnema/zut/packages/core"
13	"github.com/bnema/zut/packages/provider"
14)
15
16type failingAutoSubagentsSettingsStore struct {
17	SettingsStore
18	err   error
19	calls int
20}
21
22func (s *failingAutoSubagentsSettingsStore) SetAutoSubagents(bool) error {
23	s.calls++
24	return s.err
25}
26
27func TestAutoSubagentsToggleRollsBackInMemoryStateWhenPersistenceFails(t *testing.T) {
28	previous := true
29	allowed := true
30	supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
31	t.Cleanup(supervisor.StopAll)
32	store := &failingAutoSubagentsSettingsStore{err: errors.New("disk full")}
33	agent := core.NewAgent(nil, "test-model", "base system\n\nauto-subagents guidance", core.Registry{
34		"subagent_spawn":  &tools.SubagentSpawnTool{Supervisor: supervisor},
35		"subagent_status": &tools.SubagentStatusTool{Supervisor: supervisor},
36	})
37	interactive := NewInteractive(InteractiveConfig{
38		Agent:                          agent,
39		Supervisor:                     supervisor,
40		AutoSubagentsEnabled:           &previous,
41		AutoSubagentsToolAllowed:       &allowed,
42		AutoSubagentsStatusToolAllowed: &allowed,
43		AutoSubagentsSystemAddendum:    "auto-subagents guidance",
44		SettingsStore:                  store,
45	})
46	interactive.settingsDialog = newSettingsDialog()
47	interactive.rend = nil
48	interactive.openSettingsDialog()
49	beforeSystem, _ := agent.PromptConfig()
50	beforeTools := agent.ToolsSnapshot()
51
52	interactive.applySettingToggle("auto_subagents_enabled", false)
53
54	interactive.mu.Lock()
55	gotEnabled := interactive.cfg.AutoSubagentsEnabled != nil && *interactive.cfg.AutoSubagentsEnabled
56	statusErr := interactive.statusErr
57	interactive.mu.Unlock()
58	if !gotEnabled {
59		t.Fatal("auto-subagents flag stayed disabled after persistence failure")
60	}
61	if store.calls != 1 {
62		t.Fatalf("SetAutoSubagents calls = %d, want 1", store.calls)
63	}
64	if !strings.Contains(statusErr, "disk full") {
65		t.Fatalf("status error = %q, want persistence failure", statusErr)
66	}
67	afterSystem, _ := agent.PromptConfig()
68	if afterSystem != beforeSystem {
69		t.Fatalf("system prompt changed after persistence failure: %q -> %q", beforeSystem, afterSystem)
70	}
71	afterTools := agent.ToolsSnapshot()
72	if len(afterTools) != len(beforeTools) {
73		t.Fatalf("tool registry size = %d, want unchanged %d", len(afterTools), len(beforeTools))
74	}
75	for name, before := range beforeTools {
76		if afterTools[name] != before {
77			t.Fatalf("tool %q changed after persistence failure", name)
78		}
79	}
80	item := findSettingsItem(interactive.settingsDialog.items, "auto_subagents_enabled")
81	if item == nil || !item.value {
82		t.Fatalf("settings row = %#v, want restored enabled value", item)
83	}
84}
85
86func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) {
87	trueValue := true
88	falseValue := false
89
90	tests := []struct {
91		name       string
92		supervisor *subagents.Supervisor
93		allowed    *bool
94		wantHint   string
95	}{
96		{
97			name:     "supervisor unavailable",
98			allowed:  &trueValue,
99			wantHint: "subagent supervisor not available in this mode",
100		},
101		{
102			name:       "launch policy excludes tool",
103			supervisor: subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}),
104			allowed:    &falseValue,
105			wantHint:   "launch-time tool policy excludes subagent manager tools",
106		},
107	}
108	for _, tt := range tests {
109		t.Run(tt.name, func(t *testing.T) {
110			if tt.supervisor != nil {
111				t.Cleanup(tt.supervisor.StopAll)
112			}
113			iv := &Interactive{
114				cfg: InteractiveConfig{
115					AutoSubagentsEnabled:     &trueValue,
116					AutoSubagentsToolAllowed: tt.allowed,
117					Supervisor:               tt.supervisor,
118				},
119				settingsDialog: newSettingsDialog(),
120			}
121			iv.openSettingsDialog()
122
123			var item settingsItem
124			for _, candidate := range iv.settingsDialog.items {
125				if candidate.key == "auto_subagents_enabled" {
126					item = candidate
127					break
128				}
129			}
130			if !item.disabled {
131				t.Fatal("auto-subagents setting is not disabled")
132			}
133			if item.value {
134				t.Fatal("disabled auto-subagents setting is shown as enabled")
135			}
136			if !strings.Contains(item.hint, tt.wantHint) {
137				t.Fatalf("hint = %q; want %q", item.hint, tt.wantHint)
138			}
139		})
140	}
141}
142
143func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) {
144	allowed := false
145	enabled := false
146	supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
147	t.Cleanup(supervisor.StopAll)
148	iv := &Interactive{
149		agent: &core.Agent{Tools: core.Registry{}},
150		cfg: InteractiveConfig{
151			AutoSubagentsEnabled:     &enabled,
152			AutoSubagentsToolAllowed: &allowed,
153			Supervisor:               supervisor,
154		},
155		dirty: make(chan struct{}, 1),
156	}
157
158	iv.applyAutoSubagentsTool()
159	if _, ok := iv.agent.Tools["subagent_spawn"]; ok {
160		t.Fatal("subagent_spawn registered despite launch-time policy")
161	}
162	if _, ok := iv.agent.Tools["subagent_status"]; ok {
163		t.Fatal("subagent_status registered despite launch-time policy")
164	}
165	if _, ok := iv.agent.Tools["subagent_stop"]; ok {
166		t.Fatal("subagent_stop registered despite launch-time policy")
167	}
168	if _, ok := iv.agent.Tools["subagent_resume"]; ok {
169		t.Fatal("subagent_resume registered despite launch-time policy")
170	}
171	iv.applySettingToggle("auto_subagents_enabled", true)
172	iv.mu.Lock()
173	statusErr := iv.statusErr
174	value := iv.cfg.AutoSubagentsEnabled != nil && *iv.cfg.AutoSubagentsEnabled
175	iv.mu.Unlock()
176	if value {
177		t.Fatal("auto-subagents toggle enabled despite launch-time policy")
178	}
179	if !strings.Contains(statusErr, "launch-time tool policy") {
180		t.Fatalf("toggle error = %q; want launch-time policy hint", statusErr)
181	}
182}
183
184func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T) {
185	for _, tc := range []struct {
186		name          string
187		spawnAllowed  bool
188		statusAllowed bool
189		stopAllowed   bool
190		resumeAllowed bool
191		wantSpawn     bool
192		wantStatus    bool
193		wantStop      bool
194		wantResume    bool
195	}{
196		{name: "status only", statusAllowed: true, wantStatus: true},
197		{name: "spawn only", spawnAllowed: true, wantSpawn: true},
198		{name: "stop only", stopAllowed: true, wantStop: true},
199		{name: "resume only", resumeAllowed: true, wantResume: true},
200		{name: "all", spawnAllowed: true, statusAllowed: true, stopAllowed: true, resumeAllowed: true, wantSpawn: true, wantStatus: true, wantStop: true, wantResume: true},
201	} {
202		t.Run(tc.name, func(t *testing.T) {
203			enabled := true
204			spawnAllowed := tc.spawnAllowed
205			statusAllowed := tc.statusAllowed
206			stopAllowed := tc.stopAllowed
207			resumeAllowed := tc.resumeAllowed
208			supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
209			t.Cleanup(supervisor.StopAll)
210			iv := &Interactive{
211				agent: &core.Agent{Tools: core.Registry{}},
212				cfg: InteractiveConfig{
213					AutoSubagentsEnabled:           &enabled,
214					AutoSubagentsToolAllowed:       &spawnAllowed,
215					AutoSubagentsStatusToolAllowed: &statusAllowed,
216					AutoSubagentsStopToolAllowed:   &stopAllowed,
217					AutoSubagentsResumeToolAllowed: &resumeAllowed,
218					Supervisor:                     supervisor,
219				},
220				dirty: make(chan struct{}, 1),
221			}
222
223			iv.applyAutoSubagentsTool()
224			_, gotSpawn := iv.agent.Tools["subagent_spawn"]
225			_, gotStatus := iv.agent.Tools["subagent_status"]
226			_, gotStop := iv.agent.Tools["subagent_stop"]
227			_, gotResume := iv.agent.Tools["subagent_resume"]
228			if gotSpawn != tc.wantSpawn || gotStatus != tc.wantStatus || gotStop != tc.wantStop || gotResume != tc.wantResume {
229				t.Fatalf("registered tools spawn=%v status=%v stop=%v resume=%v, want spawn=%v status=%v stop=%v resume=%v", gotSpawn, gotStatus, gotStop, gotResume, tc.wantSpawn, tc.wantStatus, tc.wantStop, tc.wantResume)
230			}
231		})
232	}
233}
234
235func TestAutoSubagentsDisabledKeepsToolsForExplicitUserRequests(t *testing.T) {
236	enabled := false
237	allowed := true
238	supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
239	t.Cleanup(supervisor.StopAll)
240	iv := NewInteractive(InteractiveConfig{
241		Agent:                       &core.Agent{Tools: core.Registry{}},
242		AutoSubagentsEnabled:        &enabled,
243		AutoSubagentsToolAllowed:    &allowed,
244		AutoSubagentsSystemAddendum: "primary-agent orchestrator",
245		Supervisor:                  supervisor,
246	})
247
248	spawn, ok := iv.agent.ToolsSnapshot()["subagent_spawn"].(*tools.SubagentSpawnTool)
249	if !ok {
250		t.Fatal("subagent_spawn is unavailable when automatic subagents are disabled")
251	}
252	if spawn.Enabled == nil || !spawn.Enabled() {
253		t.Fatal("subagent_spawn is disabled when a user explicitly requests delegation")
254	}
255	system, _ := iv.agent.PromptConfig()
256	if strings.Contains(system, "primary-agent orchestrator") {
257		t.Fatalf("disabled automatic subagents retained the orchestrator contract: %q", system)
258	}
259}
260
261type serializedAutoSubagentsClient struct {
262	started  chan struct{}
263	request  chan provider.Request
264	release  chan struct{}
265	finished chan struct{}
266}
267
268func (c *serializedAutoSubagentsClient) Name() string { return "serialized-auto-subagents-test" }
269
270func (c *serializedAutoSubagentsClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
271	c.request <- req
272	c.started <- struct{}{}
273	out := make(chan provider.Event, 1)
274	go func() {
275		defer close(out)
276		select {
277		case <-c.release:
278			out <- provider.EventDone{Stop: provider.StopEnd}
279		case <-ctx.Done():
280			out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
281		}
282		close(c.finished)
283	}()
284	return out, nil
285}
286
287func TestAutoSubagentsPromptConfigurationIsSynchronized(t *testing.T) {
288	client := &serializedAutoSubagentsClient{
289		started:  make(chan struct{}, 1),
290		request:  make(chan provider.Request, 1),
291		release:  make(chan struct{}),
292		finished: make(chan struct{}),
293	}
294	agent := core.NewAgent(client, "test-model", "base system", nil)
295	supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
296	t.Cleanup(supervisor.StopAll)
297	allowed := true
298	iv := NewInteractive(InteractiveConfig{
299		Agent:                       agent,
300		Supervisor:                  supervisor,
301		AutoSubagentsToolAllowed:    &allowed,
302		AutoSubagentsSystemAddendum: "delegation guidance",
303	})
304
305	iv.startTurn(context.Background(), "hello")
306	select {
307	case <-client.started:
308	case <-time.After(time.Second):
309		t.Fatal("prompt did not reach provider")
310	}
311
312	request := <-client.request
313	if request.System != "base system" {
314		t.Fatalf("provider request system = %q; want base system snapshot", request.System)
315	}
316	applied := make(chan struct{})
317	go func() {
318		iv.applyAutoSubagentsSystemPrompt(true)
319		close(applied)
320	}()
321	select {
322	case <-applied:
323	case <-time.After(time.Second):
324		t.Fatal("system-prompt update did not apply while provider was running")
325	}
326	if system, _ := agent.PromptConfig(); !strings.Contains(system, "delegation guidance") {
327		t.Fatalf("system prompt missing applied guidance: %q", system)
328	}
329
330	close(client.release)
331	select {
332	case <-client.finished:
333	case <-time.After(time.Second):
334		t.Fatal("prompt did not finish after provider release")
335	}
336}

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 477

↑↓ moveenter openesc close