v0.30.0 4a12e83

resident_tools_test.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"strings"
8	"sync"
9	"testing"
10	"time"
11
12	"github.com/bnema/zut/packages/agent/subagents"
13	"github.com/bnema/zut/packages/core"
14	"github.com/bnema/zut/packages/provider"
15)
16
17func TestResidentStatusRetrievesBudgetHandoffWithoutExecution(t *testing.T) {
18	manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
19		return func(context.Context, string) error {
20			if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "partial finding"}}}}); err != nil {
21				return err
22			}
23			return subagents.ErrBudgetExceeded
24		}, nil
25	})
26	t.Cleanup(func() { _ = manager.Close(context.Background()) })
27	spec := subagents.ResidentChildSpec{ID: "budget-status", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test", Required: true}
28	completed, cancel := manager.WatchCompletion(spec.ID, spec.InitialTurnID)
29	defer cancel()
30	if _, err := manager.Spawn(t.Context(), spec, "investigate"); err != nil {
31		t.Fatal(err)
32	}
33	select {
34	case <-completed:
35	case <-time.After(time.Second):
36		t.Fatal("no completion")
37	}
38	status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }}
39	for _, include := range []bool{false, true} {
40		raw := json.RawMessage(`{"agent_id":"budget-status"}`)
41		if include {
42			raw = json.RawMessage(`{"agent_id":"budget-status","include_result":true}`)
43		}
44		result, err := status.Execute(t.Context(), raw, nil)
45		if err != nil || result.IsError {
46			t.Fatalf("status: %#v, %v", result, err)
47		}
48		response := result.Details.(subagentStatusResponse)
49		if response.Agent.State != subagents.ResidentBudgetExhausted || (response.Result != nil) != include {
50			t.Fatalf("response = %#v", response)
51		}
52		if include && (!strings.Contains(response.Result.Handoff, "partial finding") || response.Result.ErrorCode != "budget_exhausted") {
53			t.Fatalf("result = %#v", response.Result)
54		}
55	}
56	result, err := status.Execute(t.Context(), json.RawMessage(`{"include_result":true}`), nil)
57	if err != nil || !result.IsError {
58		t.Fatalf("missing child ID must fail: %#v, %v", result, err)
59	}
60	if len(manager.UnmetRequired()) != 1 {
61		t.Fatal("reading a handoff satisfied required work")
62	}
63}
64
65func TestResidentToolsUseManagerOnly(t *testing.T) {
66	runs := make(chan string, 2)
67	manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
68		return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil
69	})
70	t.Cleanup(func() { _ = manager.Close(context.Background()) })
71	spawn := &SubagentSpawnTool{
72		ResidentManager: manager,
73		Enabled:         func() bool { return true },
74		DefaultProvider: func() string { return "openai" },
75		DefaultModel:    func() string { return "gpt-5" },
76		BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
77			return subagents.ResidentChildSpec{ID: "resident-tool", SessionID: "child-session", Provider: request.Provider, Model: request.Model, Required: request.Required}, nil
78		},
79	}
80	result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"initial"}`), nil)
81	if err != nil {
82		t.Fatal(err)
83	}
84	if got := toolResultText(t, result); !strings.Contains(got, "owns the delegated scope") || !strings.Contains(got, "Do not repeat that work in the parent") {
85		t.Fatalf("spawn result lacks ownership guidance: %q", got)
86	}
87	if got := <-runs; got != "initial" {
88		t.Fatalf("initial prompt = %q", got)
89	}
90	status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }}
91	result, err = status.Execute(context.Background(), json.RawMessage(`{"agent_id":"resident-tool"}`), nil)
92	if err != nil {
93		t.Fatal(err)
94	}
95	if details, ok := result.Details.(subagentStatusResponse); !ok || details.Agent == nil || details.Agent.State == "" {
96		t.Fatalf("status details = %#v", result.Details)
97	}
98	resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
99	if _, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"resident-tool","prompt":"follow up"}`), nil); err != nil {
100		t.Fatal(err)
101	}
102	if got := <-runs; got != "follow up" {
103		t.Fatalf("follow-up prompt = %q", got)
104	}
105}
106
107func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) {
108	spawn := &SubagentSpawnTool{}
109	for _, want := range []string{"independent sidecar", "keep immediate blockers local", "never duplicate it in the parent", "end or yield the parent turn"} {
110		if got := spawn.Description(); !strings.Contains(got, want) {
111			t.Fatalf("description missing %q: %s", want, got)
112		}
113	}
114	var schema struct {
115		Properties map[string]struct {
116			Description string `json:"description"`
117		} `json:"properties"`
118	}
119	if err := json.Unmarshal(spawn.Schema(), &schema); err != nil {
120		t.Fatal(err)
121	}
122	task := schema.Properties["task"].Description
123	for _, want := range []string{"concrete, bounded scope", "explicit ownership", "does not overlap", "Shared isolation uses this working directory", "worktree isolation captures a patch without merging it"} {
124		if !strings.Contains(task, want) {
125			t.Fatalf("task schema missing %q: %s", want, task)
126		}
127	}
128}
129
130func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) {
131	for _, tc := range []struct {
132		name   string
133		err    error
134		status string
135	}{
136		{"success", nil, "completed"},
137		{"failure", errors.New("worker failed"), "failed"},
138		{"budget", subagents.ErrBudgetExceeded, "budget_exhausted"},
139		{"cancellation", context.Canceled, "interrupted"},
140		{"cancellation and budget", errors.Join(context.Canceled, subagents.ErrBudgetExceeded), "interrupted"},
141	} {
142		t.Run(tc.name, func(t *testing.T) {
143			manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
144				return func(context.Context, string) error { return tc.err }, nil
145			})
146			t.Cleanup(func() { _ = manager.Close(context.Background()) })
147			spawn := &SubagentSpawnTool{
148				ResidentManager: manager,
149				Enabled:         func() bool { return true },
150				DefaultProvider: func() string { return "openai" },
151				DefaultModel:    func() string { return "gpt-5" },
152				BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
153					return subagents.ResidentChildSpec{ID: "wait-for-completion", SessionID: "child-session", Provider: request.Provider, Model: request.Model, Required: request.Required}, nil
154				},
155			}
156			result, err := spawn.Execute(t.Context(), json.RawMessage(`{"task":"finish now","wait":1,"required":true}`), nil)
157			if err != nil || result.IsError {
158				t.Fatalf("Execute = (%#v, %v)", result, err)
159			}
160			if got := toolResultText(t, result); !strings.Contains(got, "\nstate: "+tc.status+"\n") || !strings.Contains(got, "\nrequired: "+tc.status+"\n") {
161				t.Fatalf("wait result = %q, want %s state", got, tc.status)
162			}
163			if unmet := len(manager.UnmetRequired()); (unmet != 0) != (tc.err != nil) {
164				t.Fatalf("unmet required = %d, error = %v", unmet, tc.err)
165			}
166		})
167	}
168}
169
170func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) {
171	started := make(chan struct{}, 1)
172	release := make(chan struct{})
173	var releaseOnce sync.Once
174	releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
175	finished := make(chan string, 1)
176	manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
177		return func(ctx context.Context, prompt string) error {
178			switch prompt {
179			case "blocker":
180				started <- struct{}{}
181				select {
182				case <-release:
183					return nil
184				case <-ctx.Done():
185					return ctx.Err()
186				}
187			case "waiter":
188				finished <- prompt
189			}
190			return nil
191		}, nil
192	})
193	t.Cleanup(func() {
194		releaseBlocker()
195		_ = manager.Close(context.Background())
196	})
197	spawn := &SubagentSpawnTool{
198		ResidentManager: manager,
199		Enabled:         func() bool { return true },
200		DefaultProvider: func() string { return "openai" },
201		DefaultModel:    func() string { return "gpt-5" },
202		BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
203			return subagents.ResidentChildSpec{ID: request.Task, SessionID: request.Task, Provider: request.Provider, Model: request.Model}, nil
204		},
205	}
206	if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"blocker"}`), nil); err != nil {
207		t.Fatalf("spawn blocker: %v", err)
208	}
209	select {
210	case <-started:
211	case <-time.After(5 * time.Second):
212		t.Fatal("blocker did not start")
213	}
214	result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"waiter","wait":1}`), nil)
215	if err != nil || result.IsError {
216		t.Fatalf("spawn waiter = (%#v, %v)", result, err)
217	}
218	if got := toolResultText(t, result); !strings.Contains(got, "state: queued") || !strings.Contains(got, "wait: timed out after 1 seconds") {
219		t.Fatalf("wait expiry result = %q, want queued timeout", got)
220	}
221	if state, ok := manager.State("waiter"); !ok || state != subagents.ResidentQueued {
222		t.Fatalf("waiter state = %q (found=%t), want queued", state, ok)
223	}
224	releaseBlocker()
225	select {
226	case got := <-finished:
227		if got != "waiter" {
228			t.Fatalf("finished task = %q, want waiter", got)
229		}
230	case <-time.After(5 * time.Second):
231		t.Fatal("queued waiter did not execute")
232	}
233}
234
235func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
236	manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
237		return func(context.Context, string) error { return nil }, nil
238	})
239	t.Cleanup(func() { _ = manager.Close(context.Background()) })
240	built := 0
241	spawn := &SubagentSpawnTool{
242		ResidentManager: manager,
243		Enabled:         func() bool { return true },
244		BuildResidentSpec: func(context.Context, ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
245			built++
246			return subagents.ResidentChildSpec{ID: "should-not-exist", SessionID: "child"}, nil
247		},
248	}
249	for _, raw := range []string{
250		`{"task":"x","unexpected":true}`,
251		`{"task":"x"}{}`,
252		`{"task":"x","model":"gpt-5"}`,
253		`{"task":"x","wait":0}`,
254		`{"task":"x","wait":301}`,
255		`{"task":"x","isolation":"outside"}`,
256		`{"task":"x","budget_ratio":0.5}`,
257		`{"task":"x","budget_tokens":1000}`,
258	} {
259		result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)
260		if err != nil && !strings.Contains(err.Error(), "invalid args") {
261			t.Fatalf("Execute(%s) error = %v", raw, err)
262		}
263		if err == nil && !result.IsError {
264			t.Fatalf("Execute(%s) = %#v, want protocol error", raw, result)
265		}
266	}
267	if built != 0 || len(manager.Snapshot()) != 0 {
268		t.Fatalf("invalid input reached resident manager: built=%d snapshots=%#v", built, manager.Snapshot())
269	}
270}
271
272func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
273	manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
274		return func(context.Context, string) error { return nil }, nil
275	})
276	t.Cleanup(func() { _ = manager.Close(context.Background()) })
277	profileFast := false
278	var got ResidentSpawnRequest
279	spawn := &SubagentSpawnTool{
280		ResidentManager:  manager,
281		Enabled:          func() bool { return true },
282		DefaultModel:     func() string { return "host-model" },
283		DefaultProvider:  func() string { return "host-provider" },
284		DefaultReasoning: func() string { return "medium" },
285		ResolveSubagent: func(name string) (*subagents.Profile, error) {
286			return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast}, nil
287		},
288		BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
289			got = request
290			return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil
291		},
292	}
293	result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true}`), nil)
294	if err != nil || result.IsError {
295		t.Fatalf("Execute = (%#v, %v)", result, err)
296	}
297	if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode {
298		t.Fatalf("factory request = %#v", got)
299	}
300}
301
302func TestResidentToolsRejectUnknownFields(t *testing.T) {
303	for _, tool := range []interface {
304		Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error)
305	}{
306		&SubagentStatusTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
307		&SubagentStopTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
308		&SubagentResumeTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
309	} {
310		_, err := tool.Execute(context.Background(), json.RawMessage(`{"unknown":true}`), nil)
311		if err == nil || !strings.Contains(err.Error(), "unknown field") {
312			t.Fatalf("unknown field error = %v", err)
313		}
314	}
315}
316
317func toolResultText(t *testing.T, result core.ToolResult) string {
318	t.Helper()
319	if len(result.Content) != 1 {
320		t.Fatalf("content = %#v, want one text block", result.Content)
321	}
322	block, ok := result.Content[0].(provider.TextBlock)
323	if !ok {
324		t.Fatalf("content = %#v, want text block", result.Content)
325	}
326	return block.Text
327}
328
329func TestPublicResidentStatusIncludesRolloutBudget(t *testing.T) {
330	entry := publicResidentStatus(subagents.ResidentSnapshot{
331		ID: "budgeted", Budget: subagents.BudgetSnapshot{Used: 70, Limit: 100, Percent: 70, State: subagents.BudgetNormal}, BudgetSource: "model_context",
332	})
333	if entry.Budget == nil || entry.Budget.State != subagents.BudgetNormal || entry.BudgetSource != "model_context" {
334		t.Fatalf("status budget = %#v", entry)
335	}
336}
337
338func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) {
339	_, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab")
340	if ok {
341		t.Fatal("ambiguous resident prefix resolved")
342	}
343}

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