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}