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}