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