v0.46.0 ea0e112

subagent_steer_test.go RawHistoryFolder

1package tools
2
3import (
4	"context"
5	"encoding/json"
6	"sync"
7	"testing"
8	"time"
9
10	"github.com/bnema/zut/packages/agent/subagents"
11)
12
13// steerRuntime blocks its turn until the first steer arrives, then finishes.
14type steerRuntime struct {
15	started chan struct{}
16	steered chan string
17
18	mu      sync.Mutex
19	running bool
20}
21
22func (r *steerRuntime) Run(ctx context.Context, _ string) error {
23	r.mu.Lock()
24	r.running = true
25	r.mu.Unlock()
26	defer func() { r.mu.Lock(); r.running = false; r.mu.Unlock() }()
27	r.started <- struct{}{}
28	select {
29	case <-ctx.Done():
30		return ctx.Err()
31	case <-r.steered:
32		return nil
33	}
34}
35
36func (r *steerRuntime) Steer(prompt string) bool {
37	r.mu.Lock()
38	defer r.mu.Unlock()
39	if !r.running {
40		return false
41	}
42	r.steered <- prompt
43	return true
44}
45
46func (r *steerRuntime) PendingSteers() int    { return 0 }
47func (r *steerRuntime) DrainSteers() []string { return nil }
48
49// Resume steers a running child by default, and its wait resolves on the
50// running turn that received the follow-up.
51func TestResidentResumeSteersRunningChildAndWaitsOnItsTurn(t *testing.T) {
52	runtime := &steerRuntime{started: make(chan struct{}, 1), steered: make(chan string, 1)}
53	manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
54		return runtime, nil
55	})
56	t.Cleanup(func() { _ = manager.Close(context.Background()) })
57	if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "steer-tool", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "investigate"); err != nil {
58		t.Fatal(err)
59	}
60	select {
61	case <-runtime.started:
62	case <-time.After(5 * time.Second):
63		t.Fatal("turn did not start")
64	}
65	facade := &SubagentTool{Resume: &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}}
66	result, err := facade.Execute(t.Context(), json.RawMessage(`{"action":"resume","agent_id":"steer-tool","prompt":"report now","wait":5}`), nil)
67	if err != nil || result.IsError {
68		t.Fatalf("resume = (%#v, %v)", result, err)
69	}
70	response, ok := result.Details.(subagentResumeResponse)
71	if !ok || response.Delivery != "steered" || response.Wait == nil || response.Wait.TimedOut || response.Wait.Status != string(subagents.ResidentCompleted) {
72		t.Fatalf("response = %#v", result.Details)
73	}
74}
75
76func TestResidentResumeRejectsUnknownMode(t *testing.T) {
77	manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
78		return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
79	})
80	t.Cleanup(func() { _ = manager.Close(context.Background()) })
81	resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
82	result, err := resume.Execute(t.Context(), json.RawMessage(`{"agent_id":"x","prompt":"p","mode":"now"}`), nil)
83	if err != nil || !result.IsError {
84		t.Fatalf("result = (%#v, %v), want mode error", result, err)
85	}
86}
87
88func TestResidentInterruptToolRequiresRunningTurn(t *testing.T) {
89	manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
90		return subagents.ResidentTurnRunner(func(context.Context, string) error { return nil }), nil
91	})
92	t.Cleanup(func() { _ = manager.Close(context.Background()) })
93	done, cancel := manager.WatchCompletion("idle-child", "initial")
94	defer cancel()
95	if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "idle-child", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "task"); err != nil {
96		t.Fatal(err)
97	}
98	<-done
99	interrupt := &SubagentInterruptTool{ResidentManager: manager, Enabled: func() bool { return true }}
100	result, err := interrupt.Execute(t.Context(), json.RawMessage(`{"agent_id":"idle-child"}`), nil)
101	if err != nil || !result.IsError {
102		t.Fatalf("result = (%#v, %v), want no-running-turn error", result, err)
103	}
104}
105
106// The facade dispatches interrupt to a running child and keeps it live.
107func TestSubagentFacadeInterruptsRunningTurn(t *testing.T) {
108	started := make(chan struct{}, 1)
109	manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentRuntime, error) {
110		return subagents.ResidentTurnRunner(func(ctx context.Context, _ string) error {
111			started <- struct{}{}
112			<-ctx.Done()
113			return ctx.Err()
114		}), nil
115	})
116	t.Cleanup(func() { _ = manager.Close(context.Background()) })
117	done, cancel := manager.WatchCompletion("running-child", "initial")
118	defer cancel()
119	if _, err := manager.Spawn(t.Context(), subagents.ResidentChildSpec{ID: "running-child", InitialTurnID: "initial", SessionID: "session", Provider: "openai", Model: "test"}, "task"); err != nil {
120		t.Fatal(err)
121	}
122	<-started
123	facade := &SubagentTool{Interrupt: &SubagentInterruptTool{ResidentManager: manager, Enabled: func() bool { return true }}}
124	result, err := facade.Execute(t.Context(), json.RawMessage(`{"action":"interrupt","agent_id":"running-child"}`), nil)
125	if err != nil || result.IsError {
126		t.Fatalf("interrupt = (%#v, %v)", result, err)
127	}
128	if response, ok := result.Details.(subagentActionResponse); !ok || response.Action != "interrupt_requested" {
129		t.Fatalf("response = %#v", result.Details)
130	}
131	select {
132	case completion := <-done:
133		if completion.Completion().Status != string(subagents.ResidentInterrupted) {
134			t.Fatalf("completion = %#v", completion)
135		}
136	case <-time.After(5 * time.Second):
137		t.Fatal("interrupted turn did not complete")
138	}
139	if manager.Get("running-child") == nil {
140		t.Fatal("interrupt removed the live child")
141	}
142	result, err = facade.Execute(t.Context(), json.RawMessage(`{"action":"interrupt","agent_id":"running-child","mode":"steer"}`), nil)
143	if err != nil || !result.IsError {
144		t.Fatalf("mode on interrupt = (%#v, %v), want foreign-field error", result, err)
145	}
146}

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