v0.3.0 761f038

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

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