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}