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}