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}