v0.11.1 ab30a57

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

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