v0.13.0 befd698

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 TestOrchestratorSlashTogglesAutoSubagents(t *testing.T) {
87	allowed := true
88	enabled := false
89	supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
90	t.Cleanup(supervisor.StopAll)
91	store := &failingAutoSubagentsSettingsStore{}
92	agent := core.NewAgent(nil, "test-model", "base system", core.Registry{})
93	interactive := NewInteractive(InteractiveConfig{
94		Agent:                       agent,
95		Supervisor:                  supervisor,
96		AutoSubagentsEnabled:        &enabled,
97		AutoSubagentsToolAllowed:    &allowed,
98		AutoSubagentsSystemAddendum: "orchestrator guidance",
99		SettingsStore:               store,
100	})
101	interactive.rend = nil
102
103	if interactive.runSlash(context.Background(), "/ORCHESTRATOR") {
104		t.Fatal("/orchestrator requested exit")
105	}
106	if interactive.cfg.AutoSubagentsEnabled == nil || !*interactive.cfg.AutoSubagentsEnabled {
107		t.Fatal("/orchestrator did not enable orchestration")
108	}
109	if store.calls != 1 || interactive.statusOK != "subagent orchestrator enabled" {
110		t.Fatalf("enable persistence/status = calls=%d status=%q", store.calls, interactive.statusOK)
111	}
112	if system, _ := agent.PromptConfig(); !strings.Contains(system, "orchestrator guidance") {
113		t.Fatalf("enabled system prompt = %q, want orchestrator guidance", system)
114	}
115
116	interactive.runSlash(context.Background(), "/orchestrator")
117	if interactive.cfg.AutoSubagentsEnabled == nil || *interactive.cfg.AutoSubagentsEnabled {
118		t.Fatal("/orchestrator did not disable orchestration")
119	}
120	if store.calls != 2 || interactive.statusOK != "subagent orchestrator disabled" {
121		t.Fatalf("disable persistence/status = calls=%d status=%q", store.calls, interactive.statusOK)
122	}
123	if system, _ := agent.PromptConfig(); strings.Contains(system, "orchestrator guidance") {
124		t.Fatalf("disabled system prompt still contains orchestrator guidance: %q", system)
125	}
126	if _, ok := agent.ToolsSnapshot()["subagent_spawn"]; !ok {
127		t.Fatal("disabling orchestration removed subagent_spawn")
128	}
129}
130
131func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) {
132	trueValue := true
133	falseValue := false
134
135	tests := []struct {
136		name       string
137		supervisor *subagents.Supervisor
138		allowed    *bool
139		wantHint   string
140	}{
141		{
142			name:     "supervisor unavailable",
143			allowed:  &trueValue,
144			wantHint: "subagent supervisor not available in this mode",
145		},
146		{
147			name:       "launch policy excludes tool",
148			supervisor: subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}),
149			allowed:    &falseValue,
150			wantHint:   "launch-time tool policy excludes subagent manager tools",
151		},
152	}
153	for _, tt := range tests {
154		t.Run(tt.name, func(t *testing.T) {
155			if tt.supervisor != nil {
156				t.Cleanup(tt.supervisor.StopAll)
157			}
158			iv := &Interactive{
159				cfg: InteractiveConfig{
160					AutoSubagentsEnabled:     &trueValue,
161					AutoSubagentsToolAllowed: tt.allowed,
162					Supervisor:               tt.supervisor,
163				},
164				settingsDialog: newSettingsDialog(),
165			}
166			iv.openSettingsDialog()
167
168			var item settingsItem
169			for _, candidate := range iv.settingsDialog.items {
170				if candidate.key == "auto_subagents_enabled" {
171					item = candidate
172					break
173				}
174			}
175			if !item.disabled {
176				t.Fatal("auto-subagents setting is not disabled")
177			}
178			if item.value {
179				t.Fatal("disabled auto-subagents setting is shown as enabled")
180			}
181			if !strings.Contains(item.hint, tt.wantHint) {
182				t.Fatalf("hint = %q; want %q", item.hint, tt.wantHint)
183			}
184		})
185	}
186}
187
188func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) {
189	allowed := false
190	enabled := false
191	supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
192	t.Cleanup(supervisor.StopAll)
193	iv := &Interactive{
194		agent: &core.Agent{Tools: core.Registry{}},
195		cfg: InteractiveConfig{
196			AutoSubagentsEnabled:     &enabled,
197			AutoSubagentsToolAllowed: &allowed,
198			Supervisor:               supervisor,
199		},
200		dirty: make(chan struct{}, 1),
201	}
202
203	iv.applyAutoSubagentsTool()
204	if _, ok := iv.agent.Tools["subagent_spawn"]; ok {
205		t.Fatal("subagent_spawn registered despite launch-time policy")
206	}
207	if _, ok := iv.agent.Tools["subagent_status"]; ok {
208		t.Fatal("subagent_status registered despite launch-time policy")
209	}
210	if _, ok := iv.agent.Tools["subagent_stop"]; ok {
211		t.Fatal("subagent_stop registered despite launch-time policy")
212	}
213	if _, ok := iv.agent.Tools["subagent_resume"]; ok {
214		t.Fatal("subagent_resume registered despite launch-time policy")
215	}
216	iv.applySettingToggle("auto_subagents_enabled", true)
217	iv.mu.Lock()
218	statusErr := iv.statusErr
219	value := iv.cfg.AutoSubagentsEnabled != nil && *iv.cfg.AutoSubagentsEnabled
220	iv.mu.Unlock()
221	if value {
222		t.Fatal("auto-subagents toggle enabled despite launch-time policy")
223	}
224	if !strings.Contains(statusErr, "launch-time tool policy") {
225		t.Fatalf("toggle error = %q; want launch-time policy hint", statusErr)
226	}
227}
228
229func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T) {
230	for _, tc := range []struct {
231		name          string
232		spawnAllowed  bool
233		statusAllowed bool
234		stopAllowed   bool
235		resumeAllowed bool
236		wantSpawn     bool
237		wantStatus    bool
238		wantStop      bool
239		wantResume    bool
240	}{
241		{name: "status only", statusAllowed: true, wantStatus: true},
242		{name: "spawn only", spawnAllowed: true, wantSpawn: true},
243		{name: "stop only", stopAllowed: true, wantStop: true},
244		{name: "resume only", resumeAllowed: true, wantResume: true},
245		{name: "all", spawnAllowed: true, statusAllowed: true, stopAllowed: true, resumeAllowed: true, wantSpawn: true, wantStatus: true, wantStop: true, wantResume: true},
246	} {
247		t.Run(tc.name, func(t *testing.T) {
248			enabled := true
249			spawnAllowed := tc.spawnAllowed
250			statusAllowed := tc.statusAllowed
251			stopAllowed := tc.stopAllowed
252			resumeAllowed := tc.resumeAllowed
253			supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
254			t.Cleanup(supervisor.StopAll)
255			iv := &Interactive{
256				agent: &core.Agent{Tools: core.Registry{}},
257				cfg: InteractiveConfig{
258					AutoSubagentsEnabled:           &enabled,
259					AutoSubagentsToolAllowed:       &spawnAllowed,
260					AutoSubagentsStatusToolAllowed: &statusAllowed,
261					AutoSubagentsStopToolAllowed:   &stopAllowed,
262					AutoSubagentsResumeToolAllowed: &resumeAllowed,
263					Supervisor:                     supervisor,
264				},
265				dirty: make(chan struct{}, 1),
266			}
267
268			iv.applyAutoSubagentsTool()
269			_, gotSpawn := iv.agent.Tools["subagent_spawn"]
270			_, gotStatus := iv.agent.Tools["subagent_status"]
271			_, gotStop := iv.agent.Tools["subagent_stop"]
272			_, gotResume := iv.agent.Tools["subagent_resume"]
273			if gotSpawn != tc.wantSpawn || gotStatus != tc.wantStatus || gotStop != tc.wantStop || gotResume != tc.wantResume {
274				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)
275			}
276		})
277	}
278}
279
280func TestAutoSubagentsDisabledKeepsToolsForExplicitUserRequests(t *testing.T) {
281	enabled := false
282	allowed := true
283	supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
284	t.Cleanup(supervisor.StopAll)
285	iv := NewInteractive(InteractiveConfig{
286		Agent:                       &core.Agent{Tools: core.Registry{}},
287		AutoSubagentsEnabled:        &enabled,
288		AutoSubagentsToolAllowed:    &allowed,
289		AutoSubagentsSystemAddendum: "primary-agent orchestrator",
290		Supervisor:                  supervisor,
291	})
292
293	spawn, ok := iv.agent.ToolsSnapshot()["subagent_spawn"].(*tools.SubagentSpawnTool)
294	if !ok {
295		t.Fatal("subagent_spawn is unavailable when automatic subagents are disabled")
296	}
297	if spawn.Enabled == nil || !spawn.Enabled() {
298		t.Fatal("subagent_spawn is disabled when a user explicitly requests delegation")
299	}
300	system, _ := iv.agent.PromptConfig()
301	if strings.Contains(system, "primary-agent orchestrator") {
302		t.Fatalf("disabled automatic subagents retained the orchestrator contract: %q", system)
303	}
304}
305
306type serializedAutoSubagentsClient struct {
307	started  chan struct{}
308	request  chan provider.Request
309	release  chan struct{}
310	finished chan struct{}
311}
312
313func (c *serializedAutoSubagentsClient) Name() string { return "serialized-auto-subagents-test" }
314
315func (c *serializedAutoSubagentsClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
316	c.request <- req
317	c.started <- struct{}{}
318	out := make(chan provider.Event, 1)
319	go func() {
320		defer close(out)
321		select {
322		case <-c.release:
323			out <- provider.EventDone{Stop: provider.StopEnd}
324		case <-ctx.Done():
325			out <- provider.EventDone{Stop: provider.StopAborted, Err: ctx.Err()}
326		}
327		close(c.finished)
328	}()
329	return out, nil
330}
331
332func TestAutoSubagentsPromptConfigurationIsSynchronized(t *testing.T) {
333	client := &serializedAutoSubagentsClient{
334		started:  make(chan struct{}, 1),
335		request:  make(chan provider.Request, 1),
336		release:  make(chan struct{}),
337		finished: make(chan struct{}),
338	}
339	agent := core.NewAgent(client, "test-model", "base system", nil)
340	supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
341	t.Cleanup(supervisor.StopAll)
342	allowed := true
343	iv := NewInteractive(InteractiveConfig{
344		Agent:                       agent,
345		Supervisor:                  supervisor,
346		AutoSubagentsToolAllowed:    &allowed,
347		AutoSubagentsSystemAddendum: "delegation guidance",
348	})
349
350	iv.startTurn(context.Background(), "hello")
351	select {
352	case <-client.started:
353	case <-time.After(time.Second):
354		t.Fatal("prompt did not reach provider")
355	}
356
357	request := <-client.request
358	if request.System != "base system" {
359		t.Fatalf("provider request system = %q; want base system snapshot", request.System)
360	}
361	applied := make(chan struct{})
362	go func() {
363		iv.applyAutoSubagentsSystemPrompt(true)
364		close(applied)
365	}()
366	select {
367	case <-applied:
368	case <-time.After(time.Second):
369		t.Fatal("system-prompt update did not apply while provider was running")
370	}
371	if system, _ := agent.PromptConfig(); !strings.Contains(system, "delegation guidance") {
372		t.Fatalf("system prompt missing applied guidance: %q", system)
373	}
374
375	close(client.release)
376	select {
377	case <-client.finished:
378	case <-time.After(time.Second):
379		t.Fatal("prompt did not finish after provider release")
380	}
381}

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