v0.11.2 619aabb

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

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 476

↑↓ moveenter openesc close