v0.25.1 025a25b

resident_tools_test.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"strings"
7	"sync"
8	"testing"
9	"time"
10
11	"github.com/bnema/zut/packages/agent/subagents"
12	"github.com/bnema/zut/packages/core"
13	"github.com/bnema/zut/packages/provider"
14)
15
16func TestResidentToolsUseManagerOnly(t *testing.T) {
17	runs := make(chan string, 2)
18	manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
19		return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil
20	})
21	t.Cleanup(func() { _ = manager.Close(context.Background()) })
22	spawn := &SubagentSpawnTool{
23		ResidentManager: manager,
24		Enabled:         func() bool { return true },
25		DefaultProvider: func() string { return "openai" },
26		DefaultModel:    func() string { return "gpt-5" },
27		BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
28			return subagents.ResidentChildSpec{ID: "resident-tool", SessionID: "child-session", Provider: request.Provider, Model: request.Model, Required: request.Required}, nil
29		},
30	}
31	result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"initial"}`), nil)
32	if err != nil {
33		t.Fatal(err)
34	}
35	if got := toolResultText(t, result); !strings.Contains(got, "owns the delegated scope") || !strings.Contains(got, "Do not repeat that work in the parent") {
36		t.Fatalf("spawn result lacks ownership guidance: %q", got)
37	}
38	if got := <-runs; got != "initial" {
39		t.Fatalf("initial prompt = %q", got)
40	}
41	status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }}
42	result, err = status.Execute(context.Background(), json.RawMessage(`{"agent_id":"resident-tool"}`), nil)
43	if err != nil {
44		t.Fatal(err)
45	}
46	if details, ok := result.Details.(subagentStatusResponse); !ok || details.Agent == nil || details.Agent.State == "" {
47		t.Fatalf("status details = %#v", result.Details)
48	}
49	resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
50	if _, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"resident-tool","prompt":"follow up"}`), nil); err != nil {
51		t.Fatal(err)
52	}
53	if got := <-runs; got != "follow up" {
54		t.Fatalf("follow-up prompt = %q", got)
55	}
56}
57
58func TestSubagentSpawnGuidanceRequiresIndependentOwnership(t *testing.T) {
59	spawn := &SubagentSpawnTool{}
60	for _, want := range []string{"independent sidecar", "keep immediate blockers local", "never duplicate it in the parent", "end or yield the parent turn"} {
61		if got := spawn.Description(); !strings.Contains(got, want) {
62			t.Fatalf("description missing %q: %s", want, got)
63		}
64	}
65	var schema struct {
66		Properties map[string]struct {
67			Description string `json:"description"`
68		} `json:"properties"`
69	}
70	if err := json.Unmarshal(spawn.Schema(), &schema); err != nil {
71		t.Fatal(err)
72	}
73	task := schema.Properties["task"].Description
74	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"} {
75		if !strings.Contains(task, want) {
76			t.Fatalf("task schema missing %q: %s", want, task)
77		}
78	}
79}
80
81func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) {
82	manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
83		return func(context.Context, string) error { return nil }, nil
84	})
85	t.Cleanup(func() { _ = manager.Close(context.Background()) })
86	spawn := &SubagentSpawnTool{
87		ResidentManager: manager,
88		Enabled:         func() bool { return true },
89		DefaultProvider: func() string { return "openai" },
90		DefaultModel:    func() string { return "gpt-5" },
91		BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
92			return subagents.ResidentChildSpec{ID: "wait-for-completion", SessionID: "child-session", Provider: request.Provider, Model: request.Model}, nil
93		},
94	}
95	result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"finish now","wait":1}`), nil)
96	if err != nil || result.IsError {
97		t.Fatalf("Execute = (%#v, %v)", result, err)
98	}
99	if got := toolResultText(t, result); !strings.Contains(got, "state: completed") {
100		t.Fatalf("wait result = %q, want completed state", got)
101	}
102}
103
104func TestResidentSpawnWaitExpiryReportsQueuedChild(t *testing.T) {
105	started := make(chan struct{}, 1)
106	release := make(chan struct{})
107	var releaseOnce sync.Once
108	releaseBlocker := func() { releaseOnce.Do(func() { close(release) }) }
109	finished := make(chan string, 1)
110	manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 1, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
111		return func(ctx context.Context, prompt string) error {
112			switch prompt {
113			case "blocker":
114				started <- struct{}{}
115				select {
116				case <-release:
117					return nil
118				case <-ctx.Done():
119					return ctx.Err()
120				}
121			case "waiter":
122				finished <- prompt
123			}
124			return nil
125		}, nil
126	})
127	t.Cleanup(func() {
128		releaseBlocker()
129		_ = manager.Close(context.Background())
130	})
131	spawn := &SubagentSpawnTool{
132		ResidentManager: manager,
133		Enabled:         func() bool { return true },
134		DefaultProvider: func() string { return "openai" },
135		DefaultModel:    func() string { return "gpt-5" },
136		BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
137			return subagents.ResidentChildSpec{ID: request.Task, SessionID: request.Task, Provider: request.Provider, Model: request.Model}, nil
138		},
139	}
140	if _, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"blocker"}`), nil); err != nil {
141		t.Fatalf("spawn blocker: %v", err)
142	}
143	select {
144	case <-started:
145	case <-time.After(5 * time.Second):
146		t.Fatal("blocker did not start")
147	}
148	result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"waiter","wait":1}`), nil)
149	if err != nil || result.IsError {
150		t.Fatalf("spawn waiter = (%#v, %v)", result, err)
151	}
152	if got := toolResultText(t, result); !strings.Contains(got, "state: queued") || !strings.Contains(got, "wait: timed out after 1 seconds") {
153		t.Fatalf("wait expiry result = %q, want queued timeout", got)
154	}
155	if state, ok := manager.State("waiter"); !ok || state != subagents.ResidentQueued {
156		t.Fatalf("waiter state = %q (found=%t), want queued", state, ok)
157	}
158	releaseBlocker()
159	select {
160	case got := <-finished:
161		if got != "waiter" {
162			t.Fatalf("finished task = %q, want waiter", got)
163		}
164	case <-time.After(5 * time.Second):
165		t.Fatal("queued waiter did not execute")
166	}
167}
168
169func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
170	manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
171		return func(context.Context, string) error { return nil }, nil
172	})
173	t.Cleanup(func() { _ = manager.Close(context.Background()) })
174	built := 0
175	spawn := &SubagentSpawnTool{
176		ResidentManager: manager,
177		Enabled:         func() bool { return true },
178		BuildResidentSpec: func(context.Context, ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
179			built++
180			return subagents.ResidentChildSpec{ID: "should-not-exist", SessionID: "child"}, nil
181		},
182	}
183	for _, raw := range []string{
184		`{"task":"x","unexpected":true}`,
185		`{"task":"x"}{}`,
186		`{"task":"x","model":"gpt-5"}`,
187		`{"task":"x","wait":0}`,
188		`{"task":"x","wait":301}`,
189		`{"task":"x","isolation":"outside"}`,
190	} {
191		result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)
192		if err != nil && !strings.Contains(err.Error(), "invalid args") {
193			t.Fatalf("Execute(%s) error = %v", raw, err)
194		}
195		if err == nil && !result.IsError {
196			t.Fatalf("Execute(%s) = %#v, want protocol error", raw, result)
197		}
198	}
199	if built != 0 || len(manager.Snapshot()) != 0 {
200		t.Fatalf("invalid input reached resident manager: built=%d snapshots=%#v", built, manager.Snapshot())
201	}
202}
203
204func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
205	manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
206		return func(context.Context, string) error { return nil }, nil
207	})
208	t.Cleanup(func() { _ = manager.Close(context.Background()) })
209	profileFast := false
210	var got ResidentSpawnRequest
211	spawn := &SubagentSpawnTool{
212		ResidentManager:  manager,
213		Enabled:          func() bool { return true },
214		DefaultModel:     func() string { return "host-model" },
215		DefaultProvider:  func() string { return "host-provider" },
216		DefaultReasoning: func() string { return "medium" },
217		ResolveSubagent: func(name string) (*subagents.Profile, error) {
218			return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast}, nil
219		},
220		BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
221			got = request
222			return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil
223		},
224	}
225	result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true}`), nil)
226	if err != nil || result.IsError {
227		t.Fatalf("Execute = (%#v, %v)", result, err)
228	}
229	if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode {
230		t.Fatalf("factory request = %#v", got)
231	}
232}
233
234func TestResidentToolsRejectUnknownFields(t *testing.T) {
235	for _, tool := range []interface {
236		Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error)
237	}{
238		&SubagentStatusTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
239		&SubagentStopTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
240		&SubagentResumeTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
241	} {
242		_, err := tool.Execute(context.Background(), json.RawMessage(`{"unknown":true}`), nil)
243		if err == nil || !strings.Contains(err.Error(), "unknown field") {
244			t.Fatalf("unknown field error = %v", err)
245		}
246	}
247}
248
249func toolResultText(t *testing.T, result core.ToolResult) string {
250	t.Helper()
251	if len(result.Content) != 1 {
252		t.Fatalf("content = %#v, want one text block", result.Content)
253	}
254	block, ok := result.Content[0].(provider.TextBlock)
255	if !ok {
256		t.Fatalf("content = %#v, want text block", result.Content)
257	}
258	return block.Text
259}
260
261func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) {
262	_, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab")
263	if ok {
264		t.Fatal("ambiguous resident prefix resolved")
265	}
266}

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