v0.16.0 042538b

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	}, 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}

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