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}