1package agent
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "testing"
14 "time"
15
16 "github.com/bnema/zut/packages/agent/subagents"
17 "github.com/bnema/zut/packages/core"
18 "github.com/bnema/zut/packages/provider"
19)
20
21func TestResidentChildRunnerDefaultsToFiniteMaxSteps(t *testing.T) {
22 for _, tc := range []struct {
23 name string
24 maxSteps int
25 want int
26 }{
27 {name: "unlimited parent defaults to bounded child", maxSteps: 0, want: 50},
28 {name: "explicit parent limit preserved", maxSteps: 30, want: 30},
29 } {
30 t.Run(tc.name, func(t *testing.T) {
31 var requests atomic.Int32
32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33 requests.Add(1)
34 w.Header().Set("content-type", "text/event-stream")
35 writeOpenAIChunk(t, w, map[string]any{
36 "choices": []any{map[string]any{
37 "index": 0,
38 "delta": map[string]any{"tool_calls": []any{map[string]any{
39 "index": 0, "id": "call-loop", "type": "function",
40 "function": map[string]string{"name": "read", "arguments": `{"path":"x"}`},
41 }}},
42 "finish_reason": "tool_calls",
43 }},
44 "usage": map[string]int{"prompt_tokens": 10, "completion_tokens": 1},
45 })
46 }))
47 defer server.Close()
48
49 spec := subagents.ResidentChildSpec{
50 ID: "maxsteps-child", SessionID: "maxsteps-session", Provider: "openai", Model: "gpt-4o",
51 Tools: []string{"read"},
52 }
53 journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)
54 if err != nil {
55 t.Fatal(err)
56 }
57 t.Cleanup(func() { _ = journal.Close() })
58 if err := journal.Accept(spec, "review"); err != nil {
59 t.Fatal(err)
60 }
61 runner, err := newResidentChildRunner(Args{
62 Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL,
63 MaxSteps: tc.maxSteps, CWD: t.TempDir(), NoContextFiles: true, NoSkill: true,
64 }, spec, journal)
65 if err != nil {
66 t.Fatal(err)
67 }
68 err = runner(t.Context(), "review")
69 if !errors.Is(err, core.ErrMaxSteps) {
70 t.Fatalf("runner error = %v, want step-limit stop", err)
71 }
72 if !strings.Contains(err.Error(), fmt.Sprintf("%d", tc.want)) {
73 t.Fatalf("runner error = %v, want limit %d", err, tc.want)
74 }
75 if got := requests.Load(); got != int32(tc.want) {
76 t.Fatalf("provider requests = %d, want %d", got, tc.want)
77 }
78 })
79 }
80}
81
82func TestResidentChildRunnerInjectsContextReminderIntoNextRequest(t *testing.T) {
83 var firstUsage atomic.Int64
84 var captured []string
85 var mu sync.Mutex
86 var requests atomic.Int32
87 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
88 number := requests.Add(1)
89 var body map[string]any
90 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
91 t.Errorf("decode request: %v", err)
92 }
93 encoded, _ := json.Marshal(body["messages"])
94 mu.Lock()
95 captured = append(captured, string(encoded))
96 mu.Unlock()
97 w.Header().Set("content-type", "text/event-stream")
98 usage := map[string]int{"prompt_tokens": 10, "completion_tokens": 1}
99 text := "second result"
100 if number == 1 {
101 text = "first result"
102 usage = map[string]int{"prompt_tokens": int(firstUsage.Load()), "completion_tokens": 1}
103 }
104 writeOpenAIChunk(t, w, map[string]any{
105 "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": text}, "finish_reason": "stop"}},
106 "usage": usage,
107 })
108 }))
109 defer server.Close()
110
111 args := Args{Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, CWD: t.TempDir(), NoContextFiles: true, NoSkill: true}
112 resolved, err := Resolve(args, true)
113 if err != nil {
114 t.Fatal(err)
115 }
116 if resolved.ContextWindow <= 0 {
117 t.Fatalf("resolved context window = %d, want positive", resolved.ContextWindow)
118 }
119 // Cross the 85% threshold on the first turn.
120 firstUsage.Store(int64(resolved.ContextWindow * 86 / 100))
121 spec := subagents.ResidentChildSpec{
122 ID: "nudge-child", SessionID: "nudge-session", Provider: "openai", Model: "gpt-4o",
123 Tools: []string{"read"},
124 }
125 journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)
126 if err != nil {
127 t.Fatal(err)
128 }
129 t.Cleanup(func() { _ = journal.Close() })
130 if err := journal.Accept(spec, "review"); err != nil {
131 t.Fatal(err)
132 }
133 runner, err := newResidentChildRunner(args, spec, journal)
134 if err != nil {
135 t.Fatal(err)
136 }
137 if err := runner(t.Context(), "review"); err != nil {
138 t.Fatal(err)
139 }
140 if err := runner(t.Context(), "follow up"); err != nil {
141 t.Fatal(err)
142 }
143 if got := requests.Load(); got != 2 {
144 t.Fatalf("provider requests = %d, want 2", got)
145 }
146 mu.Lock()
147 defer mu.Unlock()
148 if len(captured) != 2 {
149 t.Fatalf("captured requests = %d, want 2", len(captured))
150 }
151 if strings.Contains(captured[0], "Context usage is at") {
152 t.Fatalf("first request carries a reminder before any usage: %s", captured[0])
153 }
154 want := fmt.Sprintf("Context usage is at %d%%", firstUsage.Load()*100/int64(resolved.ContextWindow))
155 if !strings.Contains(captured[1], want) {
156 t.Fatalf("second request missing %q: %s", want, captured[1])
157 }
158 // The reminder travels as developer context in the live request; the
159 // durable transcript keeps only finalized history.
160 messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
161 if err != nil {
162 t.Fatal(err)
163 }
164 var texts []string
165 for _, message := range messages {
166 for _, content := range message.Content {
167 if block, ok := content.(provider.TextBlock); ok {
168 texts = append(texts, block.Text)
169 }
170 }
171 }
172 joined := strings.Join(texts, "\n")
173 for _, want := range []string{"first result", "second result"} {
174 if !strings.Contains(joined, want) {
175 t.Fatalf("journal history missing %q: %q", want, joined)
176 }
177 }
178 if strings.Contains(joined, "Context usage is at") {
179 t.Fatalf("journal transcript carries the reminder: %q", joined)
180 }
181}
182
183func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T) {
184 catalogue := core.Registry{
185 "read": nil,
186 "bash": nil,
187 "subagent_spawn": nil,
188 "update_goal": nil,
189 }
190 registry, err := residentChildRegistry(catalogue, []string{"read"})
191 if err != nil {
192 t.Fatal(err)
193 }
194 if len(registry) != 1 {
195 t.Fatalf("registry = %#v", registry)
196 }
197 for _, name := range []string{"subagent_spawn", "update_goal", "missing"} {
198 _, err := residentChildRegistry(catalogue, []string{name})
199 if err == nil || !strings.Contains(err.Error(), name) {
200 t.Fatalf("residentChildRegistry(%q) error = %v", name, err)
201 }
202 }
203}
204
205func TestConfigureResidentContextNudgeRemindsOnceAtThreshold(t *testing.T) {
206 agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})
207 configureResidentContextNudge(agent, 1_000)
208
209 check := func(usage provider.Usage, wantReminder bool) string {
210 t.Helper()
211 agent.SeedLastTurnUsage(usage)
212 allowed, reason, contextText := agent.BeforeTurnContext(t.Context(), 1)
213 if !allowed || reason != "" {
214 t.Fatalf("nudge blocked turn: allowed=%t reason=%q", allowed, reason)
215 }
216 if wantReminder && !strings.Contains(contextText, "Context usage is at ") {
217 t.Fatalf("missing reminder for usage %#v: %q", usage, contextText)
218 }
219 if !wantReminder && contextText != "" {
220 t.Fatalf("unexpected reminder for usage %#v: %q", usage, contextText)
221 }
222 return contextText
223 }
224
225 // Below the threshold: silent, including cache-heavy usage.
226 check(provider.Usage{InputTokens: 849}, false)
227 check(provider.Usage{InputTokens: 400, CacheReadTokens: 400, CacheWriteTokens: 49}, false)
228 // First crossing: exactly one reminder carrying the computed percent.
229 first := check(provider.Usage{InputTokens: 850}, true)
230 if !strings.Contains(first, "85%") {
231 t.Fatalf("reminder percent = %q, want 85%%", first)
232 }
233 // Later turns stay silent even as usage grows.
234 check(provider.Usage{InputTokens: 970}, false)
235}
236
237func TestConfigureResidentContextNudgeHandlesDirectJumpAndUnknownWindow(t *testing.T) {
238 agent := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})
239 configureResidentContextNudge(agent, 1_000)
240 agent.SeedLastTurnUsage(provider.Usage{InputTokens: 970})
241 allowed, _, contextText := agent.BeforeTurnContext(t.Context(), 1)
242 if !allowed || !strings.Contains(contextText, "97%") {
243 t.Fatalf("jump reminder = allowed=%t context=%q, want one 97%% reminder", allowed, contextText)
244 }
245
246 unknown := core.NewAgent(nil, "model", "system", core.Registry{"read": nil})
247 configureResidentContextNudge(unknown, 0)
248 unknown.SeedLastTurnUsage(provider.Usage{InputTokens: 970})
249 if allowed, reason, contextText := unknown.BeforeTurnContext(t.Context(), 1); !allowed || reason != "" || contextText != "" {
250 t.Fatalf("unknown window context = allowed=%t reason=%q context=%q, want silent", allowed, reason, contextText)
251 }
252}
253
254func TestResidentChildRunnerKeepsToolsAcrossTurns(t *testing.T) {
255 var requests atomic.Int32
256 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
257 requestNumber := requests.Add(1)
258 var body map[string]any
259 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
260 t.Errorf("decode request: %v", err)
261 }
262 w.Header().Set("content-type", "text/event-stream")
263 if requestNumber == 1 {
264 if got := requestMaxTokens(t, body); got != 16_384 {
265 t.Errorf("initial max_tokens = %d, want 16384", got)
266 }
267 writeOpenAIChunk(t, w, map[string]any{
268 "choices": []any{map[string]any{
269 "index": 0,
270 "delta": map[string]any{"tool_calls": []any{map[string]any{
271 "index": 0, "id": "call-1", "type": "function",
272 "function": map[string]string{"name": "read", "arguments": `{"path":"x"}`},
273 }}},
274 "finish_reason": "tool_calls",
275 }},
276 "usage": map[string]int{"prompt_tokens": 900, "completion_tokens": 0},
277 })
278 return
279 }
280 if tools, ok := body["tools"].([]any); !ok || len(tools) == 0 {
281 t.Errorf("near-limit request tools = %#v, want registered tools", body["tools"])
282 }
283 if got := requestMaxTokens(t, body); got <= 100 {
284 t.Errorf("near-limit max_tokens = %d, want model output limit", got)
285 }
286 writeOpenAIChunk(t, w, map[string]any{
287 "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "final result"}, "finish_reason": "stop"}},
288 "usage": map[string]int{"prompt_tokens": 0, "completion_tokens": 50},
289 })
290 }))
291 defer server.Close()
292
293 runner, journal := newResidentTestRunner(t, server.URL)
294 if err := runner(t.Context(), "review"); err != nil {
295 t.Fatal(err)
296 }
297 if got := requests.Load(); got != 2 {
298 t.Fatalf("provider requests = %d, want 2", got)
299 }
300 if summary := latestResidentAssistantText(t, journal); summary != "final result" {
301 t.Fatalf("latest summary = %q", summary)
302 }
303}
304
305func TestResidentChildRunnerRunsHighUsageFollowUpWithoutBudget(t *testing.T) {
306 var requests atomic.Int32
307 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
308 requests.Add(1)
309 var body map[string]any
310 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
311 t.Errorf("decode request: %v", err)
312 }
313 if got := requestMaxTokens(t, body); got != 16_384 {
314 t.Errorf("initial max_tokens = %d, want 16384", got)
315 }
316 w.Header().Set("content-type", "text/event-stream")
317 writeOpenAIChunk(t, w, map[string]any{
318 "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "best available result"}, "finish_reason": "stop"}},
319 "usage": map[string]int{"prompt_tokens": 1_200, "completion_tokens": 0},
320 })
321 }))
322 defer server.Close()
323
324 runner, journal := newResidentTestRunner(t, server.URL)
325 if err := runner(t.Context(), "review"); err != nil {
326 t.Fatalf("runner error = %v, want terminal result", err)
327 }
328 if got := requests.Load(); got != 1 {
329 t.Fatalf("provider requests = %d, want 1", got)
330 }
331 if summary := latestResidentAssistantText(t, journal); summary != "best available result" {
332 t.Fatalf("latest summary = %q", summary)
333 }
334 // Without a cumulative budget, a high-usage follow-up runs normally.
335 if err := runner(t.Context(), "continue"); err != nil {
336 t.Fatalf("follow-up error = %v, want success", err)
337 }
338 if got := requests.Load(); got != 2 {
339 t.Fatalf("provider requests = %d, want 2", got)
340 }
341}
342
343func TestResidentChildRunnerResumesWithRetainedHistory(t *testing.T) {
344 var requests atomic.Int32
345 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
346 request := requests.Add(1)
347 w.Header().Set("content-type", "text/event-stream")
348 if request == 1 {
349 writeOpenAIChunk(t, w, map[string]any{
350 "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "first result"}, "finish_reason": "stop"}},
351 "usage": map[string]int{"prompt_tokens": 1200, "completion_tokens": 0},
352 })
353 return
354 }
355 var body map[string]any
356 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
357 t.Error(err)
358 }
359 encoded, _ := json.Marshal(body["messages"])
360 if !strings.Contains(string(encoded), "first result") {
361 t.Errorf("resume lost prior messages: %s", encoded)
362 }
363 writeOpenAIChunk(t, w, map[string]any{
364 "choices": []any{map[string]any{"index": 0, "delta": map[string]string{"content": "recovered result"}, "finish_reason": "stop"}},
365 "usage": map[string]int{"prompt_tokens": 20, "completion_tokens": 10},
366 })
367 }))
368 defer server.Close()
369 cwd := t.TempDir()
370 manager := subagents.NewResidentManager(t.TempDir(), func(spec subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
371 return newResidentChildRunner(Args{Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: server.URL, CWD: cwd, NoContextFiles: true, NoSkill: true}, spec, journal)
372 })
373 t.Cleanup(func() { _ = manager.Close(context.Background()) })
374 completed := make(chan subagents.ResidentCompletion, 2)
375 manager.SetCompletionObserver(func(c subagents.ResidentCompletion) { completed <- c })
376 spec := subagents.ResidentChildSpec{ID: "resume-child", SessionID: "session", Provider: "openai", Model: "gpt-4o"}
377 if _, err := manager.Spawn(t.Context(), spec, "review"); err != nil {
378 t.Fatal(err)
379 }
380 for turn := 0; turn < 2; turn++ {
381 select {
382 case completion := <-completed:
383 if completion.Err != nil {
384 t.Fatal(completion.Err)
385 }
386 case <-time.After(5 * time.Second):
387 t.Fatal("no completion")
388 }
389 if turn == 0 {
390 if err := manager.Resume(t.Context(), spec.ID, "follow up using previous results"); err != nil {
391 t.Fatal(err)
392 }
393 }
394 }
395 snapshot, _ := manager.SnapshotFor(spec.ID)
396 if requests.Load() != 2 || snapshot.Usage.InputTokens != 1220 {
397 t.Fatalf("requests=%d, snapshot=%#v", requests.Load(), snapshot)
398 }
399}
400
401func newResidentTestRunner(t *testing.T, baseURL string) (subagents.ResidentTurnRunner, *subagents.ResidentJournal) {
402 t.Helper()
403 spec := subagents.ResidentChildSpec{
404 ID: "resident-child", SessionID: "resident-session", Provider: "openai", Model: "gpt-4o",
405 Tools: []string{"read"},
406 }
407 journal, err := subagents.OpenResidentJournal(t.TempDir(), spec.ID)
408 if err != nil {
409 t.Fatal(err)
410 }
411 t.Cleanup(func() { _ = journal.Close() })
412 if err := journal.Accept(spec, "review"); err != nil {
413 t.Fatal(err)
414 }
415 runner, err := newResidentChildRunner(Args{
416 Provider: "openai", Model: "gpt-4o", APIKey: "synthetic", BaseURL: baseURL,
417 CWD: t.TempDir(), NoContextFiles: true, NoSkill: true,
418 }, spec, journal)
419 if err != nil {
420 t.Fatal(err)
421 }
422 return runner, journal
423}
424
425func requestMaxTokens(t *testing.T, body map[string]any) int {
426 t.Helper()
427 value, ok := body["max_tokens"].(float64)
428 if !ok {
429 t.Errorf("request max_tokens = %#v", body["max_tokens"])
430 return 0
431 }
432 return int(value)
433}
434
435func latestResidentAssistantText(t *testing.T, journal *subagents.ResidentJournal) string {
436 t.Helper()
437 messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
438 if err != nil {
439 t.Fatal(err)
440 }
441 for index := len(messages) - 1; index >= 0; index-- {
442 if messages[index].Role != provider.RoleAssistant {
443 continue
444 }
445 var text strings.Builder
446 for _, content := range messages[index].Content {
447 if block, ok := content.(provider.TextBlock); ok {
448 text.WriteString(block.Text)
449 }
450 }
451 return text.String()
452 }
453 return ""
454}
455
456func writeOpenAIChunk(t *testing.T, w http.ResponseWriter, chunk map[string]any) {
457 t.Helper()
458 data, err := json.Marshal(chunk)
459 if err != nil {
460 t.Fatal(err)
461 }
462 _, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", data)
463}
464
465func TestResidentChildArgsPreserveDurableProfileInheritance(t *testing.T) {
466 no := false
467 next := residentChildArgs(Args{Orchestrate: true, NoSkill: false, NoContextFiles: false, BaseURL: "https://stale.example/v1", InsecureTLS: false}, "openai", subagents.ResidentChildSpec{
468 Provider: "openai", BaseURL: "https://current.example/v1", InsecureTLS: true, Model: "gpt-5", Workspace: "/repo/child",
469 InheritSkills: &no, InheritProjectContext: &no,
470 })
471 if next.Provider != "openai" || next.BaseURL != "https://current.example/v1" || !next.InsecureTLS || next.Model != "gpt-5" || next.CWD != "/repo/child" || !next.NoSkill || !next.NoContextFiles || next.Orchestrate || !next.ResidentChild {
472 t.Fatalf("resident child args = %#v", next)
473 }
474}
475
476func TestResidentChildArgsDoNotForwardCLIKeyAcrossProviders(t *testing.T) {
477 parent := Args{Provider: "openai", APIKey: "parent-key"}
478 child := subagents.ResidentChildSpec{Provider: "anthropic", Model: "claude"}
479 if next := residentChildArgs(parent, "openai", child); next.APIKey != "" {
480 t.Fatalf("cross-provider child inherited API key %q", next.APIKey)
481 }
482 child.Provider = "openai"
483 if next := residentChildArgs(parent, "openai", child); next.APIKey != "parent-key" {
484 t.Fatalf("same-provider child API key = %q, want parent key", next.APIKey)
485 }
486}