v0.35.0 21873e8

subagent_factory_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close