v0.12.0 0c5f8d5

auto_subagents_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close