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 }, subagents.AgentTraceView{})
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 TestPublicSubagentStatusUsesOrderedPrimaryOperation(t *testing.T) {
65 at := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
66 entry := publicSubagentStatus(subagents.AgentSnapshot{ID: "agent-1"}, subagents.AgentTraceView{
67 OpenOperations: []subagents.Operation{
68 {Type: "tool.started", StartedAt: at.Add(time.Second)},
69 {Type: "turn.started", StartedAt: at},
70 },
71 })
72 if entry.PrimaryOperation == nil || entry.PrimaryOperation.Type != "tool.started" {
73 t.Fatalf("primary operation = %#v", entry.PrimaryOperation)
74 }
75 encoded, err := json.Marshal(entry)
76 if err != nil {
77 t.Fatal(err)
78 }
79 var fields map[string]any
80 if err := json.Unmarshal(encoded, &fields); err != nil {
81 t.Fatal(err)
82 }
83 if _, found := fields["operation"]; found {
84 t.Fatalf("obsolete operation field in %#v", fields)
85 }
86 if _, found := fields["primary_operation"]; !found {
87 t.Fatalf("primary operation missing from %#v", fields)
88 }
89}
90
91func TestPublicSubagentStatusDoesNotInferAvailabilityWithoutResultReference(t *testing.T) {
92 entry := publicSubagentStatus(subagents.AgentSnapshot{
93 ID: "agent-1",
94 Result: &subagents.TurnResult{Status: subagents.ResultFailed},
95 }, subagents.AgentTraceView{})
96 if entry.Result == nil || entry.Result.Available {
97 t.Fatalf("result availability = %#v, want unavailable without result ref", entry.Result)
98 }
99}
100
101func TestPublicSubagentStatusIncludesResultDeliveryFacts(t *testing.T) {
102 entry := publicSubagentStatus(subagents.AgentSnapshot{ID: "agent-1"}, subagents.AgentTraceView{
103 Result: &subagents.ResultFact{Available: true, Ref: "subagent://agent-1/result"},
104 })
105 if entry.Result == nil || entry.Result.State != "unknown" || entry.Result.Delivered || entry.Result.DeliveryFailed {
106 t.Fatalf("result = %#v", entry.Result)
107 }
108 encoded, err := json.Marshal(entry)
109 if err != nil {
110 t.Fatal(err)
111 }
112 var fields map[string]json.RawMessage
113 if err := json.Unmarshal(encoded, &fields); err != nil {
114 t.Fatal(err)
115 }
116 var result map[string]any
117 if err := json.Unmarshal(fields["result"], &result); err != nil {
118 t.Fatal(err)
119 }
120 if result["delivered"] != false || result["delivery_failed"] != false {
121 t.Fatalf("result JSON = %#v", result)
122 }
123}
124
125func TestPublicSubagentStatusKeepsDurableDeliveryAfterTraceRestart(t *testing.T) {
126 entry := publicSubagentStatus(subagents.AgentSnapshot{
127 ID: "agent-1",
128 Result: &subagents.TurnResult{Status: subagents.ResultSucceeded},
129 ResultRef: "subagent://agent-1/result",
130 Requirement: subagents.RequirementSnapshot{Required: true, Notified: true},
131 }, subagents.AgentTraceView{})
132 if entry.Result == nil || !entry.Result.Available || !entry.Result.Delivered {
133 t.Fatalf("result = %#v, want durable delivered fact without transient trace", entry.Result)
134 }
135}
136
137func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) {
138 var schema struct {
139 Type string `json:"type"`
140 Properties map[string]struct {
141 Type string `json:"type"`
142 } `json:"properties"`
143 Required []string `json:"required"`
144 }
145 if err := json.Unmarshal((&SubagentStatusTool{}).Schema(), &schema); err != nil {
146 t.Fatal(err)
147 }
148 if schema.Type != "object" {
149 t.Fatalf("schema type = %q, want object", schema.Type)
150 }
151 if schema.Properties["agent_id"].Type != "string" {
152 t.Fatalf("agent_id schema = %#v, want string", schema.Properties["agent_id"])
153 }
154 if len(schema.Required) != 0 {
155 t.Fatalf("required = %v, want agent_id optional", schema.Required)
156 }
157}
158
159func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) {
160 root := t.TempDir()
161 started := make(chan struct{}, 1)
162 release := make(chan struct{})
163 manager := subagents.New(subagents.Config{
164 Root: filepath.Join(root, "subagents"),
165 RepoRoot: root,
166 NewRunner: func(*subagents.Agent) subagents.Runner {
167 return statusTestRunner{started: started, release: release}
168 },
169 })
170 t.Cleanup(manager.StopAll)
171
172 agent, err := manager.Spawn(context.Background(), "review architecture\nPRIVATE_PROMPT_MARKER")
173 if err != nil {
174 t.Fatal(err)
175 }
176 select {
177 case <-started:
178 case <-time.After(time.Second):
179 t.Fatal("subagent did not start")
180 }
181
182 tool := &SubagentStatusTool{
183 Supervisor: manager,
184 Enabled: func() bool { return true },
185 }
186 res, err := tool.Execute(context.Background(), json.RawMessage(`{}`), nil)
187 if err != nil {
188 t.Fatal(err)
189 }
190 if res.IsError {
191 t.Fatalf("unexpected tool error: %s", textResult(res.Content))
192 }
193
194 var got struct {
195 Agents []json.RawMessage `json:"agents"`
196 }
197 text := textResult(res.Content)
198 if err := json.Unmarshal([]byte(text), &got); err != nil {
199 t.Fatalf("status JSON: %v\n%s", err, text)
200 }
201 if len(got.Agents) != 1 {
202 t.Fatalf("listed agents = %d, want 1", len(got.Agents))
203 }
204 var entry map[string]json.RawMessage
205 if err := json.Unmarshal(got.Agents[0], &entry); err != nil {
206 t.Fatal(err)
207 }
208 for _, forbiddenField := range []string{"state", "process_state", "turn_state", "updated_at", "finished_at"} {
209 if _, present := entry[forbiddenField]; present {
210 t.Fatalf("status response exposes removed lifecycle field %q", forbiddenField)
211 }
212 }
213 var agentID, startedAt, taskSummary string
214 if err := json.Unmarshal(entry["agent_id"], &agentID); err != nil {
215 t.Fatal(err)
216 }
217 if err := json.Unmarshal(entry["started_at"], &startedAt); err != nil {
218 t.Fatal(err)
219 }
220 if err := json.Unmarshal(entry["task_summary"], &taskSummary); err != nil {
221 t.Fatal(err)
222 }
223 if agentID != agent.ID {
224 t.Fatalf("agent id = %q, want %q", agentID, agent.ID)
225 }
226 if startedAt == "" {
227 t.Fatal("started_at is empty")
228 }
229 if taskSummary != "review architecture" {
230 t.Fatalf("task summary = %q, want first task line", taskSummary)
231 }
232 if _, present := entry["last_event"]; !present {
233 t.Fatal("status response omits trace-backed last_event")
234 }
235 for _, forbidden := range []string{
236 "PRIVATE_PROMPT_MARKER",
237 "PRIVATE_TRANSCRIPT_MARKER",
238 "/private/worktree",
239 "api_key",
240 "provider",
241 "transcript",
242 } {
243 if strings.Contains(strings.ToLower(text), strings.ToLower(forbidden)) {
244 t.Fatalf("status output contains forbidden %q:\n%s", forbidden, text)
245 }
246 }
247
248 close(release)
249 agent.Wait()
250}
251
252func TestSubagentStatusReportsQueuedWorkerWithoutLifecycleState(t *testing.T) {
253 root := t.TempDir()
254 started := make(chan struct{}, 2)
255 release := make(chan struct{})
256 manager := subagents.New(subagents.Config{
257 Root: filepath.Join(root, "subagents"),
258 RepoRoot: root,
259 Policy: subagents.SubagentPolicy{
260 MaxConcurrent: 1,
261 },
262 NewRunner: func(*subagents.Agent) subagents.Runner {
263 return statusTestRunner{started: started, release: release}
264 },
265 })
266 t.Cleanup(manager.StopAll)
267
268 active, err := manager.Spawn(context.Background(), "active worker")
269 if err != nil {
270 t.Fatal(err)
271 }
272 queued, err := manager.Spawn(context.Background(), "queued worker")
273 if err != nil {
274 t.Fatal(err)
275 }
276 select {
277 case <-started:
278 case <-time.After(time.Second):
279 t.Fatal("active subagent did not start")
280 }
281
282 tool := &SubagentStatusTool{
283 Supervisor: manager,
284 Enabled: func() bool { return true },
285 }
286 res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"`+queued.ID+`"}`), nil)
287 if err != nil {
288 t.Fatal(err)
289 }
290 var got struct {
291 Agent struct {
292 ID string `json:"agent_id"`
293 Operation json.RawMessage `json:"operation"`
294 } `json:"agent"`
295 }
296 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
297 t.Fatal(err)
298 }
299 if got.Agent.ID != queued.ID {
300 t.Fatalf("agent id = %q, want %q", got.Agent.ID, queued.ID)
301 }
302 if len(got.Agent.Operation) != 0 {
303 t.Fatalf("queued operation = %s, want omitted", got.Agent.Operation)
304 }
305
306 close(release)
307 active.Wait()
308 queued.Wait()
309}
310
311func TestSubagentStatusQueriesOneWorkerAndReportsTerminalResultMetadata(t *testing.T) {
312 root := t.TempDir()
313 manager := subagents.New(subagents.Config{
314 Root: filepath.Join(root, "subagents"),
315 RepoRoot: root,
316 NewRunner: func(*subagents.Agent) subagents.Runner {
317 return statusTestRunner{}
318 },
319 })
320 t.Cleanup(manager.StopAll)
321
322 agent, err := manager.Spawn(context.Background(), "finish the parser")
323 if err != nil {
324 t.Fatal(err)
325 }
326 agent.Wait()
327
328 tool := &SubagentStatusTool{
329 Supervisor: manager,
330 Enabled: func() bool { return true },
331 }
332 res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"`+agent.ID+`"}`), nil)
333 if err != nil {
334 t.Fatal(err)
335 }
336 if res.IsError {
337 t.Fatalf("unexpected tool error: %s", textResult(res.Content))
338 }
339
340 var got struct {
341 Agent struct {
342 ID string `json:"agent_id"`
343 Result *struct {
344 State string `json:"state"`
345 Available bool `json:"available"`
346 } `json:"result"`
347 } `json:"agent"`
348 }
349 text := textResult(res.Content)
350 if err := json.Unmarshal([]byte(text), &got); err != nil {
351 t.Fatalf("status JSON: %v\n%s", err, text)
352 }
353 if got.Agent.ID != agent.ID {
354 t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID)
355 }
356 if got.Agent.Result == nil || !got.Agent.Result.Available || got.Agent.Result.State != "completed" {
357 t.Fatalf("result metadata = %#v, want available completed result", got.Agent.Result)
358 }
359}
360
361func TestSubagentStatusListsEmptySupervisor(t *testing.T) {
362 tool := &SubagentStatusTool{
363 Supervisor: newTestSupervisor(t),
364 Enabled: func() bool { return true },
365 }
366
367 res, err := tool.Execute(context.Background(), json.RawMessage(`{}`), nil)
368 if err != nil {
369 t.Fatal(err)
370 }
371 if res.IsError {
372 t.Fatalf("unexpected tool error: %s", textResult(res.Content))
373 }
374 var got struct {
375 Agents []json.RawMessage `json:"agents"`
376 }
377 text := textResult(res.Content)
378 if err := json.Unmarshal([]byte(text), &got); err != nil {
379 t.Fatal(err)
380 }
381 if got.Agents == nil || len(got.Agents) != 0 {
382 t.Fatalf("agents = %#v, want present empty list", got.Agents)
383 }
384 if text != `{"agents":[]}` {
385 t.Fatalf("empty status JSON = %s, want {\"agents\":[]}", text)
386 }
387}
388
389func TestSubagentStatusRejectsUnknownWorker(t *testing.T) {
390 tool := &SubagentStatusTool{
391 Supervisor: newTestSupervisor(t),
392 Enabled: func() bool { return true },
393 }
394
395 res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"missing"}`), nil)
396 if err != nil {
397 t.Fatal(err)
398 }
399 if !res.IsError || !strings.Contains(textResult(res.Content), "no such agent") {
400 t.Fatalf("result = %#v, want unknown-agent tool error", res)
401 }
402}
403
404func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testing.T) {
405 for _, tc := range []struct {
406 name string
407 runnerErr error
408 stop bool
409 wantTerminal string
410 }{
411 {name: "failed", runnerErr: errors.New("runner failed"), wantTerminal: "failed"},
412 {name: "cancelled", stop: true, wantTerminal: "cancelled"},
413 } {
414 t.Run(tc.name, func(t *testing.T) {
415 root := t.TempDir()
416 started := make(chan struct{}, 1)
417 release := make(chan struct{})
418 manager := subagents.New(subagents.Config{
419 Root: filepath.Join(root, "subagents"),
420 RepoRoot: root,
421 NewRunner: func(*subagents.Agent) subagents.Runner {
422 return statusTestRunner{started: started, release: release, err: tc.runnerErr}
423 },
424 })
425 t.Cleanup(manager.StopAll)
426
427 agent, err := manager.Spawn(context.Background(), tc.name)
428 if err != nil {
429 t.Fatal(err)
430 }
431 select {
432 case <-started:
433 case <-time.After(time.Second):
434 t.Fatal("subagent did not start")
435 }
436 if tc.stop {
437 if err := manager.Stop(agent.ID); err != nil {
438 t.Fatal(err)
439 }
440 } else {
441 agent.Wait()
442 }
443 agent.Wait()
444
445 tool := &SubagentStatusTool{
446 Supervisor: manager,
447 Enabled: func() bool { return true },
448 }
449 res, err := tool.Execute(context.Background(), json.RawMessage(`{"agent_id":"`+agent.ID+`"}`), nil)
450 if err != nil {
451 t.Fatal(err)
452 }
453 var got struct {
454 Agent struct {
455 ID string `json:"agent_id"`
456 Terminal string `json:"terminal"`
457 } `json:"agent"`
458 }
459 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
460 t.Fatal(err)
461 }
462 if got.Agent.ID != agent.ID {
463 t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID)
464 }
465 if got.Agent.Terminal != tc.wantTerminal {
466 t.Fatalf("terminal = %q, want %q", got.Agent.Terminal, tc.wantTerminal)
467 }
468 if tc.stop && got.Agent.Terminal == "completed" {
469 t.Fatalf("cancelled worker reported completed terminal")
470 }
471 })
472 }
473}