v0.12.3 38097d7

subagent_status_test.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"path/filepath"
8	"strings"
9	"testing"
10	"time"
11
12	"github.com/bnema/zut/packages/agent/subagents"
13)
14
15type statusTestRunner struct {
16	started chan<- struct{}
17	release <-chan struct{}
18	err     error
19}
20
21func (r statusTestRunner) Run(ctx context.Context, sink subagents.Sink) error {
22	if sink != nil {
23		sink.Transcript("PRIVATE_TRANSCRIPT_MARKER\n/private/worktree api_key=PRIVATE_KEY")
24	}
25	if r.started != nil {
26		r.started <- struct{}{}
27	}
28	if r.err != nil {
29		return r.err
30	}
31	if r.release == nil {
32		return nil
33	}
34	select {
35	case <-r.release:
36		return nil
37	case <-ctx.Done():
38		return ctx.Err()
39	}
40}
41
42func TestSubagentStatusIncludesTurnCounters(t *testing.T) {
43	entry := publicSubagentStatus(subagents.AgentSnapshot{
44		ID:              "counter-agent",
45		LifetimeTurns:   7,
46		CurrentRunTurns: 2,
47	})
48	if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 {
49		t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns)
50	}
51	encoded, err := json.Marshal(entry)
52	if err != nil {
53		t.Fatal(err)
54	}
55	var fields map[string]any
56	if err := json.Unmarshal(encoded, &fields); err != nil {
57		t.Fatal(err)
58	}
59	if fields["lifetime_turns"] != float64(7) || fields["current_run_turns"] != float64(2) {
60		t.Fatalf("encoded status counters = %#v", fields)
61	}
62}
63
64func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) {
65	var schema struct {
66		Type       string `json:"type"`
67		Properties map[string]struct {
68			Type string `json:"type"`
69		} `json:"properties"`
70		Required []string `json:"required"`
71	}
72	if err := json.Unmarshal((&SubagentStatusTool{}).Schema(), &schema); err != nil {
73		t.Fatal(err)
74	}
75	if schema.Type != "object" {
76		t.Fatalf("schema type = %q, want object", schema.Type)
77	}
78	if schema.Properties["agent_id"].Type != "string" {
79		t.Fatalf("agent_id schema = %#v, want string", schema.Properties["agent_id"])
80	}
81	if len(schema.Required) != 0 {
82		t.Fatalf("required = %v, want agent_id optional", schema.Required)
83	}
84}
85
86func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) {
87	root := t.TempDir()
88	started := make(chan struct{}, 1)
89	release := make(chan struct{})
90	manager := subagents.New(subagents.Config{
91		Root:     filepath.Join(root, "subagents"),
92		RepoRoot: root,
93		NewRunner: func(*subagents.Agent) subagents.Runner {
94			return statusTestRunner{started: started, release: release}
95		},
96	})
97	t.Cleanup(manager.StopAll)
98
99	agent, err := manager.Spawn(context.Background(), "review architecture\nPRIVATE_PROMPT_MARKER")
100	if err != nil {
101		t.Fatal(err)
102	}
103	select {
104	case <-started:
105	case <-time.After(time.Second):
106		t.Fatal("subagent did not start")
107	}
108
109	tool := &SubagentStatusTool{
110		Supervisor: manager,
111		Enabled:    func() bool { return true },
112	}
113	res, err := tool.Execute(context.Background(), json.RawMessage(`{}`), nil)
114	if err != nil {
115		t.Fatal(err)
116	}
117	if res.IsError {
118		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
119	}
120
121	var got struct {
122		Agents []struct {
123			ID          string `json:"agent_id"`
124			State       string `json:"state"`
125			StartedAt   string `json:"started_at"`
126			UpdatedAt   string `json:"updated_at"`
127			TaskSummary string `json:"task_summary"`
128		} `json:"agents"`
129	}
130	text := textResult(res.Content)
131	if err := json.Unmarshal([]byte(text), &got); err != nil {
132		t.Fatalf("status JSON: %v\n%s", err, text)
133	}
134	if len(got.Agents) != 1 {
135		t.Fatalf("listed agents = %d, want 1", len(got.Agents))
136	}
137	if got.Agents[0].ID != agent.ID {
138		t.Fatalf("agent id = %q, want %q", got.Agents[0].ID, agent.ID)
139	}
140	if got.Agents[0].State != "running" {
141		t.Fatalf("state = %q, want running", got.Agents[0].State)
142	}
143	if got.Agents[0].StartedAt == "" || got.Agents[0].UpdatedAt == "" {
144		t.Fatalf("timestamps = started %q updated %q, want both present", got.Agents[0].StartedAt, got.Agents[0].UpdatedAt)
145	}
146	if got.Agents[0].TaskSummary != "review architecture" {
147		t.Fatalf("task summary = %q, want first task line", got.Agents[0].TaskSummary)
148	}
149	for _, forbidden := range []string{
150		"PRIVATE_PROMPT_MARKER",
151		"PRIVATE_TRANSCRIPT_MARKER",
152		"/private/worktree",
153		"api_key",
154		"provider",
155		"transcript",
156	} {
157		if strings.Contains(strings.ToLower(text), strings.ToLower(forbidden)) {
158			t.Fatalf("status output contains forbidden %q:\n%s", forbidden, text)
159		}
160	}
161
162	close(release)
163	agent.Wait()
164}
165
166func TestSubagentStatusReportsQueuedWorkerAsStarting(t *testing.T) {
167	root := t.TempDir()
168	started := make(chan struct{}, 2)
169	release := make(chan struct{})
170	manager := subagents.New(subagents.Config{
171		Root:     filepath.Join(root, "subagents"),
172		RepoRoot: root,
173		Policy: subagents.SubagentPolicy{
174			MaxConcurrent: 1,
175		},
176		NewRunner: func(*subagents.Agent) subagents.Runner {
177			return statusTestRunner{started: started, release: release}
178		},
179	})
180	t.Cleanup(manager.StopAll)
181
182	active, err := manager.Spawn(context.Background(), "active worker")
183	if err != nil {
184		t.Fatal(err)
185	}
186	queued, err := manager.Spawn(context.Background(), "queued worker")
187	if err != nil {
188		t.Fatal(err)
189	}
190	select {
191	case <-started:
192	case <-time.After(time.Second):
193		t.Fatal("active subagent did not start")
194	}
195
196	tool := &SubagentStatusTool{
197		Supervisor: manager,
198		Enabled:    func() bool { return true },
199	}
200	res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"`+queued.ID+`"}`), nil)
201	if err != nil {
202		t.Fatal(err)
203	}
204	var got struct {
205		Agent struct {
206			State string `json:"state"`
207		} `json:"agent"`
208	}
209	if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
210		t.Fatal(err)
211	}
212	if got.Agent.State != "starting" {
213		t.Fatalf("queued state = %q, want starting", got.Agent.State)
214	}
215
216	close(release)
217	active.Wait()
218	queued.Wait()
219}
220
221func TestSubagentStatusQueriesOneWorkerAndReportsTerminalResultMetadata(t *testing.T) {
222	root := t.TempDir()
223	manager := subagents.New(subagents.Config{
224		Root:     filepath.Join(root, "subagents"),
225		RepoRoot: root,
226		NewRunner: func(*subagents.Agent) subagents.Runner {
227			return statusTestRunner{}
228		},
229	})
230	t.Cleanup(manager.StopAll)
231
232	agent, err := manager.Spawn(context.Background(), "finish the parser")
233	if err != nil {
234		t.Fatal(err)
235	}
236	agent.Wait()
237
238	tool := &SubagentStatusTool{
239		Supervisor: manager,
240		Enabled:    func() bool { return true },
241	}
242	res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"`+agent.ID+`"}`), nil)
243	if err != nil {
244		t.Fatal(err)
245	}
246	if res.IsError {
247		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
248	}
249
250	var got struct {
251		Agent struct {
252			ID       string `json:"agent_id"`
253			State    string `json:"state"`
254			Finished string `json:"finished_at"`
255			Result   *struct {
256				State     string `json:"state"`
257				Available bool   `json:"available"`
258			} `json:"result"`
259		} `json:"agent"`
260	}
261	text := textResult(res.Content)
262	if err := json.Unmarshal([]byte(text), &got); err != nil {
263		t.Fatalf("status JSON: %v\n%s", err, text)
264	}
265	if got.Agent.ID != agent.ID {
266		t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID)
267	}
268	if got.Agent.State != "completed" {
269		t.Fatalf("state = %q, want completed", got.Agent.State)
270	}
271	if got.Agent.Finished == "" {
272		t.Fatal("finished_at is empty for completed worker")
273	}
274	if got.Agent.Result == nil || !got.Agent.Result.Available || got.Agent.Result.State != "completed" {
275		t.Fatalf("result metadata = %#v, want available completed result", got.Agent.Result)
276	}
277}
278
279func TestSubagentStatusListsEmptySupervisor(t *testing.T) {
280	tool := &SubagentStatusTool{
281		Supervisor: newTestSupervisor(t),
282		Enabled:    func() bool { return true },
283	}
284
285	res, err := tool.Execute(context.Background(), json.RawMessage(`{}`), nil)
286	if err != nil {
287		t.Fatal(err)
288	}
289	if res.IsError {
290		t.Fatalf("unexpected tool error: %s", textResult(res.Content))
291	}
292	var got struct {
293		Agents []json.RawMessage `json:"agents"`
294	}
295	text := textResult(res.Content)
296	if err := json.Unmarshal([]byte(text), &got); err != nil {
297		t.Fatal(err)
298	}
299	if got.Agents == nil || len(got.Agents) != 0 {
300		t.Fatalf("agents = %#v, want present empty list", got.Agents)
301	}
302	if text != `{"agents":[]}` {
303		t.Fatalf("empty status JSON = %s, want {\"agents\":[]}", text)
304	}
305}
306
307func TestSubagentStatusRejectsUnknownWorker(t *testing.T) {
308	tool := &SubagentStatusTool{
309		Supervisor: newTestSupervisor(t),
310		Enabled:    func() bool { return true },
311	}
312
313	res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"missing"}`), nil)
314	if err != nil {
315		t.Fatal(err)
316	}
317	if !res.IsError || !strings.Contains(textResult(res.Content), "no such agent") {
318		t.Fatalf("result = %#v, want unknown-agent tool error", res)
319	}
320}
321
322func TestSubagentStatusMapsFailedAndCancelledWorkers(t *testing.T) {
323	for _, tc := range []struct {
324		name      string
325		runnerErr error
326		stop      bool
327		wantState string
328	}{
329		{name: "failed", runnerErr: errors.New("runner failed"), wantState: "failed"},
330		{name: "cancelled", stop: true, wantState: "cancelled"},
331	} {
332		t.Run(tc.name, func(t *testing.T) {
333			root := t.TempDir()
334			started := make(chan struct{}, 1)
335			release := make(chan struct{})
336			manager := subagents.New(subagents.Config{
337				Root:     filepath.Join(root, "subagents"),
338				RepoRoot: root,
339				NewRunner: func(*subagents.Agent) subagents.Runner {
340					return statusTestRunner{started: started, release: release, err: tc.runnerErr}
341				},
342			})
343			t.Cleanup(manager.StopAll)
344
345			agent, err := manager.Spawn(context.Background(), tc.name)
346			if err != nil {
347				t.Fatal(err)
348			}
349			select {
350			case <-started:
351			case <-time.After(time.Second):
352				t.Fatal("subagent did not start")
353			}
354			if tc.stop {
355				if err := manager.Stop(agent.ID); err != nil {
356					t.Fatal(err)
357				}
358			} else {
359				agent.Wait()
360			}
361			agent.Wait()
362
363			tool := &SubagentStatusTool{
364				Supervisor: manager,
365				Enabled:    func() bool { return true },
366			}
367			res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"`+agent.ID+`"}`), nil)
368			if err != nil {
369				t.Fatal(err)
370			}
371			var got struct {
372				Agent struct {
373					State string `json:"state"`
374				} `json:"agent"`
375			}
376			if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
377				t.Fatal(err)
378			}
379			if got.Agent.State != tc.wantState {
380				t.Fatalf("state = %q, want %q", got.Agent.State, tc.wantState)
381			}
382		})
383	}
384}

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