v0.12.2 1adbe2f

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 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}

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