v0.44.0 efd47b3

subagent_test.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"reflect"
7	"strings"
8	"testing"
9
10	"github.com/bnema/zut/packages/agent/subagents"
11	"github.com/bnema/zut/packages/core"
12	"github.com/bnema/zut/packages/provider"
13)
14
15func facadeResultText(t *testing.T, result core.ToolResult) string {
16	t.Helper()
17	var sb strings.Builder
18	for _, block := range result.Content {
19		text, ok := block.(provider.TextBlock)
20		if !ok {
21			t.Fatalf("content block = %T, want provider.TextBlock", block)
22		}
23		sb.WriteString(text.Text)
24	}
25	return sb.String()
26}
27
28func newFacadeManager(t *testing.T) *subagents.ResidentManager {
29	t.Helper()
30	manager := subagents.NewResidentManager(t.TempDir(), nil)
31	t.Cleanup(func() { _ = manager.Close(context.Background()) })
32	return manager
33}
34
35// allActionsFacade wires one enabled internal tool per action so dispatch
36// targets can be observed independently.
37func allActionsFacade(t *testing.T) *SubagentTool {
38	t.Helper()
39	manager := newFacadeManager(t)
40	enabled := func() bool { return true }
41	return &SubagentTool{
42		Spawn:  &SubagentSpawnTool{ResidentManager: manager, Enabled: enabled},
43		Status: &SubagentStatusTool{ResidentManager: manager, Enabled: enabled},
44		Stop:   &SubagentStopTool{ResidentManager: manager, Enabled: enabled},
45		Resume: &SubagentResumeTool{ResidentManager: manager, Enabled: enabled},
46	}
47}
48
49func TestSubagentFacadeNameAndSchema(t *testing.T) {
50	facade := &SubagentTool{}
51	if got := facade.Name(); got != "subagent" {
52		t.Fatalf("Name() = %q, want subagent", got)
53	}
54	var schema struct {
55		Required   []string                   `json:"required"`
56		Properties map[string]json.RawMessage `json:"properties"`
57	}
58	if err := json.Unmarshal(facade.Schema(), &schema); err != nil {
59		t.Fatalf("schema is not valid JSON: %v", err)
60	}
61	if len(schema.Required) != 1 || schema.Required[0] != "action" {
62		t.Fatalf("required = %v, want [action]", schema.Required)
63	}
64	for _, field := range []string{
65		"action", "task", "agent", "model", "provider", "reasoning", "fast_mode",
66		"required", "wait", "isolation", "agent_id", "include_result", "watch", "prompt",
67	} {
68		if _, ok := schema.Properties[field]; !ok {
69			t.Fatalf("schema is missing property %q", field)
70		}
71	}
72	if got := facade.Description(); got != "Spawn, inspect, resume, and stop resident sub-agents. One action per call.\n\n"+subagentSpawnGuidance {
73		t.Fatalf("description = %q", got)
74	}
75}
76
77// facadeSchemaProperties parses the model-facing facade schema into its
78// property descriptions.
79func facadeSchemaProperties(t *testing.T) map[string]string {
80	t.Helper()
81	var schema struct {
82		Properties map[string]struct {
83			Description string `json:"description"`
84		} `json:"properties"`
85	}
86	if err := json.Unmarshal((&SubagentTool{}).Schema(), &schema); err != nil {
87		t.Fatalf("schema is not valid JSON: %v", err)
88	}
89	props := make(map[string]string, len(schema.Properties))
90	for name, prop := range schema.Properties {
91		props[name] = prop.Description
92	}
93	return props
94}
95
96// TestSubagentFacadeSchemaCoversActionFields ties the flat, merged schema to
97// the runtime action/field map, so a field added to an action cannot silently
98// disappear from what the model sees.
99func TestSubagentFacadeSchemaCoversActionFields(t *testing.T) {
100	props := facadeSchemaProperties(t)
101	for action, fields := range subagentActionFields {
102		for _, field := range fields {
103			if _, ok := props[field]; !ok {
104				t.Fatalf("action %s field %q is missing from the facade schema", action, field)
105			}
106		}
107	}
108}
109
110// subagentActionAnnotation pins the action-scoping sentence each
111// action-owned property must carry. Every field in subagentActionFields must
112// appear here, and vice versa, so the merged schema cannot advertise an
113// action-owned field as unconditionally valid.
114var subagentActionAnnotation = map[string]string{
115	"task":           "Required when action is spawn.",
116	"agent":          "Only valid when action is spawn.",
117	"model":          "Only valid when action is spawn.",
118	"provider":       "Only valid when action is spawn.",
119	"reasoning":      "Only valid when action is spawn.",
120	"fast_mode":      "Only valid when action is spawn.",
121	"required":       "Only valid when action is spawn.",
122	"wait":           "Only valid when action is spawn or resume.",
123	"isolation":      "Only valid when action is spawn.",
124	"agent_id":       "Required when action is stop or resume.",
125	"include_result": "Only valid when action is status.",
126	"watch":          "Only valid when action is status.",
127	"prompt":         "Required when action is resume.",
128}
129
130func TestSubagentFacadeSchemaAnnotatesActionFields(t *testing.T) {
131	props := facadeSchemaProperties(t)
132	for action, fields := range subagentActionFields {
133		for _, field := range fields {
134			annotation, pinned := subagentActionAnnotation[field]
135			if !pinned {
136				t.Fatalf("action %s owns %q with no pinned annotation", action, field)
137			}
138			desc, ok := props[field]
139			if !ok {
140				t.Fatalf("schema is missing property %q owned by action %s", field, action)
141			}
142			if !strings.Contains(desc, annotation) {
143				t.Fatalf("property %q description %q is missing %q", field, desc, annotation)
144			}
145		}
146	}
147	inFields := map[string]bool{}
148	for _, fields := range subagentActionFields {
149		for _, field := range fields {
150			inFields[field] = true
151		}
152	}
153	for field := range subagentActionAnnotation {
154		if !inFields[field] {
155			t.Fatalf("annotation table names %q, which no action owns", field)
156		}
157	}
158}
159
160// TestSubagentFacadeResumeCaveat keeps the former resume-tool warning in the
161// facade schema: a terminal failure must be inspected before resume, and a
162// follow-up continues the retained session rather than satisfying the work.
163func TestSubagentFacadeResumeCaveat(t *testing.T) {
164	props := facadeSchemaProperties(t)
165	prompt := props["prompt"]
166	for _, want := range []string{"inspect its saved result first", "without discarding progress or satisfying required work"} {
167		if !strings.Contains(prompt, want) {
168			t.Fatalf("prompt description %q is missing %q", prompt, want)
169		}
170	}
171}
172
173// TestSubagentFacadeConfirmationKey covers the per-action confirmation grant:
174// each action yields its own key, and anything malformed falls back to the
175// plain base name so a remembered grant cannot widen.
176func TestSubagentFacadeConfirmationKey(t *testing.T) {
177	facade := &SubagentTool{}
178	for _, tc := range []struct {
179		raw  string
180		want string
181	}{
182		{raw: `{"action":"spawn","task":"t"}`, want: "subagent:spawn"},
183		{raw: `{"action":"status"}`, want: "subagent:status"},
184		{raw: `{"action":"stop","agent_id":"x"}`, want: "subagent:stop"},
185		{raw: `{"action":"resume","agent_id":"x","prompt":"p"}`, want: "subagent:resume"},
186		{raw: `{}`, want: "subagent"},
187		{raw: `{"action":""}`, want: "subagent"},
188		{raw: `{"action":"  "}`, want: "subagent"},
189		{raw: `{"action":42}`, want: "subagent"},
190		{raw: `{"action":["spawn"]}`, want: "subagent"},
191		{raw: `{"action":{}}`, want: "subagent"},
192		{raw: `{"action":true}`, want: "subagent"},
193		{raw: `{not json`, want: "subagent"},
194	} {
195		if got := facade.ConfirmationKey(json.RawMessage(tc.raw)); got != tc.want {
196			t.Fatalf("ConfirmationKey(%s) = %q, want %q", tc.raw, got, tc.want)
197		}
198	}
199	seen := map[string]bool{}
200	for _, action := range []string{"spawn", "status", "stop", "resume"} {
201		key := facade.ConfirmationKey(json.RawMessage(`{"action":"` + action + `"}`))
202		if seen[key] {
203			t.Fatalf("action %s reuses key %q", action, key)
204		}
205		seen[key] = true
206	}
207}
208
209func TestSubagentFacadeRequiresAction(t *testing.T) {
210	facade := allActionsFacade(t)
211	for _, raw := range []string{`{}`, `{"action":""}`, `{"action":"  "}`} {
212		result, err := facade.Execute(context.Background(), json.RawMessage(raw), nil)
213		if err != nil {
214			t.Fatalf("%s: unexpected host error: %v", raw, err)
215		}
216		if !result.IsError || facadeResultText(t, result) != "subagent: action is required" {
217			t.Fatalf("%s: result = %#v", raw, result)
218		}
219	}
220	for _, raw := range []string{`{"action":42}`, `{"action":["spawn"]}`, `{"action":{}}`, `{"action":true}`} {
221		result, err := facade.Execute(context.Background(), json.RawMessage(raw), nil)
222		if err != nil {
223			t.Fatalf("%s: unexpected host error: %v", raw, err)
224		}
225		if !result.IsError || facadeResultText(t, result) != "subagent: action must be a string" {
226			t.Fatalf("%s: result = %#v, want action-must-be-a-string", raw, result)
227		}
228	}
229}
230
231func TestSubagentFacadeRejectsUnknownActionAndMalformedArgs(t *testing.T) {
232	facade := allActionsFacade(t)
233	result, err := facade.Execute(context.Background(), json.RawMessage(`{"action":"explode"}`), nil)
234	if err != nil {
235		t.Fatalf("unexpected host error: %v", err)
236	}
237	if !result.IsError || facadeResultText(t, result) != "subagent: unknown action" {
238		t.Fatalf("unknown action result = %#v", result)
239	}
240	if _, err := facade.Execute(context.Background(), json.RawMessage(`{"action":`), nil); err == nil || !strings.Contains(err.Error(), "invalid args") {
241		t.Fatalf("malformed args error = %v", err)
242	}
243}
244
245func TestSubagentFacadeReportsUnavailableActions(t *testing.T) {
246	facade := &SubagentTool{}
247	for action, want := range map[string]string{
248		"spawn":  "subagent: spawn action is unavailable in this mode",
249		"status": "subagent: status action is unavailable in this mode",
250		"stop":   "subagent: stop action is unavailable in this mode",
251		"resume": "subagent: resume action is unavailable in this mode",
252	} {
253		raw := json.RawMessage(`{"action":"` + action + `"}`)
254		result, err := facade.Execute(context.Background(), raw, nil)
255		if err != nil {
256			t.Fatalf("%s: unexpected host error: %v", action, err)
257		}
258		if !result.IsError || facadeResultText(t, result) != want {
259			t.Fatalf("%s: result = %#v, want %q", action, result, want)
260		}
261	}
262}
263
264// Each action must reach only its own internal tool.
265func TestSubagentFacadeDispatchesToOwningAction(t *testing.T) {
266	facade := allActionsFacade(t)
267	for action, raw := range map[string]string{
268		"status": `{"action":"status","agent_id":"nope"}`,
269		"stop":   `{"action":"stop","agent_id":"nope"}`,
270		"resume": `{"action":"resume","agent_id":"nope","prompt":"follow up"}`,
271	} {
272		result, err := facade.Execute(context.Background(), json.RawMessage(raw), nil)
273		if err != nil {
274			t.Fatalf("%s: unexpected host error: %v", action, err)
275		}
276		if !result.IsError || !strings.Contains(facadeResultText(t, result), `no such agent "nope"`) {
277			t.Fatalf("%s: result = %#v", action, result)
278		}
279	}
280	result, err := facade.Execute(context.Background(), json.RawMessage(`{"action":"resume","agent_id":"nope"}`), nil)
281	if err != nil {
282		t.Fatalf("unexpected host error: %v", err)
283	}
284	if !result.IsError || !strings.Contains(facadeResultText(t, result), "prompt is required") {
285		t.Fatalf("resume without prompt = %#v", result)
286	}
287}
288
289// A field owned by another action is rejected before dispatch; a field no
290// action owns still reaches the sub-tool and is rejected by its strict decoder.
291func TestSubagentFacadeRejectsForeignFieldsAndKeepsUnknownOnes(t *testing.T) {
292	facade := allActionsFacade(t)
293	result, err := facade.Execute(context.Background(), json.RawMessage(`{"action":"stop","agent_id":"nope","wait":5}`), nil)
294	if err != nil {
295		t.Fatalf("unexpected host error: %v", err)
296	}
297	if !result.IsError || facadeResultText(t, result) != "subagent: wait is not valid for action stop; it belongs to the resume, spawn actions" {
298		t.Fatalf("foreign field result = %#v", result)
299	}
300	result, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"status","wait":5}`), nil)
301	if err != nil {
302		t.Fatalf("unexpected host error: %v", err)
303	}
304	if !result.IsError || facadeResultText(t, result) != "subagent: wait is not valid for action status; it belongs to the resume, spawn actions" {
305		t.Fatalf("status foreign field result = %#v", result)
306	}
307	result, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"spawn","task":"t","agent_id":"child"}`), nil)
308	if err != nil {
309		t.Fatalf("unexpected host error: %v", err)
310	}
311	if !result.IsError || facadeResultText(t, result) != "subagent: agent_id is not valid for action spawn; it belongs to the resume, status, stop actions" {
312		t.Fatalf("multi-owner foreign field result = %#v", result)
313	}
314	_, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"stop","agent_id":"nope","bogus":1}`), nil)
315	if err == nil || !strings.Contains(err.Error(), "unknown field") {
316		t.Fatalf("unknown field error = %v", err)
317	}
318}
319
320// The parser forwards the selected action's raw field bytes unchanged, so the
321// sub-tools keep their own number and validation semantics.
322func TestSubagentFacadeForwardsActionPayload(t *testing.T) {
323	cases := []struct {
324		raw  string
325		want string
326	}{
327		{raw: `{"action":"spawn","task":"review","wait":300}`, want: `{"task":"review","wait":300}`},
328		{raw: `{"action":"status","agent_id":"child","include_result":true}`, want: `{"agent_id":"child","include_result":true}`},
329		{raw: `{"action":"resume","agent_id":"child","prompt":"confirm"}`, want: `{"agent_id":"child","prompt":"confirm"}`},
330		{raw: `{"action":"resume","agent_id":"child","prompt":"confirm","wait":30}`, want: `{"agent_id":"child","prompt":"confirm","wait":30}`},
331		{raw: `{"action":"stop","agent_id":"child"}`, want: `{"agent_id":"child"}`},
332	}
333	for _, tc := range cases {
334		action, payload, err := parseSubagentAction(json.RawMessage(tc.raw))
335		if err != nil {
336			t.Fatalf("%s: parse error %v", tc.raw, err)
337		}
338		var got, want map[string]json.RawMessage
339		if err := json.Unmarshal(payload, &got); err != nil {
340			t.Fatalf("%s: payload %s is not an object: %v", tc.raw, payload, err)
341		}
342		if err := json.Unmarshal([]byte(tc.want), &want); err != nil {
343			t.Fatal(err)
344		}
345		if !reflect.DeepEqual(got, want) {
346			t.Fatalf("%s: payload = %s, want %s", tc.raw, payload, tc.want)
347		}
348		if action == "" {
349			t.Fatalf("%s: empty action", tc.raw)
350		}
351	}
352}

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