v0.38.0 58133ea

subagent_runtime_test.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"encoding/json"
6	"slices"
7	"strings"
8	"testing"
9
10	"github.com/bnema/zut/packages/agent/subagents"
11	"github.com/bnema/zut/packages/agent/tools"
12	"github.com/bnema/zut/packages/core"
13)
14
15func TestExpandWebCapabilityTools(t *testing.T) {
16	catalogue := append([]string{"read"}, tools.WebCapabilityNames...)
17	got := expandWebCapabilityTools([]string{"read", "web_search"}, catalogue, func(string) bool { return true })
18	for _, name := range append([]string{"read"}, tools.WebCapabilityNames...) {
19		found := false
20		for _, candidate := range got {
21			if candidate == name {
22				found = true
23				break
24			}
25		}
26		if !found {
27			t.Fatalf("expanded child tools = %v, missing %s", got, name)
28		}
29	}
30}
31
32func TestExpandWebCapabilityToolsPreservesPolicyAndNonWebSelections(t *testing.T) {
33	catalogue := append([]string{"read"}, tools.WebCapabilityNames...)
34	partial := expandWebCapabilityTools([]string{"web_open"}, catalogue, func(string) bool { return true })
35	for _, name := range tools.WebCapabilityNames {
36		found := false
37		for _, candidate := range partial {
38			if candidate == name {
39				found = true
40				break
41			}
42		}
43		if !found {
44			t.Fatalf("partial web selection did not expand: %v", partial)
45		}
46	}
47	denied := expandWebCapabilityTools([]string{"read", "web_search"}, catalogue, func(name string) bool { return name == "read" })
48	if len(denied) != 1 || denied[0] != "read" {
49		t.Fatalf("denied web capability = %v, want [read]", denied)
50	}
51	nonWeb := expandWebCapabilityTools([]string{"read"}, catalogue, func(string) bool { return true })
52	if len(nonWeb) != 1 || nonWeb[0] != "read" {
53		t.Fatalf("non-web selection changed to %v", nonWeb)
54	}
55}
56
57func TestResidentChildSpecSnapshotsCurrentProviderTransportSettings(t *testing.T) {
58	runtime := newSubagentRuntime(subagentRuntimeConfig{
59		Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
60		Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://old.example/v1", InsecureTLS: false, ContextWindow: 500_000,
61	})
62	t.Cleanup(func() { _ = runtime.Close(context.Background()) })
63	runtime.SetProviderSettings("https://current.example/v1", true)
64	runtime.SetModel("gpt-current")
65	spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
66	if err != nil {
67		t.Fatal(err)
68	}
69	if spec.BaseURL != "https://current.example/v1" || !spec.InsecureTLS || spec.Model != "gpt-current" {
70		t.Fatalf("resident spec transport/model = %q insecure=%t model=%q", spec.BaseURL, spec.InsecureTLS, spec.Model)
71	}
72}
73
74func TestResidentChildSpecTracksFastMode(t *testing.T) {
75	runtime := newSubagentRuntime(subagentRuntimeConfig{
76		Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
77		Provider: "openai", Model: "gpt-5.6-sol", FastMode: false,
78	})
79	t.Cleanup(func() { _ = runtime.Close(context.Background()) })
80
81	spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
82	if err != nil {
83		t.Fatal(err)
84	}
85	if spec.FastMode {
86		t.Fatal("initial resident fast mode = true, want false")
87	}
88	runtime.SetFastMode(true)
89	spec, err = runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
90	if err != nil {
91		t.Fatal(err)
92	}
93	if !spec.FastMode {
94		t.Fatal("resident fast mode = false after update, want true")
95	}
96}
97
98func TestResidentChildSpecCarriesNoCumulativeBudget(t *testing.T) {
99	runtime := newSubagentRuntime(subagentRuntimeConfig{
100		Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
101		Provider: "openai", Model: "gpt-5.6-sol", ContextWindow: 42_000,
102	})
103	t.Cleanup(func() { _ = runtime.Close(context.Background()) })
104
105	spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
106	if err != nil {
107		t.Fatal(err)
108	}
109	if len(spec.Tools) != 1 || spec.Tools[0] != "read" {
110		t.Fatalf("child spec tools = %#v, want resolved tool list", spec)
111	}
112	// The budget fields are gone from the spec struct; the serialized
113	// form must carry no budget bytes either.
114	encoded, err := json.Marshal(spec)
115	if err != nil {
116		t.Fatal(err)
117	}
118	if strings.Contains(string(encoded), "budget") {
119		t.Fatalf("child spec carries budget = %s", encoded)
120	}
121}
122
123func TestResidentChildSpecDoesNotCarryTransportAcrossProviderOverride(t *testing.T) {
124	runtime := newSubagentRuntime(subagentRuntimeConfig{
125		Args: Args{}, Root: t.TempDir(), RepoRoot: t.TempDir(),
126		Provider: "openai", Model: "gpt-5.6-sol", BaseURL: "https://parent.example/v1", InsecureTLS: true, ContextWindow: 500_000,
127	})
128	t.Cleanup(func() { _ = runtime.Close(context.Background()) })
129	spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
130		Task: "review", Provider: "anthropic", Model: "claude-sonnet-4-5",
131	}, core.Registry{"read": nil})
132	if err != nil {
133		t.Fatal(err)
134	}
135	if spec.BaseURL != "" || spec.InsecureTLS {
136		t.Fatalf("cross-provider spec inherited transport: %#v", spec)
137	}
138}
139
140func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) {
141	t.Setenv("ZUT_HOME", t.TempDir())
142	cwd := t.TempDir()
143	runtime := newSubagentRuntime(subagentRuntimeConfig{
144		Args:     Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
145		Root:     t.TempDir(),
146		RepoRoot: cwd,
147		Provider: "openai", Model: "gpt-5.6-sol",
148	})
149	t.Cleanup(func() { _ = runtime.Close(context.Background()) })
150
151	parent := core.Registry{
152		"read":           nil,
153		"schedule":       nil,
154		"host-only-tool": nil,
155		"subagent_spawn": nil,
156		"update_goal":    nil,
157	}
158	spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
159	if err != nil {
160		t.Fatal(err)
161	}
162	if slices.Contains(spec.Tools, "schedule") || slices.Contains(spec.Tools, "host-only-tool") {
163		t.Fatalf("generic child inherited parent-only tools: %v", spec.Tools)
164	}
165	if !slices.Contains(spec.Tools, "read") {
166		t.Fatalf("generic child lost supported tool: %v", spec.Tools)
167	}
168	for _, name := range []string{"subagent_spawn", "update_goal"} {
169		if slices.Contains(spec.Tools, name) {
170			t.Fatalf("generic child kept forbidden tool %q: %v", name, spec.Tools)
171		}
172	}
173	resolved, err := Resolve(residentChildArgs(runtime.args, runtime.credentialProvider, spec), false)
174	if err != nil {
175		t.Fatal(err)
176	}
177	if _, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools); err != nil {
178		t.Fatalf("generic spec failed child registry validation: %v (tools=%v)", err, spec.Tools)
179	}
180}
181
182func TestResidentChildSpecProfileToolDeclarations(t *testing.T) {
183	t.Setenv("ZUT_HOME", t.TempDir())
184	cwd := t.TempDir()
185	newRuntime := func(policy subagents.SubagentPolicy) *subagentRuntime {
186		rt := newSubagentRuntime(subagentRuntimeConfig{
187			Args:     Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
188			Root:     t.TempDir(),
189			RepoRoot: cwd,
190			Provider: "openai", Model: "gpt-5.6-sol",
191			Policy: policy,
192		})
193		t.Cleanup(func() { _ = rt.Close(context.Background()) })
194		return rt
195	}
196	parent := core.Registry{"read": nil, "bash": nil, "schedule": nil}
197
198	t.Run("omitted inherits like generic", func(t *testing.T) {
199		rt := newRuntime(subagents.SubagentPolicy{})
200		generic, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
201		if err != nil {
202			t.Fatal(err)
203		}
204		withProfile, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
205			Task:    "review",
206			Profile: &subagents.Profile{Name: "generic"},
207		}, parent)
208		if err != nil {
209			t.Fatal(err)
210		}
211		if !slices.Equal(generic.Tools, withProfile.Tools) {
212			t.Fatalf("omitted profile tools = %v, want generic %v", withProfile.Tools, generic.Tools)
213		}
214	})
215
216	t.Run("explicit keeps only supported permitted names", func(t *testing.T) {
217		rt := newRuntime(subagents.SubagentPolicy{})
218		spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
219			Task: "review",
220			Profile: &subagents.Profile{
221				Name: "reviewer", ToolsDeclared: true,
222				Tools: []string{"read", "schedule", "host-only-tool", "missing"},
223			},
224		}, parent)
225		if err != nil {
226			t.Fatal(err)
227		}
228		if len(spec.Tools) != 1 || spec.Tools[0] != "read" {
229			t.Fatalf("explicit profile tools = %v, want [read]", spec.Tools)
230		}
231	})
232
233	t.Run("explicit empty stays empty", func(t *testing.T) {
234		rt := newRuntime(subagents.SubagentPolicy{})
235		spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
236			Task:    "review",
237			Profile: &subagents.Profile{Name: "empty", ToolsDeclared: true, Tools: []string{}},
238		}, parent)
239		if err != nil {
240			t.Fatal(err)
241		}
242		if len(spec.Tools) != 0 {
243			t.Fatalf("explicit empty profile tools = %v, want []", spec.Tools)
244		}
245	})
246
247	t.Run("policy denials hold", func(t *testing.T) {
248		rt := newRuntime(subagents.SubagentPolicy{AllowedTools: []string{"read"}})
249		spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
250		if err != nil {
251			t.Fatal(err)
252		}
253		if len(spec.Tools) != 1 || spec.Tools[0] != "read" {
254			t.Fatalf("policy-restricted tools = %v, want [read]", spec.Tools)
255		}
256		explicit, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
257			Task: "review",
258			Profile: &subagents.Profile{
259				Name: "reviewer", ToolsDeclared: true,
260				Tools: []string{"read", "bash"},
261			},
262		}, parent)
263		if err != nil {
264			t.Fatal(err)
265		}
266		if len(explicit.Tools) != 1 || explicit.Tools[0] != "read" {
267			t.Fatalf("policy-restricted explicit tools = %v, want [read]", explicit.Tools)
268		}
269	})
270}
271
272func TestResidentChildSpecNeverGrantsToolsOutsideParent(t *testing.T) {
273	t.Setenv("ZUT_HOME", t.TempDir())
274	cwd := t.TempDir()
275	runtime := newSubagentRuntime(subagentRuntimeConfig{
276		Args:     Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
277		Root:     t.TempDir(),
278		RepoRoot: cwd,
279		Provider: "openai", Model: "gpt-5.6-sol",
280	})
281	t.Cleanup(func() { _ = runtime.Close(context.Background()) })
282
283	spec, err := runtime.buildResidentChildSpec(context.Background(),
284		tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
285	if err != nil {
286		t.Fatal(err)
287	}
288	// bash/write exist in the resolved child runtime but were never offered
289	// by the parent catalogue; the child must not receive them.
290	for _, name := range []string{"bash", "write", "schedule"} {
291		if slices.Contains(spec.Tools, name) {
292			t.Fatalf("child gained tool %q outside parent catalogue: %v", name, spec.Tools)
293		}
294	}
295	explicit, err := runtime.buildResidentChildSpec(context.Background(),
296		tools.ResidentSpawnRequest{
297			Task: "review",
298			Profile: &subagents.Profile{
299				Name: "reviewer", ToolsDeclared: true,
300				Tools: []string{"read", "bash"},
301			},
302		}, core.Registry{"read": nil})
303	if err != nil {
304		t.Fatal(err)
305	}
306	if len(explicit.Tools) != 1 || explicit.Tools[0] != "read" {
307		t.Fatalf("explicit profile gained tool outside parent catalogue: %v", explicit.Tools)
308	}
309}
310
311func TestResidentChildSpecDisabledSkillsDoNotLeak(t *testing.T) {
312	t.Setenv("ZUT_HOME", t.TempDir())
313	cwd := t.TempDir()
314	runtime := newSubagentRuntime(subagentRuntimeConfig{
315		Args:     Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
316		Root:     t.TempDir(),
317		RepoRoot: cwd,
318		Provider: "openai", Model: "gpt-5.6-sol",
319	})
320	t.Cleanup(func() { _ = runtime.Close(context.Background()) })
321
322	inherit := false
323	parent := core.Registry{"read": nil, "skill": nil}
324	spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
325		Task:    "review",
326		Profile: &subagents.Profile{Name: "noskill", InheritSkills: &inherit},
327	}, parent)
328	if err != nil {
329		t.Fatal(err)
330	}
331	if slices.Contains(spec.Tools, "skill") {
332		t.Fatalf("skill-disabled child inherited skill tool: %v", spec.Tools)
333	}
334	resolved, err := Resolve(residentChildArgs(runtime.args, runtime.credentialProvider, spec), false)
335	if err != nil {
336		t.Fatal(err)
337	}
338	if _, ok := resolved.ToolRegistry["skill"]; ok {
339		t.Fatalf("skill-disabled child registry contains skill tool")
340	}
341	if _, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools); err != nil {
342		t.Fatalf("skill-disabled spec failed validation: %v", err)
343	}
344}

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 476

↑↓ moveenter openesc close