1package modes
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/bnema/zut/packages/agent/subagents"
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"
14)
15
16const autoSubagentsTestTimeout = 10 * time.Second
17
18func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) {
19 const onDemand = "delegate only on an explicit user request"
20 iv := &Interactive{
21 agent: &core.Agent{System: "base system"},
22 cfg: InteractiveConfig{
23 AutoSubagentsSystemAddendum: "auto-subagents guidance",
24 OnDemandSubagentsSystemAddendum: onDemand,
25 SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]",
26 Supervisor: &subagents.Supervisor{},
27 },
28 }
29
30 iv.applyAutoSubagentsSystemPrompt(true)
31 if !strings.Contains(iv.agent.System, "[subagents_list]") || !strings.Contains(iv.agent.System, "auto-subagents guidance") {
32 t.Fatalf("enabled system prompt missing subagent blocks: %q", iv.agent.System)
33 }
34 before := iv.agent.System
35 iv.applyAutoSubagentsSystemPrompt(true)
36 if iv.agent.System != before {
37 t.Fatalf("enabling twice changed system prompt: %q -> %q", before, iv.agent.System)
38 }
39
40 iv.applyAutoSubagentsSystemPrompt(false)
41 if strings.Contains(iv.agent.System, "[subagents_list]") || strings.Contains(iv.agent.System, "auto-subagents guidance") {
42 t.Fatalf("disabled system prompt retained subagent blocks: %q", iv.agent.System)
43 }
44 if !strings.Contains(iv.agent.System, onDemand) {
45 t.Fatalf("disabled system prompt omitted on-demand delegation guidance: %q", iv.agent.System)
46 }
47}
48
49func TestAutoSubagentsTogglePreservesMatchingBasePromptBlocks(t *testing.T) {
50 manifest := "[subagents_list]\n- reviewer\n[/subagents_list]"
51 guidance := "auto-subagents guidance"
52 base := "base system\n\n" + manifest + "\n\n" + guidance
53 iv := &Interactive{
54 agent: &core.Agent{System: base},
55 cfg: InteractiveConfig{
56 AutoSubagentsSystemAddendum: guidance,
57 SubagentsSystemAddendum: manifest,
58 Supervisor: &subagents.Supervisor{},
59 },
60 }
61
62 iv.applyAutoSubagentsSystemPrompt(true)
63 if strings.Count(iv.agent.System, manifest) != 2 || strings.Count(iv.agent.System, guidance) != 2 {
64 t.Fatalf("enabling should append owned duplicate blocks: %q", iv.agent.System)
65 }
66 iv.applyAutoSubagentsSystemPrompt(false)
67 if got := strings.TrimSpace(iv.agent.System); got != base {
68 t.Fatalf("disabling changed matching base prompt: %q; want %q", got, base)
69 }
70}
71
72func TestAutoSubagentsSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) {
73 response := "first response line\n" + strings.Repeat("result ", 150) + "final marker"
74 mgr := subagents.New(subagents.Config{
75 Root: t.TempDir(),
76 RepoRoot: t.TempDir(),
77 NewRunner: func(*subagents.Agent) subagents.Runner {
78 return subagents.RunnerFunc(func(_ context.Context, sink subagents.Sink) error {
79 sink.Transcript(response)
80 return nil
81 })
82 },
83 })
84 a, err := mgr.Spawn(context.Background(), strings.Repeat("long task ", 150))
85 if err != nil {
86 t.Fatal(err)
87 }
88
89 iv := newQueuedAutoSubagentsInteractive()
90 iv.TrackSubagentWorker(a, a.Task)
91 update := waitForQueuedPrompt(t, iv)
92 if !strings.Contains(update, "final response:\n"+response) {
93 t.Fatalf("update missing complete final response: %q", update)
94 }
95}
96
97func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) {
98 mgr := subagents.New(subagents.Config{
99 Root: t.TempDir(),
100 RepoRoot: t.TempDir(),
101 NewRunner: func(*subagents.Agent) subagents.Runner {
102 return subagents.RunnerFunc(func(context.Context, subagents.Sink) error {
103 return errors.New("listener startup failed")
104 })
105 },
106 })
107 a, err := mgr.Spawn(context.Background(), "report the date")
108 if err != nil {
109 t.Fatal(err)
110 }
111
112 iv := newQueuedAutoSubagentsInteractive()
113 iv.TrackSubagentWorker(a, a.Task)
114
115 update := waitForQueuedPrompt(t, iv)
116 if !strings.Contains(update, "status: failed") {
117 t.Fatalf("update missing failed status: %q", update)
118 }
119 if !strings.Contains(update, "listener startup failed") {
120 t.Fatalf("update missing startup error: %q", update)
121 }
122}
123
124func TestTrackStoppedSubagentWorkerDeliversTerminalUpdate(t *testing.T) {
125 started := make(chan struct{})
126 mgr := subagents.New(subagents.Config{
127 Root: t.TempDir(),
128 RepoRoot: t.TempDir(),
129 NewRunner: func(*subagents.Agent) subagents.Runner {
130 return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
131 close(started)
132 <-ctx.Done()
133 return ctx.Err()
134 })
135 },
136 })
137 a, err := mgr.Spawn(context.Background(), "investigate the stuck worker")
138 if err != nil {
139 t.Fatal(err)
140 }
141 <-started
142 t.Cleanup(func() {
143 _ = mgr.Stop(a.ID)
144 a.Wait()
145 })
146
147 iv := newQueuedAutoSubagentsInteractive()
148 iv.TrackStoppedSubagentWorker(a)
149 if err := mgr.Stop(a.ID); err != nil {
150 t.Fatal(err)
151 }
152
153 update := waitForQueuedPrompt(t, iv)
154 if !strings.Contains(update, "task: investigate the stuck worker") {
155 t.Fatalf("stop update missing task: %q", update)
156 }
157 if !strings.Contains(update, "status: killed") {
158 t.Fatalf("stop update missing killed status: %q", update)
159 }
160}
161
162func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) {
163 started := make(chan struct{})
164 mgr := subagents.New(subagents.Config{
165 Root: t.TempDir(),
166 RepoRoot: t.TempDir(),
167 NewRunner: func(*subagents.Agent) subagents.Runner {
168 return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
169 close(started)
170 <-ctx.Done()
171 return ctx.Err()
172 })
173 },
174 })
175 a, err := mgr.Spawn(context.Background(), "review the implementation")
176 if err != nil {
177 t.Fatal(err)
178 }
179 <-started
180 t.Cleanup(func() {
181 _ = mgr.Stop(a.ID)
182 a.Wait()
183 })
184
185 iv := newQueuedAutoSubagentsInteractive()
186 const followUp = "I applied your review. What do you think now?"
187 iv.TrackResumedSubagentWorker(a, followUp)
188 if a.OnTurnEnd == nil {
189 t.Fatal("resumed worker has no completion watcher")
190 }
191 a.OnTurnEnd(2, "")
192
193 update := waitForQueuedPrompt(t, iv)
194 if !strings.Contains(update, "task: "+followUp) {
195 t.Fatalf("follow-up update uses the original task instead of the new prompt: %q", update)
196 }
197 if !strings.Contains(update, "status: completed") {
198 t.Fatalf("follow-up update missing completed status: %q", update)
199 }
200}
201
202func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
203 started := make(chan struct{})
204 mgr := subagents.New(subagents.Config{
205 Root: t.TempDir(),
206 RepoRoot: t.TempDir(),
207 NewRunner: func(*subagents.Agent) subagents.Runner {
208 return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
209 close(started)
210 <-ctx.Done()
211 return ctx.Err()
212 })
213 },
214 })
215 a, err := mgr.Spawn(context.Background(), "report the date")
216 if err != nil {
217 t.Fatal(err)
218 }
219 <-started
220 t.Cleanup(func() {
221 _ = mgr.Stop(a.ID)
222 a.Wait()
223 })
224
225 iv := newQueuedAutoSubagentsInteractive()
226 entry := &subagentWatchEntry{agent: a, task: a.Task}
227 iv.subagentWatch = []*subagentWatchEntry{entry}
228 iv.completeSupervisorWatchEntry(entry, "completed", "")
229 iv.completeSupervisorWatchEntry(entry, "failed", "late error")
230
231 update := waitForQueuedPrompt(t, iv)
232 if !strings.Contains(update, "status: completed") {
233 t.Fatalf("update uses daemon status instead of turn outcome: %q", update)
234 }
235 iv.mu.Lock()
236 queued := len(iv.queued)
237 iv.mu.Unlock()
238 if queued != 1 {
239 t.Fatalf("queued updates = %d; want exactly one", queued)
240 }
241}
242
243type autoSubagentsQueueClient struct {
244 requests chan provider.Request
245}
246
247func (c *autoSubagentsQueueClient) Name() string { return "auto-subagents-queue-test" }
248
249func (c *autoSubagentsQueueClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
250 c.requests <- req
251 out := make(chan provider.Event, 1)
252 go func() {
253 defer close(out)
254 out <- provider.EventDone{
255 Stop: provider.StopEnd,
256 Message: provider.Message{
257 Role: provider.RoleAssistant,
258 Content: []provider.Content{provider.TextBlock{Text: "done"}},
259 },
260 }
261 }()
262 return out, nil
263}
264
265func TestAutoSubagentsUpdateQueuedAtTurnEndStartsFollowUp(t *testing.T) {
266 client := &autoSubagentsQueueClient{requests: make(chan provider.Request, 2)}
267 agent := core.NewAgent(client, "test-model", "", nil)
268 interactive := NewInteractive(InteractiveConfig{Agent: agent})
269 interactive.runCtx = context.Background()
270
271 var once sync.Once
272 agent.OnEvent = func(ev core.AgentEvent) {
273 if _, ok := ev.(core.EvDone); !ok {
274 return
275 }
276 once.Do(func() {
277 interactive.flushSupervisorSummary([]*subagentWatchEntry{{
278 agent: &subagents.Agent{},
279 task: "smoke test",
280 }})
281 })
282 }
283
284 interactive.startTurn(context.Background(), "initial")
285 select {
286 case <-client.requests:
287 case <-time.After(2 * time.Second):
288 t.Fatal("initial request did not start")
289 }
290
291 var followUp provider.Request
292 select {
293 case followUp = <-client.requests:
294 case <-time.After(2 * time.Second):
295 t.Fatal("queued auto-subagents update did not start a follow-up request")
296 }
297 foundUpdate := false
298 for _, message := range followUp.Messages {
299 for _, content := range message.Content {
300 if text, ok := content.(provider.TextBlock); ok && strings.Contains(text.Text, "[auto-subagents update]") {
301 foundUpdate = true
302 }
303 }
304 }
305 if !foundUpdate {
306 t.Fatalf("follow-up request did not contain the auto-subagents update: %#v", followUp.Messages)
307 }
308
309 deadline := time.NewTimer(2 * time.Second)
310 defer deadline.Stop()
311 for {
312 interactive.mu.Lock()
313 busy := interactive.busy
314 interactive.mu.Unlock()
315 if !busy && agent.QueuedMessageCount() == 0 {
316 return
317 }
318 select {
319 case <-deadline.C:
320 t.Fatal("interactive turn remained busy or retained a queued update")
321 case <-time.After(time.Millisecond):
322 }
323 }
324}
325
326func newQueuedAutoSubagentsInteractive() *Interactive {
327 return &Interactive{
328 agent: &core.Agent{},
329 busy: true,
330 compacting: true,
331 dirty: make(chan struct{}, 1),
332 }
333}
334
335func waitForQueuedPrompt(t *testing.T, iv *Interactive) string {
336 t.Helper()
337 deadline := time.NewTimer(autoSubagentsTestTimeout)
338 defer deadline.Stop()
339 ticker := time.NewTicker(time.Millisecond)
340 defer ticker.Stop()
341 for {
342 iv.mu.Lock()
343 if len(iv.queued) > 0 {
344 prompt := iv.queued[0]
345 iv.mu.Unlock()
346 return prompt
347 }
348 iv.mu.Unlock()
349 select {
350 case <-deadline.C:
351 t.Fatal("timed out waiting for auto-subagents update")
352 case <-ticker.C:
353 }
354 }
355}