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