1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/bnema/zut/packages/agent/subagents"
14 "github.com/bnema/zut/packages/core"
15 "github.com/bnema/zut/packages/provider"
16)
17
18// A waited turn still reaches the host, but its report must not also be
19// repeated in the tool result. Without a host, the tool remains self-contained.
20func TestResidentWaitResultDelivery(t *testing.T) {
21 for _, action := range []string{"spawn", "resume"} {
22 for _, route := range []string{"inline", "accepted", "rejected"} {
23 host := route == "accepted"
24 for _, turnErr := range []error{nil, errors.New("synthetic failure"), context.Canceled} {
25 t.Run(fmt.Sprintf("%s/host=%s/%s", action, route, completionStatus(turnErr)), func(t *testing.T) {
26 const summary = "synthetic worker report"
27 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
28 return subagents.ResidentTurnRunner(func(_ context.Context, prompt string) error {
29 if prompt == "initial" {
30 return nil
31 }
32 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: summary}}}}); err != nil {
33 return err
34 }
35 return turnErr
36 }), nil
37 })
38 t.Cleanup(func() { _ = manager.Close(context.Background()) })
39 facade := newDeliveryFacade(t, manager, action)
40 updates := make(chan subagents.ResidentCompletion, 2)
41 if route != "inline" {
42 manager.SetCompletionObserver(func(c subagents.ResidentCompletion) bool {
43 if host {
44 updates <- c
45 }
46 return host
47 })
48 }
49
50 args := json.RawMessage(`{"action":"spawn","task":"report","wait":5}`)
51 if action == "resume" {
52 args = json.RawMessage(`{"action":"resume","agent_id":"report-child","prompt":"report","mode":"queue","wait":5}`)
53 }
54 result, err := facade.Execute(t.Context(), args, nil)
55 if err != nil || result.IsError {
56 t.Fatalf("Execute = (%#v, %v)", result, err)
57 }
58 text := toolResultText(t, result)
59 if strings.Contains(text, summary) == host {
60 t.Fatalf("host=%t, tool report = %q", host, text)
61 }
62 if !strings.Contains(text, completionStatus(turnErr)) {
63 t.Fatalf("missing terminal status in %q", text)
64 }
65 if host {
66 if !strings.Contains(text, "host_update") {
67 t.Fatalf("missing delivery indication in %q", text)
68 }
69 select {
70 case c := <-updates:
71 if c.Summary != summary || !errors.Is(c.Err, turnErr) {
72 t.Fatalf("host report = %#v", c)
73 }
74 case <-time.After(5 * time.Second):
75 t.Fatal("host lost the report")
76 }
77 select {
78 case c := <-updates:
79 t.Fatalf("duplicate host report: %#v", c)
80 default:
81 }
82 }
83 })
84 }
85 }
86 }
87}
88
89// Both action tests share the same durable child setup. Resume needs an idle
90// initial turn before the tested follow-up; observers are installed afterwards.
91func newDeliveryFacade(t *testing.T, manager *subagents.ResidentManager, action string) *SubagentTool {
92 t.Helper()
93 spec := subagents.ResidentChildSpec{ID: "report-child", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
94 if action == "resume" {
95 done, cancel := manager.WatchCompletion(spec.ID, spec.InitialTurnID)
96 defer cancel()
97 if _, err := manager.Spawn(t.Context(), spec, "initial"); err != nil {
98 t.Fatal(err)
99 }
100 select {
101 case <-done:
102 case <-time.After(5 * time.Second):
103 t.Fatal("initial turn did not finish")
104 }
105 }
106 return &SubagentTool{
107 Spawn: &SubagentSpawnTool{
108 ResidentManager: manager, Enabled: func() bool { return true },
109 BuildResidentSpec: func(context.Context, ResidentSpawnRequest) (subagents.ResidentChildSpec, error) { return spec, nil },
110 },
111 Resume: &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }},
112 }
113}
114
115func completionStatus(err error) string {
116 if errors.Is(err, context.Canceled) {
117 return "interrupted"
118 }
119 if err != nil {
120 return "failed"
121 }
122 return "completed"
123}
124
125// Abandoning a bounded wait never removes the host's completion route.
126func TestResidentAbandonedWaitPreservesHostReport(t *testing.T) {
127 for _, action := range []string{"spawn", "resume"} {
128 for _, cancelWait := range []bool{false, true} {
129 t.Run(fmt.Sprintf("%s/cancel=%t", action, cancelWait), func(t *testing.T) {
130 started := make(chan struct{}, 1)
131 release := make(chan struct{})
132 var once sync.Once
133 unblock := func() { once.Do(func() { close(release) }) }
134 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
135 return subagents.ResidentTurnRunner(func(ctx context.Context, prompt string) error {
136 if prompt == "initial" {
137 return nil
138 }
139 started <- struct{}{}
140 select {
141 case <-release:
142 case <-ctx.Done():
143 return ctx.Err()
144 }
145 return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "late report"}}}})
146 }), nil
147 })
148 t.Cleanup(func() { unblock(); _ = manager.Close(context.Background()) })
149 facade := newDeliveryFacade(t, manager, action)
150 updates := make(chan subagents.ResidentCompletion, 2)
151 manager.SetCompletionObserver(func(c subagents.ResidentCompletion) bool { updates <- c; return true })
152
153 seconds := 1
154 if cancelWait {
155 seconds = 300
156 }
157 args := fmt.Sprintf(`{"action":"spawn","task":"report","wait":%d}`, seconds)
158 if action == "resume" {
159 args = fmt.Sprintf(`{"action":"resume","agent_id":"report-child","prompt":"report","mode":"queue","wait":%d}`, seconds)
160 }
161 ctx, cancel := context.WithCancel(t.Context())
162 defer cancel()
163 type toolOutcome struct {
164 result core.ToolResult
165 err error
166 }
167 done := make(chan toolOutcome, 1)
168 go func() {
169 result, err := facade.Execute(ctx, json.RawMessage(args), nil)
170 done <- toolOutcome{result, err}
171 }()
172 select {
173 case <-started:
174 case <-time.After(5 * time.Second):
175 t.Fatal("turn did not start")
176 }
177 if cancelWait {
178 cancel()
179 }
180 select {
181 case outcome := <-done:
182 if cancelWait {
183 if !errors.Is(outcome.err, context.Canceled) {
184 t.Fatalf("wait error = %v", outcome.err)
185 }
186 } else {
187 if outcome.err != nil || outcome.result.IsError {
188 t.Fatalf("wait outcome = %#v", outcome)
189 }
190 if action == "resume" {
191 response, ok := outcome.result.Details.(subagentResumeResponse)
192 if !ok || response.Wait == nil || !response.Wait.TimedOut {
193 t.Fatalf("wait outcome = %#v", outcome)
194 }
195 } else if !strings.Contains(toolResultText(t, outcome.result), "timed out") {
196 t.Fatalf("wait outcome = %#v", outcome)
197 }
198 }
199 case <-time.After(5 * time.Second):
200 t.Fatal("wait did not return")
201 }
202 unblock()
203 select {
204 case c := <-updates:
205 if c.Err != nil || c.Summary != "late report" {
206 t.Fatalf("late completion = %#v", c)
207 }
208 case <-time.After(5 * time.Second):
209 t.Fatal("late host report was lost")
210 }
211 })
212 }
213 }
214}
215
216func TestSubagentGuidanceRejectsResumeAsWait(t *testing.T) {
217 facade := &SubagentTool{}
218 for _, text := range []string{facade.Description(), string(facade.Schema())} {
219 if !strings.Contains(text, "Do not use resume solely to wait") {
220 t.Fatalf("missing resume-as-wait warning: %s", text)
221 }
222 }
223}