detached a9d674e

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":       nil,
156		"update_goal":    nil,
157		"plan":           nil,
158	}
159	spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
160	if err != nil {
161		t.Fatal(err)
162	}
163	if slices.Contains(spec.Tools, "schedule") || slices.Contains(spec.Tools, "host-only-tool") {
164		t.Fatalf("generic child inherited parent-only tools: %v", spec.Tools)
165	}
166	if !slices.Contains(spec.Tools, "read") {
167		t.Fatalf("generic child lost supported tool: %v", spec.Tools)
168	}
169	for _, name := range []string{"subagent", "update_goal", "plan"} {
170		if slices.Contains(spec.Tools, name) {
171			t.Fatalf("generic child kept forbidden tool %q: %v", name, spec.Tools)
172		}
173	}
174	resolved, err := Resolve(residentChildArgs(runtime.args, runtime.credentialProvider, spec), false)
175	if err != nil {
176		t.Fatal(err)
177	}
178	if _, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools); err != nil {
179		t.Fatalf("generic spec failed child registry validation: %v (tools=%v)", err, spec.Tools)
180	}
181}
182
183func TestResidentChildSpecProfileToolDeclarations(t *testing.T) {
184	t.Setenv("ZUT_HOME", t.TempDir())
185	cwd := t.TempDir()
186	newRuntime := func(policy subagents.SubagentPolicy) *subagentRuntime {
187		rt := newSubagentRuntime(subagentRuntimeConfig{
188			Args:     Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
189			Root:     t.TempDir(),
190			RepoRoot: cwd,
191			Provider: "openai", Model: "gpt-5.6-sol",
192			Policy: policy,
193		})
194		t.Cleanup(func() { _ = rt.Close(context.Background()) })
195		return rt
196	}
197	parent := core.Registry{"read": nil, "bash": nil, "schedule": nil}
198
199	t.Run("omitted inherits like generic", func(t *testing.T) {
200		rt := newRuntime(subagents.SubagentPolicy{})
201		generic, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
202		if err != nil {
203			t.Fatal(err)
204		}
205		withProfile, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
206			Task:    "review",
207			Profile: &subagents.Profile{Name: "generic"},
208		}, parent)
209		if err != nil {
210			t.Fatal(err)
211		}
212		if !slices.Equal(generic.Tools, withProfile.Tools) {
213			t.Fatalf("omitted profile tools = %v, want generic %v", withProfile.Tools, generic.Tools)
214		}
215	})
216
217	t.Run("explicit keeps only supported permitted names", func(t *testing.T) {
218		rt := newRuntime(subagents.SubagentPolicy{})
219		spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
220			Task: "review",
221			Profile: &subagents.Profile{
222				Name: "reviewer", ToolsDeclared: true,
223				Tools: []string{"read", "schedule", "host-only-tool", "missing"},
224			},
225		}, parent)
226		if err != nil {
227			t.Fatal(err)
228		}
229		if len(spec.Tools) != 1 || spec.Tools[0] != "read" {
230			t.Fatalf("explicit profile tools = %v, want [read]", spec.Tools)
231		}
232	})
233
234	t.Run("explicit empty stays empty", func(t *testing.T) {
235		rt := newRuntime(subagents.SubagentPolicy{})
236		spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
237			Task:    "review",
238			Profile: &subagents.Profile{Name: "empty", ToolsDeclared: true, Tools: []string{}},
239		}, parent)
240		if err != nil {
241			t.Fatal(err)
242		}
243		if len(spec.Tools) != 0 {
244			t.Fatalf("explicit empty profile tools = %v, want []", spec.Tools)
245		}
246	})
247
248	t.Run("policy denials hold", func(t *testing.T) {
249		rt := newRuntime(subagents.SubagentPolicy{AllowedTools: []string{"read"}})
250		spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent)
251		if err != nil {
252			t.Fatal(err)
253		}
254		if len(spec.Tools) != 1 || spec.Tools[0] != "read" {
255			t.Fatalf("policy-restricted tools = %v, want [read]", spec.Tools)
256		}
257		explicit, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
258			Task: "review",
259			Profile: &subagents.Profile{
260				Name: "reviewer", ToolsDeclared: true,
261				Tools: []string{"read", "bash"},
262			},
263		}, parent)
264		if err != nil {
265			t.Fatal(err)
266		}
267		if len(explicit.Tools) != 1 || explicit.Tools[0] != "read" {
268			t.Fatalf("policy-restricted explicit tools = %v, want [read]", explicit.Tools)
269		}
270	})
271}
272
273func TestResidentChildSpecNeverGrantsToolsOutsideParent(t *testing.T) {
274	t.Setenv("ZUT_HOME", t.TempDir())
275	cwd := t.TempDir()
276	runtime := newSubagentRuntime(subagentRuntimeConfig{
277		Args:     Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
278		Root:     t.TempDir(),
279		RepoRoot: cwd,
280		Provider: "openai", Model: "gpt-5.6-sol",
281	})
282	t.Cleanup(func() { _ = runtime.Close(context.Background()) })
283
284	spec, err := runtime.buildResidentChildSpec(context.Background(),
285		tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil})
286	if err != nil {
287		t.Fatal(err)
288	}
289	// bash/write exist in the resolved child runtime but were never offered
290	// by the parent catalogue; the child must not receive them.
291	for _, name := range []string{"bash", "write", "schedule"} {
292		if slices.Contains(spec.Tools, name) {
293			t.Fatalf("child gained tool %q outside parent catalogue: %v", name, spec.Tools)
294		}
295	}
296	explicit, err := runtime.buildResidentChildSpec(context.Background(),
297		tools.ResidentSpawnRequest{
298			Task: "review",
299			Profile: &subagents.Profile{
300				Name: "reviewer", ToolsDeclared: true,
301				Tools: []string{"read", "bash"},
302			},
303		}, core.Registry{"read": nil})
304	if err != nil {
305		t.Fatal(err)
306	}
307	if len(explicit.Tools) != 1 || explicit.Tools[0] != "read" {
308		t.Fatalf("explicit profile gained tool outside parent catalogue: %v", explicit.Tools)
309	}
310}
311
312func TestResidentChildSpecDisabledSkillsDoNotLeak(t *testing.T) {
313	t.Setenv("ZUT_HOME", t.TempDir())
314	cwd := t.TempDir()
315	runtime := newSubagentRuntime(subagentRuntimeConfig{
316		Args:     Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true},
317		Root:     t.TempDir(),
318		RepoRoot: cwd,
319		Provider: "openai", Model: "gpt-5.6-sol",
320	})
321	t.Cleanup(func() { _ = runtime.Close(context.Background()) })
322
323	inherit := false
324	parent := core.Registry{"read": nil, "skill": nil}
325	spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{
326		Task:    "review",
327		Profile: &subagents.Profile{Name: "noskill", InheritSkills: &inherit},
328	}, parent)
329	if err != nil {
330		t.Fatal(err)
331	}
332	if slices.Contains(spec.Tools, "skill") {
333		t.Fatalf("skill-disabled child inherited skill tool: %v", spec.Tools)
334	}
335	resolved, err := Resolve(residentChildArgs(runtime.args, runtime.credentialProvider, spec), false)
336	if err != nil {
337		t.Fatal(err)
338	}
339	if _, ok := resolved.ToolRegistry["skill"]; ok {
340		t.Fatalf("skill-disabled child registry contains skill tool")
341	}
342	if _, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools); err != nil {
343		t.Fatalf("skill-disabled spec failed validation: %v", err)
344	}
345}
346
347// TestInjectToolsRegistersOneSubagentFacade pins the registry contract: the four
348// legacy tools collapse into exactly one facade entry whose non-nil fields are
349// the granted actions.
350func TestInjectToolsRegistersOneSubagentFacade(t *testing.T) {
351	t.Setenv("ZUT_HOME", t.TempDir())
352	cwd := t.TempDir()
353	cwdArgs := func() Args {
354		return Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}
355	}
356	cases := []struct {
357		name                                   string
358		args                                   Args
359		spawn, status, stop, resume, interrupt bool
360	}{
361		{name: "default grants every action", args: cwdArgs(), spawn: true, status: true, stop: true, resume: true, interrupt: true},
362		{name: "resume suffix grants resume only", args: func() Args {
363			args := cwdArgs()
364			args.ToolsSet = true
365			args.Tools = []string{"subagent:resume"}
366			return args
367		}(), resume: true},
368		{name: "interrupt suffix grants interrupt only", args: func() Args {
369			args := cwdArgs()
370			args.ToolsSet = true
371			args.Tools = []string{"subagent:interrupt"}
372			return args
373		}(), interrupt: true},
374	}
375	legacy := []string{"subagent_spawn", "subagent_status", "subagent_stop", "subagent_resume"}
376	for _, tc := range cases {
377		t.Run(tc.name, func(t *testing.T) {
378			runtime := newSubagentRuntime(subagentRuntimeConfig{
379				Args:     tc.args,
380				Root:     t.TempDir(),
381				RepoRoot: cwd,
382				Provider: "openai", Model: "gpt-5.6-sol",
383			})
384			t.Cleanup(func() { _ = runtime.Close(context.Background()) })
385			registry := runtime.InjectTools(core.Registry{})
386			if len(registry) != 1 {
387				t.Fatalf("injected registry has %d entries, want 1", len(registry))
388			}
389			tool, ok := registry[tools.SubagentToolName]
390			if !ok {
391				t.Fatalf("injected registry has no %q entry", tools.SubagentToolName)
392			}
393			facade, ok := tool.(*tools.SubagentTool)
394			if !ok {
395				t.Fatalf("registry entry %q = %T, want *tools.SubagentTool", tools.SubagentToolName, tool)
396			}
397			got := [5]bool{facade.Spawn != nil, facade.Status != nil, facade.Stop != nil, facade.Resume != nil, facade.Interrupt != nil}
398			want := [5]bool{tc.spawn, tc.status, tc.stop, tc.resume, tc.interrupt}
399			if got != want {
400				t.Fatalf("facade actions = %v, want %v", got, want)
401			}
402			for _, name := range legacy {
403				if _, ok := registry[name]; ok {
404					t.Fatalf("injected registry still exposes legacy tool %q", name)
405				}
406			}
407		})
408	}
409}

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