1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "strings"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/bnema/zut/packages/agent/subagents"
12 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"
14)
15
16func TestResidentToolsUseManagerOnly(t *testing.T) {
17 runs := make(chan string, 2)
18 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
19 return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil
20 })
21 t.Cleanup(func() { _ = manager.Close(context.Background()) })
22 spawn := &SubagentSpawnTool{
23 ResidentManager: manager,
24 Enabled: func() bool { return true },
25 DefaultProvider: func() string { return "openai" },
26 DefaultModel: func() string { return "gpt-5" },
27 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
28 return subagents.ResidentChildSpec{ID: "resident-tool", SessionID: "child-session", Provider: request.Provider, Model: request.Model, Required: request.Required}, nil
29 },
30 }
31 result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"initial"}`), nil)
32 if err != nil {
33 t.Fatal(err)
34 }
35 if len(result.Content) == 0 {
36 t.Fatal("spawn returned no result")
37 }
38 if got := <-runs; got != "initial" {
39 t.Fatalf("initial prompt = %q", got)
40 }
41 status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }}
42 result, err = status.Execute(context.Background(), json.RawMessage(`{"agent_id":"resident-tool"}`), nil)
43 if err != nil {
44 t.Fatal(err)
45 }
46 if details, ok := result.Details.(subagentStatusResponse); !ok || details.Agent == nil || details.Agent.State == "" {
47 t.Fatalf("status details = %#v", result.Details)
48 }
49 resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
50 if _, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"resident-tool","prompt":"follow up"}`), nil); err != nil {
51 t.Fatal(err)
52 }
53 if got := <-runs; got != "follow up" {
54 t.Fatalf("follow-up prompt = %q", got)
55 }
56}
57
58func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) {
59 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
60 return func(context.Context, string) error { return nil }, nil
61 })
62 t.Cleanup(func() { _ = manager.Close(context.Background()) })
63 spawn := &SubagentSpawnTool{
64 ResidentManager: manager,
65 Enabled: func() bool { return true },
66 DefaultProvider: func() string { return "openai" },
67 DefaultModel: func() string { return "gpt-5" },
68 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
69 return subagents.ResidentChildSpec{ID: "wait-for-completion", SessionID: "child-session", Provider: request.Provider, Model: request.Model}, nil
70 },
71 }
72 result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"finish now","wait":1}`), nil)
73 if err != nil || result.IsError {
74 t.Fatalf("Execute = (%#v, %v)", result, err)
75 }
76 if got := toolResultText(t, result); !strings.Contains(got, "state: completed") {
77 t.Fatalf("wait result = %q, want completed state", got)
78 }
79}
80
81func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) {
82 started := make(chan struct{}, 1)
83 release := make(chan struct{})
84 var releaseOnce sync.Once
85 releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
86 finished := make(chan string, 1)
87 manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
88 return func(ctx context.Context, prompt string) error {
89 switch prompt {
90 case "blocker":
91 started <- struct{}{}
92 select {
93 case <-release:
94 return nil
95 case <-ctx.Done():
96 return ctx.Err()
97 }
98 case "waiter":
99 finished <- prompt
100 }
101 return nil
102 }, nil
103 })
104 t.Cleanup(func() {
105 releaseBlocker()
106 _ = manager.Close(context.Background())
107 })
108 spawn := &SubagentSpawnTool{
109 ResidentManager: manager,
110 Enabled: func() bool { return true },
111 DefaultProvider: func() string { return "openai" },
112 DefaultModel: func() string { return "gpt-5" },
113 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
114 return subagents.ResidentChildSpec{ID: request.Task, SessionID: request.Task, Provider: request.Provider, Model: request.Model}, nil
115 },
116 }
117 if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"blocker"}`), nil); err != nil {
118 t.Fatalf("spawn blocker: %v", err)
119 }
120 select {
121 case <-started:
122 case <-time.After(5 * time.Second):
123 t.Fatal("blocker did not start")
124 }
125 result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"waiter","wait":1}`), nil)
126 if err != nil || result.IsError {
127 t.Fatalf("spawn waiter = (%#v, %v)", result, err)
128 }
129 if got := toolResultText(t, result); !strings.Contains(got, "state: queued") || !strings.Contains(got, "wait: timed out after 1 seconds") {
130 t.Fatalf("wait expiry result = %q, want queued timeout", got)
131 }
132 if state, ok := manager.State("waiter"); !ok || state != subagents.ResidentQueued {
133 t.Fatalf("waiter state = %q (found=%t), want queued", state, ok)
134 }
135 releaseBlocker()
136 select {
137 case got := <-finished:
138 if got != "waiter" {
139 t.Fatalf("finished task = %q, want waiter", got)
140 }
141 case <-time.After(5 * time.Second):
142 t.Fatal("queued waiter did not execute")
143 }
144}
145
146func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
147 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
148 return func(context.Context, string) error { return nil }, nil
149 })
150 t.Cleanup(func() { _ = manager.Close(context.Background()) })
151 built := 0
152 spawn := &SubagentSpawnTool{
153 ResidentManager: manager,
154 Enabled: func() bool { return true },
155 BuildResidentSpec: func(context.Context, ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
156 built++
157 return subagents.ResidentChildSpec{ID: "should-not-exist", SessionID: "child"}, nil
158 },
159 }
160 for _, raw := range []string{
161 `{"task":"x","unexpected":true}`,
162 `{"task":"x"}{}`,
163 `{"task":"x","model":"gpt-5"}`,
164 `{"task":"x","wait":0}`,
165 `{"task":"x","wait":301}`,
166 `{"task":"x","isolation":"outside"}`,
167 } {
168 result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)
169 if err != nil && !strings.Contains(err.Error(), "invalid args") {
170 t.Fatalf("Execute(%s) error = %v", raw, err)
171 }
172 if err == nil && !result.IsError {
173 t.Fatalf("Execute(%s) = %#v, want protocol error", raw, result)
174 }
175 }
176 if built != 0 || len(manager.Snapshot()) != 0 {
177 t.Fatalf("invalid input reached resident manager: built=%d snapshots=%#v", built, manager.Snapshot())
178 }
179}
180
181func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
182 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
183 return func(context.Context, string) error { return nil }, nil
184 })
185 t.Cleanup(func() { _ = manager.Close(context.Background()) })
186 profileFast := false
187 var got ResidentSpawnRequest
188 spawn := &SubagentSpawnTool{
189 ResidentManager: manager,
190 Enabled: func() bool { return true },
191 DefaultModel: func() string { return "host-model" },
192 DefaultProvider: func() string { return "host-provider" },
193 DefaultReasoning: func() string { return "medium" },
194 ResolveSubagent: func(name string) (*subagents.Profile, error) {
195 return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast}, nil
196 },
197 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
198 got = request
199 return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil
200 },
201 }
202 result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true}`), nil)
203 if err != nil || result.IsError {
204 t.Fatalf("Execute = (%#v, %v)", result, err)
205 }
206 if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode {
207 t.Fatalf("factory request = %#v", got)
208 }
209}
210
211func TestResidentToolsRejectUnknownFields(t *testing.T) {
212 for _, tool := range []interface {
213 Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error)
214 }{
215 &SubagentStatusTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
216 &SubagentStopTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
217 &SubagentResumeTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
218 } {
219 _, err := tool.Execute(context.Background(), json.RawMessage(`{"unknown":true}`), nil)
220 if err == nil || !strings.Contains(err.Error(), "unknown field") {
221 t.Fatalf("unknown field error = %v", err)
222 }
223 }
224}
225
226func toolResultText(t *testing.T, result core.ToolResult) string {
227 t.Helper()
228 if len(result.Content) != 1 {
229 t.Fatalf("content = %#v, want one text block", result.Content)
230 }
231 block, ok := result.Content[0].(provider.TextBlock)
232 if !ok {
233 t.Fatalf("content = %#v, want text block", result.Content)
234 }
235 return block.Text
236}
237
238func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) {
239 _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab")
240 if ok {
241 t.Fatal("ambiguous resident prefix resolved")
242 }
243}