v0.51.0 a9d674e

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", "mode",
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, interrupt, 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, resume, or interrupt.",
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	"mode":           "Only valid when action is resume.",
129}
130
131func TestSubagentFacadeSchemaAnnotatesActionFields(t *testing.T) {
132	props := facadeSchemaProperties(t)
133	for action, fields := range subagentActionFields {
134		for _, field := range fields {
135			annotation, pinned := subagentActionAnnotation[field]
136			if !pinned {
137				t.Fatalf("action %s owns %q with no pinned annotation", action, field)
138			}
139			desc, ok := props[field]
140			if !ok {
141				t.Fatalf("schema is missing property %q owned by action %s", field, action)
142			}
143			if !strings.Contains(desc, annotation) {
144				t.Fatalf("property %q description %q is missing %q", field, desc, annotation)
145			}
146		}
147	}
148	inFields := map[string]bool{}
149	for _, fields := range subagentActionFields {
150		for _, field := range fields {
151			inFields[field] = true
152		}
153	}
154	for field := range subagentActionAnnotation {
155		if !inFields[field] {
156			t.Fatalf("annotation table names %q, which no action owns", field)
157		}
158	}
159}
160
161// TestSubagentFacadeResumeCaveat keeps the former resume-tool warning in the
162// facade schema: a terminal failure must be inspected before resume, and a
163// follow-up continues the retained session rather than satisfying the work.
164func TestSubagentFacadeResumeCaveat(t *testing.T) {
165	props := facadeSchemaProperties(t)
166	prompt := props["prompt"]
167	for _, want := range []string{"inspect its saved result first", "without discarding progress or satisfying required work"} {
168		if !strings.Contains(prompt, want) {
169			t.Fatalf("prompt description %q is missing %q", prompt, want)
170		}
171	}
172}
173
174// TestSubagentFacadeConfirmationKey covers the per-action confirmation grant:
175// each action yields its own key, and anything malformed falls back to the
176// plain base name so a remembered grant cannot widen.
177func TestSubagentFacadeConfirmationKey(t *testing.T) {
178	facade := &SubagentTool{}
179	for _, tc := range []struct {
180		raw  string
181		want string
182	}{
183		{raw: `{"action":"spawn","task":"t"}`, want: "subagent:spawn"},
184		{raw: `{"action":"status"}`, want: "subagent:status"},
185		{raw: `{"action":"stop","agent_id":"x"}`, want: "subagent:stop"},
186		{raw: `{"action":"resume","agent_id":"x","prompt":"p"}`, want: "subagent:resume"},
187		{raw: `{}`, want: "subagent"},
188		{raw: `{"action":""}`, want: "subagent"},
189		{raw: `{"action":"  "}`, want: "subagent"},
190		{raw: `{"action":42}`, want: "subagent"},
191		{raw: `{"action":["spawn"]}`, want: "subagent"},
192		{raw: `{"action":{}}`, want: "subagent"},
193		{raw: `{"action":true}`, want: "subagent"},
194		{raw: `{not json`, want: "subagent"},
195	} {
196		if got := facade.ConfirmationKey(json.RawMessage(tc.raw)); got != tc.want {
197			t.Fatalf("ConfirmationKey(%s) = %q, want %q", tc.raw, got, tc.want)
198		}
199	}
200	seen := map[string]bool{}
201	for _, action := range []string{"spawn", "status", "stop", "resume"} {
202		key := facade.ConfirmationKey(json.RawMessage(`{"action":"` + action + `"}`))
203		if seen[key] {
204			t.Fatalf("action %s reuses key %q", action, key)
205		}
206		seen[key] = true
207	}
208}
209
210func TestSubagentFacadeRequiresAction(t *testing.T) {
211	facade := allActionsFacade(t)
212	for _, raw := range []string{`{}`, `{"action":""}`, `{"action":"  "}`} {
213		result, err := facade.Execute(context.Background(), json.RawMessage(raw), nil)
214		if err != nil {
215			t.Fatalf("%s: unexpected host error: %v", raw, err)
216		}
217		if !result.IsError || facadeResultText(t, result) != "subagent: action is required" {
218			t.Fatalf("%s: result = %#v", raw, result)
219		}
220	}
221	for _, raw := range []string{`{"action":42}`, `{"action":["spawn"]}`, `{"action":{}}`, `{"action":true}`} {
222		result, err := facade.Execute(context.Background(), json.RawMessage(raw), nil)
223		if err != nil {
224			t.Fatalf("%s: unexpected host error: %v", raw, err)
225		}
226		if !result.IsError || facadeResultText(t, result) != "subagent: action must be a string" {
227			t.Fatalf("%s: result = %#v, want action-must-be-a-string", raw, result)
228		}
229	}
230}
231
232func TestSubagentFacadeRejectsUnknownActionAndMalformedArgs(t *testing.T) {
233	facade := allActionsFacade(t)
234	result, err := facade.Execute(context.Background(), json.RawMessage(`{"action":"explode"}`), nil)
235	if err != nil {
236		t.Fatalf("unexpected host error: %v", err)
237	}
238	if !result.IsError || facadeResultText(t, result) != "subagent: unknown action" {
239		t.Fatalf("unknown action result = %#v", result)
240	}
241	if _, err := facade.Execute(context.Background(), json.RawMessage(`{"action":`), nil); err == nil || !strings.Contains(err.Error(), "invalid args") {
242		t.Fatalf("malformed args error = %v", err)
243	}
244}
245
246func TestSubagentFacadeReportsUnavailableActions(t *testing.T) {
247	facade := &SubagentTool{}
248	for action, want := range map[string]string{
249		"spawn":  "subagent: spawn action is unavailable in this mode",
250		"status": "subagent: status action is unavailable in this mode",
251		"stop":   "subagent: stop action is unavailable in this mode",
252		"resume": "subagent: resume action is unavailable in this mode",
253	} {
254		raw := json.RawMessage(`{"action":"` + action + `"}`)
255		result, err := facade.Execute(context.Background(), raw, nil)
256		if err != nil {
257			t.Fatalf("%s: unexpected host error: %v", action, err)
258		}
259		if !result.IsError || facadeResultText(t, result) != want {
260			t.Fatalf("%s: result = %#v, want %q", action, result, want)
261		}
262	}
263}
264
265// Each action must reach only its own internal tool.
266func TestSubagentFacadeDispatchesToOwningAction(t *testing.T) {
267	facade := allActionsFacade(t)
268	for action, raw := range map[string]string{
269		"status": `{"action":"status","agent_id":"nope"}`,
270		"stop":   `{"action":"stop","agent_id":"nope"}`,
271		"resume": `{"action":"resume","agent_id":"nope","prompt":"follow up"}`,
272	} {
273		result, err := facade.Execute(context.Background(), json.RawMessage(raw), nil)
274		if err != nil {
275			t.Fatalf("%s: unexpected host error: %v", action, err)
276		}
277		if !result.IsError || !strings.Contains(facadeResultText(t, result), `no such agent "nope"`) {
278			t.Fatalf("%s: result = %#v", action, result)
279		}
280	}
281	result, err := facade.Execute(context.Background(), json.RawMessage(`{"action":"resume","agent_id":"nope"}`), nil)
282	if err != nil {
283		t.Fatalf("unexpected host error: %v", err)
284	}
285	if !result.IsError || !strings.Contains(facadeResultText(t, result), "prompt is required") {
286		t.Fatalf("resume without prompt = %#v", result)
287	}
288}
289
290// A field owned by another action is rejected before dispatch; a field no
291// action owns still reaches the sub-tool and is rejected by its strict decoder.
292func TestSubagentFacadeRejectsForeignFieldsAndKeepsUnknownOnes(t *testing.T) {
293	facade := allActionsFacade(t)
294	result, err := facade.Execute(context.Background(), json.RawMessage(`{"action":"stop","agent_id":"nope","wait":5}`), nil)
295	if err != nil {
296		t.Fatalf("unexpected host error: %v", err)
297	}
298	if !result.IsError || facadeResultText(t, result) != "subagent: wait is not valid for action stop; it belongs to the resume, spawn actions" {
299		t.Fatalf("foreign field result = %#v", result)
300	}
301	result, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"status","wait":5}`), nil)
302	if err != nil {
303		t.Fatalf("unexpected host error: %v", err)
304	}
305	if !result.IsError || facadeResultText(t, result) != "subagent: wait is not valid for action status; it belongs to the resume, spawn actions" {
306		t.Fatalf("status foreign field result = %#v", result)
307	}
308	result, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"spawn","task":"t","agent_id":"child"}`), nil)
309	if err != nil {
310		t.Fatalf("unexpected host error: %v", err)
311	}
312	if !result.IsError || facadeResultText(t, result) != "subagent: agent_id is not valid for action spawn; it belongs to the interrupt, resume, status, stop actions" {
313		t.Fatalf("multi-owner foreign field result = %#v", result)
314	}
315	_, err = facade.Execute(context.Background(), json.RawMessage(`{"action":"stop","agent_id":"nope","bogus":1}`), nil)
316	if err == nil || !strings.Contains(err.Error(), "unknown field") {
317		t.Fatalf("unknown field error = %v", err)
318	}
319}
320
321// The parser forwards the selected action's raw field bytes unchanged, so the
322// sub-tools keep their own number and validation semantics.
323func TestSubagentFacadeForwardsActionPayload(t *testing.T) {
324	cases := []struct {
325		raw  string
326		want string
327	}{
328		{raw: `{"action":"spawn","task":"review","wait":300}`, want: `{"task":"review","wait":300}`},
329		{raw: `{"action":"status","agent_id":"child","include_result":true}`, want: `{"agent_id":"child","include_result":true}`},
330		{raw: `{"action":"resume","agent_id":"child","prompt":"confirm"}`, want: `{"agent_id":"child","prompt":"confirm"}`},
331		{raw: `{"action":"resume","agent_id":"child","prompt":"confirm","wait":30}`, want: `{"agent_id":"child","prompt":"confirm","wait":30}`},
332		{raw: `{"action":"stop","agent_id":"child"}`, want: `{"agent_id":"child"}`},
333	}
334	for _, tc := range cases {
335		action, payload, err := parseSubagentAction(json.RawMessage(tc.raw))
336		if err != nil {
337			t.Fatalf("%s: parse error %v", tc.raw, err)
338		}
339		var got, want map[string]json.RawMessage
340		if err := json.Unmarshal(payload, &got); err != nil {
341			t.Fatalf("%s: payload %s is not an object: %v", tc.raw, payload, err)
342		}
343		if err := json.Unmarshal([]byte(tc.want), &want); err != nil {
344			t.Fatal(err)
345		}
346		if !reflect.DeepEqual(got, want) {
347			t.Fatalf("%s: payload = %s, want %s", tc.raw, payload, tc.want)
348		}
349		if action == "" {
350			t.Fatalf("%s: empty action", tc.raw)
351		}
352	}
353}

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 476

↑↓ moveenter openesc close