v0.33.0 653c635

subagent_runtime_test.go RawHistoryFolder

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

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