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