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