main 4509ec7

subagent_delivery_test.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"strings"
9	"sync"
10	"testing"
11	"time"
12
13	"github.com/bnema/zut/packages/agent/subagents"
14	"github.com/bnema/zut/packages/core"
15	"github.com/bnema/zut/packages/provider"
16)
17
18// A waited turn still reaches the host, but its report must not also be
19// repeated in the tool result. Without a host, the tool remains self-contained.
20func TestResidentWaitResultDelivery(t *testing.T) {
21	for _, action := range []string{"spawn", "resume"} {
22		for _, route := range []string{"inline", "accepted", "rejected"} {
23			host := route == "accepted"
24			for _, turnErr := range []error{nil, errors.New("synthetic failure"), context.Canceled} {
25				t.Run(fmt.Sprintf("%s/host=%s/%s", action, route, completionStatus(turnErr)), func(t *testing.T) {
26					const summary = "synthetic worker report"
27					manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
28						return subagents.ResidentTurnRunner(func(_ context.Context, prompt string) error {
29							if prompt == "initial" {
30								return nil
31							}
32							if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: summary}}}}); err != nil {
33								return err
34							}
35							return turnErr
36						}), nil
37					})
38					t.Cleanup(func() { _ = manager.Close(context.Background()) })
39					facade := newDeliveryFacade(t, manager, action)
40					updates := make(chan subagents.ResidentCompletion, 2)
41					if route != "inline" {
42						manager.SetCompletionObserver(func(c subagents.ResidentCompletion) bool {
43							if host {
44								updates <- c
45							}
46							return host
47						})
48					}
49
50					args := json.RawMessage(`{"action":"spawn","task":"report","wait":5}`)
51					if action == "resume" {
52						args = json.RawMessage(`{"action":"resume","agent_id":"report-child","prompt":"report","mode":"queue","wait":5}`)
53					}
54					result, err := facade.Execute(t.Context(), args, nil)
55					if err != nil || result.IsError {
56						t.Fatalf("Execute = (%#v, %v)", result, err)
57					}
58					text := toolResultText(t, result)
59					if strings.Contains(text, summary) == host {
60						t.Fatalf("host=%t, tool report = %q", host, text)
61					}
62					if !strings.Contains(text, completionStatus(turnErr)) {
63						t.Fatalf("missing terminal status in %q", text)
64					}
65					if host {
66						if !strings.Contains(text, "host_update") {
67							t.Fatalf("missing delivery indication in %q", text)
68						}
69						select {
70						case c := <-updates:
71							if c.Summary != summary || !errors.Is(c.Err, turnErr) {
72								t.Fatalf("host report = %#v", c)
73							}
74						case <-time.After(5 * time.Second):
75							t.Fatal("host lost the report")
76						}
77						select {
78						case c := <-updates:
79							t.Fatalf("duplicate host report: %#v", c)
80						default:
81						}
82					}
83				})
84			}
85		}
86	}
87}
88
89// Both action tests share the same durable child setup. Resume needs an idle
90// initial turn before the tested follow-up; observers are installed afterwards.
91func newDeliveryFacade(t *testing.T, manager *subagents.ResidentManager, action string) *SubagentTool {
92	t.Helper()
93	spec := subagents.ResidentChildSpec{ID: "report-child", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}
94	if action == "resume" {
95		done, cancel := manager.WatchCompletion(spec.ID, spec.InitialTurnID)
96		defer cancel()
97		if _, err := manager.Spawn(t.Context(), spec, "initial"); err != nil {
98			t.Fatal(err)
99		}
100		select {
101		case <-done:
102		case <-time.After(5 * time.Second):
103			t.Fatal("initial turn did not finish")
104		}
105	}
106	return &SubagentTool{
107		Spawn: &SubagentSpawnTool{
108			ResidentManager: manager, Enabled: func() bool { return true },
109			BuildResidentSpec: func(context.Context, ResidentSpawnRequest) (subagents.ResidentChildSpec, error) { return spec, nil },
110		},
111		Resume: &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }},
112	}
113}
114
115func completionStatus(err error) string {
116	if errors.Is(err, context.Canceled) {
117		return "interrupted"
118	}
119	if err != nil {
120		return "failed"
121	}
122	return "completed"
123}
124
125// Abandoning a bounded wait never removes the host's completion route.
126func TestResidentAbandonedWaitPreservesHostReport(t *testing.T) {
127	for _, action := range []string{"spawn", "resume"} {
128		for _, cancelWait := range []bool{false, true} {
129			t.Run(fmt.Sprintf("%s/cancel=%t", action, cancelWait), func(t *testing.T) {
130				started := make(chan struct{}, 1)
131				release := make(chan struct{})
132				var once sync.Once
133				unblock := func() { once.Do(func() { close(release) }) }
134				manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
135					return subagents.ResidentTurnRunner(func(ctx context.Context, prompt string) error {
136						if prompt == "initial" {
137							return nil
138						}
139						started <- struct{}{}
140						select {
141						case <-release:
142						case <-ctx.Done():
143							return ctx.Err()
144						}
145						return journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "late report"}}}})
146					}), nil
147				})
148				t.Cleanup(func() { unblock(); _ = manager.Close(context.Background()) })
149				facade := newDeliveryFacade(t, manager, action)
150				updates := make(chan subagents.ResidentCompletion, 2)
151				manager.SetCompletionObserver(func(c subagents.ResidentCompletion) bool { updates <- c; return true })
152
153				seconds := 1
154				if cancelWait {
155					seconds = 300
156				}
157				args := fmt.Sprintf(`{"action":"spawn","task":"report","wait":%d}`, seconds)
158				if action == "resume" {
159					args = fmt.Sprintf(`{"action":"resume","agent_id":"report-child","prompt":"report","mode":"queue","wait":%d}`, seconds)
160				}
161				ctx, cancel := context.WithCancel(t.Context())
162				defer cancel()
163				type toolOutcome struct {
164					result core.ToolResult
165					err    error
166				}
167				done := make(chan toolOutcome, 1)
168				go func() {
169					result, err := facade.Execute(ctx, json.RawMessage(args), nil)
170					done <- toolOutcome{result, err}
171				}()
172				select {
173				case <-started:
174				case <-time.After(5 * time.Second):
175					t.Fatal("turn did not start")
176				}
177				if cancelWait {
178					cancel()
179				}
180				select {
181				case outcome := <-done:
182					if cancelWait {
183						if !errors.Is(outcome.err, context.Canceled) {
184							t.Fatalf("wait error = %v", outcome.err)
185						}
186					} else {
187						if outcome.err != nil || outcome.result.IsError {
188							t.Fatalf("wait outcome = %#v", outcome)
189						}
190						if action == "resume" {
191							response, ok := outcome.result.Details.(subagentResumeResponse)
192							if !ok || response.Wait == nil || !response.Wait.TimedOut {
193								t.Fatalf("wait outcome = %#v", outcome)
194							}
195						} else if !strings.Contains(toolResultText(t, outcome.result), "timed out") {
196							t.Fatalf("wait outcome = %#v", outcome)
197						}
198					}
199				case <-time.After(5 * time.Second):
200					t.Fatal("wait did not return")
201				}
202				unblock()
203				select {
204				case c := <-updates:
205					if c.Err != nil || c.Summary != "late report" {
206						t.Fatalf("late completion = %#v", c)
207					}
208				case <-time.After(5 * time.Second):
209					t.Fatal("late host report was lost")
210				}
211			})
212		}
213	}
214}
215
216func TestSubagentGuidanceRejectsResumeAsWait(t *testing.T) {
217	facade := &SubagentTool{}
218	for _, text := range []string{facade.Description(), string(facade.Schema())} {
219		if !strings.Contains(text, "Do not use resume solely to wait") {
220			t.Fatalf("missing resume-as-wait warning: %s", text)
221		}
222	}
223}

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