1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "slices"
7 "strings"
8 "sync"
9 "sync/atomic"
10 "testing"
11 "time"
12
13 "github.com/bnema/zut/packages/core"
14 "github.com/bnema/zut/packages/provider"
15 "github.com/bnema/zut/packages/tui"
16)
17
18type btwToolTestClient struct {
19 mu sync.Mutex
20 requests []provider.Request
21 done chan struct{}
22}
23
24func (c *btwToolTestClient) Name() string { return "btw-test" }
25
26func (c *btwToolTestClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
27 c.mu.Lock()
28 c.requests = append(c.requests, req)
29 call := len(c.requests)
30 c.mu.Unlock()
31
32 out := make(chan provider.Event, 4)
33 go func() {
34 defer close(out)
35 if call == 1 {
36 out <- provider.EventToolStart{ID: "tool-1", Name: "echo"}
37 out <- provider.EventToolArgs{ID: "tool-1", Delta: `{"text":"hello"}`}
38 out <- provider.EventToolEnd{ID: "tool-1"}
39 out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{
40 Role: provider.RoleAssistant,
41 Content: []provider.Content{provider.ToolCallBlock{
42 ID: "tool-1", Name: "echo", Arguments: json.RawMessage(`{"text":"hello"}`),
43 }},
44 }}
45 return
46 }
47 out <- provider.EventTextDelta{Delta: "tool finished"}
48 out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
49 Role: provider.RoleAssistant,
50 Content: []provider.Content{
51 provider.TextBlock{Text: "tool finished"},
52 },
53 }}
54 close(c.done)
55 }()
56 return out, nil
57}
58
59type btwEchoTool struct {
60 runs atomic.Int32
61}
62
63func (t *btwEchoTool) Name() string { return "echo" }
64func (t *btwEchoTool) Description() string { return "echo text" }
65func (t *btwEchoTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
66func (t *btwEchoTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
67 t.runs.Add(1)
68 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "hello"}}}, nil
69}
70
71func TestBtwToolExpansionUsesSideChatView(t *testing.T) {
72 result := strings.Repeat("output line\n", tui.ToolCollapseLines+10)
73 d := &btwDialog{
74 active: true,
75 toolView: &tui.View{Theme: tui.Dark},
76 turns: []btwTurn{{
77 User: "show output",
78 Tools: []tui.ToolCallView{{
79 ID: "tool-1", Name: "bash", Result: result, Done: true,
80 }},
81 }},
82 editor: tui.NewEditor(""),
83 }
84
85 if rendered := strings.Join(d.Render(tui.Dark, 80), "\n"); !strings.Contains(rendered, "ctrl+o to expand") {
86 t.Fatalf("long /btw tool result was not initially collapsed:\n%s", rendered)
87 }
88 d.ToggleToolExpansion()
89 if rendered := strings.Join(d.Render(tui.Dark, 80), "\n"); strings.Contains(rendered, "ctrl+o to expand") {
90 t.Fatalf("long /btw tool result remained collapsed after expansion:\n%s", rendered)
91 }
92}
93
94func TestBtwCtrlOTogglesSideViewDuringConfirmation(t *testing.T) {
95 resp := make(chan core.ConfirmDecision, 1)
96 confirm := newConfirmDialog()
97 confirm.Enqueue(&confirmRequest{toolName: "bash", resp: resp})
98 btw := &btwDialog{active: true, toolView: &tui.View{}}
99 i := &Interactive{
100 view: &tui.View{},
101 btwDialog: btw,
102 confirmDialog: confirm,
103 dirty: make(chan struct{}, 1),
104 }
105
106 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlO})
107 if !btw.toolView.ExpandAll {
108 t.Fatal("ctrl+o did not expand /btw tool output during confirmation")
109 }
110 if i.view.ExpandAll {
111 t.Fatal("ctrl+o expanded the main view instead of the active /btw view")
112 }
113 if !confirm.Active() {
114 t.Fatal("ctrl+o closed the confirmation dialog")
115 }
116 select {
117 case decision := <-resp:
118 t.Fatalf("ctrl+o unexpectedly answered confirmation: %+v", decision)
119 default:
120 }
121}
122
123func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) {
124 client := &btwToolTestClient{done: make(chan struct{})}
125 tool := &btwEchoTool{}
126 main := core.NewAgent(client, "main-model", "main-system", core.Registry{"echo": tool})
127 main.SetMessages([]provider.Message{{
128 Role: provider.RoleUser,
129 Content: []provider.Content{provider.TextBlock{Text: "frozen context"}},
130 }})
131 var policyCalls atomic.Int32
132 var policyOrigin string
133 main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
134 policyCalls.Add(1)
135 policyOrigin = call.Origin
136 return true, "", nil
137 }
138
139 d := newBtwDialog()
140 d.Open(tui.Theme{}, main, main.System, main.Model, t.TempDir(), "use the echo tool", false, false, true, nil)
141
142 select {
143 case <-client.done:
144 case <-time.After(2 * time.Second):
145 t.Fatal("timed out waiting for /btw tool turn")
146 }
147 deadline := time.Now().Add(2 * time.Second)
148 for d.Loading() && time.Now().Before(deadline) {
149 time.Sleep(time.Millisecond)
150 }
151 if d.Loading() {
152 t.Fatal("/btw remained loading after the agent turn completed")
153 }
154
155 if got := tool.runs.Load(); got != 1 {
156 t.Fatalf("tool executions = %d, want 1", got)
157 }
158 if got := policyCalls.Load(); got != 1 {
159 t.Fatalf("main agent tool-policy calls = %d, want 1", got)
160 }
161 if policyOrigin != btwOrigin(1) {
162 t.Fatalf("side-chat tool policy origin = %q, want %q", policyOrigin, btwOrigin(1))
163 }
164
165 client.mu.Lock()
166 requests := append([]provider.Request(nil), client.requests...)
167 client.mu.Unlock()
168 if len(requests) != 2 {
169 t.Fatalf("provider requests = %d, want 2", len(requests))
170 }
171 if len(requests[0].Tools) != 1 || requests[0].Tools[0].Name != "echo" {
172 t.Fatalf("first /btw request tools = %#v, want echo", requests[0].Tools)
173 }
174 if got := len(main.Messages()); got != 1 {
175 t.Fatalf("main transcript messages = %d, want frozen length 1", got)
176 }
177
178 d.mu.Lock()
179 if len(d.turns) != 1 {
180 t.Fatalf("side-chat turns = %d, want 1", len(d.turns))
181 }
182 turn := d.turns[0]
183 if turn.Assistant != "tool finished" {
184 t.Fatalf("assistant = %q, want %q", turn.Assistant, "tool finished")
185 }
186 if len(turn.Tools) != 1 || !turn.Tools[0].Done || turn.Tools[0].Error {
187 t.Fatalf("rendered tool state = %#v, want one successful completed tool", turn.Tools)
188 }
189 d.mu.Unlock()
190
191 const paneWidth = 62
192 rows := d.Render(tui.Theme{}, paneWidth)
193 rendered := strings.Join(rows, "\n")
194 if !strings.Contains(rendered, "┌─") || !strings.Contains(rendered, "echo") || !strings.Contains(rendered, "└─") {
195 t.Fatalf("/btw did not use the standard bordered tool renderer:\n%s", rendered)
196 }
197 toolRow := -1
198 for idx, row := range rows {
199 if strings.Contains(row, "┌─") {
200 toolRow = idx
201 break
202 }
203 }
204 if toolRow < 1 || rows[toolRow-1] != "" {
205 t.Fatalf("/btw tool box is not separated from the user bubble by a blank row:\n%s", rendered)
206 }
207 if cursorRow, _ := d.CursorPos(paneWidth); cursorRow != len(rows)-3 {
208 t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3)
209 }
210}
211
212func TestBtwActivityRendersFactDrivenLabel(t *testing.T) {
213 d := &btwDialog{
214 active: true,
215 loading: true,
216 theme: tui.Dark,
217 toolView: &tui.View{Theme: tui.Dark},
218 spin: newSpinner(tui.Dark),
219 activity: newAgentActivity("anthropic", "claude-test"),
220 }
221 d.spin.Start()
222 d.activity.apply(core.EvRequestStarted{Provider: "anthropic", Model: "claude-test", Scope: core.RetryScopeProvider, Attempt: 1, MaxAttempts: 3})
223
224 rendered := strings.Join(d.Render(tui.Dark, 80), "\n")
225 if !strings.Contains(rendered, "Sending request to anthropic") {
226 t.Fatalf("/btw activity = %q, want factual request label", rendered)
227 }
228 if strings.Contains(rendered, "reticulating splines") || strings.Contains(rendered, "thinking") {
229 t.Fatalf("/btw activity retained random spinner wording: %q", rendered)
230 }
231}
232
233func TestBtwDialogIgnoresCallbacksFromPreviousGeneration(t *testing.T) {
234 d := &btwDialog{
235 active: true,
236 loading: true,
237 generation: 2,
238 turns: []btwTurn{{User: "new question"}},
239 activity: newAgentActivity("provider", "model"),
240 }
241
242 d.handleAgentEvent(0, 1, core.EvTextDelta{Delta: "stale response"})
243 d.completeTurn(0, 1, "stale error")
244
245 d.mu.Lock()
246 defer d.mu.Unlock()
247 if d.turns[0].Assistant != "" || d.turns[0].Err != "" {
248 t.Fatalf("stale callback changed reopened turn: %#v", d.turns[0])
249 }
250 if !d.loading {
251 t.Fatal("stale completion cleared the reopened dialog loading state")
252 }
253}
254
255func TestBtwToolConfirmationRoutesThroughSideAgent(t *testing.T) {
256 client := &btwToolTestClient{done: make(chan struct{})}
257 tool := &btwEchoTool{}
258 main := core.NewAgent(client, "main-model", "main-system", core.Registry{"echo": tool})
259 d := newBtwDialog()
260 i := &Interactive{
261 btwDialog: d,
262 confirmDialog: newConfirmDialog(),
263 dirty: make(chan struct{}, 1),
264 }
265 gate := core.NewConfirmGate(i)
266 mainResp := make(chan core.ConfirmDecision, 1)
267 i.confirmDialog.Enqueue(&confirmRequest{toolName: "main", resp: mainResp})
268 i.confirmDialog.Blur()
269 main.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
270 return gate.CheckToolCall(core.ToolCallConfirmation{
271 ID: call.ID,
272 Name: call.Name,
273 Summary: "hello",
274 Origin: call.Origin,
275 })
276 }
277
278 d.Open(tui.Dark, main, main.System, main.Model, t.TempDir(), "use the echo tool", false, false, true, nil)
279 sideConfirmationFirst := func() bool {
280 i.confirmDialog.mu.Lock()
281 defer i.confirmDialog.mu.Unlock()
282 return len(i.confirmDialog.pending) > 0 && i.confirmDialog.pending[0].toolName == "echo"
283 }
284 deadline := time.Now().Add(2 * time.Second)
285 for !sideConfirmationFirst() && time.Now().Before(deadline) {
286 time.Sleep(time.Millisecond)
287 }
288 if !sideConfirmationFirst() {
289 t.Fatal("side-agent tool confirmation did not reach the front of the interactive confirmation queue")
290 }
291 if !i.confirmDialog.Focused() {
292 t.Fatal("side-agent confirmation did not focus the prioritized dialog")
293 }
294
295 d.mu.Lock()
296 if len(d.turns) != 1 || len(d.turns[0].Tools) != 1 {
297 d.mu.Unlock()
298 t.Fatalf("side-agent tool state = %#v, want one live tool", d.turns)
299 }
300 if got, want := d.turns[0].Tools[0].Args, "hello"; got != want {
301 d.mu.Unlock()
302 t.Fatalf("side-agent confirmation summary = %q, want %q", got, want)
303 }
304 if got, want := d.activity.label(), "Waiting for approval: echo"; got != want {
305 d.mu.Unlock()
306 t.Fatalf("side-agent activity = %q, want %q", got, want)
307 }
308 d.mu.Unlock()
309
310 i.confirmDialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
311 select {
312 case <-client.done:
313 case <-time.After(2 * time.Second):
314 t.Fatal("side-agent did not continue after confirmation")
315 }
316 if got := tool.runs.Load(); got != 1 {
317 t.Fatalf("tool executions = %d, want 1 after confirmation", got)
318 }
319 if i.confirmDialog.Focused() {
320 t.Fatal("main confirmation regained focus instead of returning input to /btw")
321 }
322 i.confirmDialog.CancelAll("test complete")
323 select {
324 case <-mainResp:
325 default:
326 t.Fatal("main confirmation was not retained after resolving the side-chat call")
327 }
328}
329
330// TestNewBtwAgentCarriesMainPlan guards GO-003: the /btw side agent shares the
331// main registry (which contains plan) and the main transcript, so it must also
332// carry the main plan. Otherwise `show` answers empty and an indexed update
333// fails on a checklist the side transcript displays.
334func TestNewBtwAgentCarriesMainPlan(t *testing.T) {
335 main := core.NewAgent(nil, "model", "system", nil)
336 want := []core.PlanStep{
337 {Step: "one", Status: core.PlanPending},
338 {Step: "two", Status: core.PlanInProgress},
339 }
340 main.SetPlan(want)
341
342 side := newBtwAgent(main, "system", "model", "btw")
343 if got := side.CurrentPlan(); !slices.Equal(got, want) {
344 t.Fatalf("side agent plan = %#v, want %#v", got, want)
345 }
346
347 // The side plan must be an independent copy.
348 got := side.CurrentPlan()
349 got[0].Step = "mutated"
350 if again := main.CurrentPlan(); again[0].Step != "one" {
351 t.Fatalf("side plan aliases the main plan: %#v", again)
352 }
353}