1package modes
2
3import (
4 "context"
5 "encoding/json"
6 "runtime"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/bnema/zut/packages/agent/extproto"
12 "github.com/bnema/zut/packages/agent/tools"
13 "github.com/bnema/zut/packages/core"
14 "github.com/bnema/zut/packages/tui"
15)
16
17func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
18 dialog := newConfirmDialog()
19 btw := &btwDialog{
20 active: true,
21 toolView: &tui.View{Theme: tui.Dark},
22 turns: []btwTurn{{Tools: []tui.ToolCallView{{
23 ID: "call-1", Name: "edit",
24 }}}},
25 editor: tui.NewEditor(""),
26 }
27 i := &Interactive{
28 toolCalls: map[string]*tui.ToolCallView{
29 "call-1": {ID: "call-1", Name: "edit"},
30 },
31 btwDialog: btw,
32 confirmDialog: dialog,
33 dirty: make(chan struct{}, 1),
34 }
35 decision := make(chan core.ConfirmDecision, 1)
36 go func() {
37 decision <- i.ConfirmToolCall(core.ToolCallConfirmation{
38 ID: "call-1",
39 Name: "edit",
40 Summary: "sample.go",
41 Content: "-old\n+new\n",
42 Origin: btwOrigin(0),
43 })
44 }()
45
46 deadline := time.Now().Add(time.Second)
47 for !dialog.Active() && time.Now().Before(deadline) {
48 runtime.Gosched()
49 }
50 if !dialog.Active() {
51 t.Fatal("confirmation dialog did not open")
52 }
53 i.mu.Lock()
54 mainPreview := i.toolCalls["call-1"].Preview
55 i.mu.Unlock()
56 if mainPreview != "" {
57 t.Fatalf("main tool preview = %q, want side-chat origin to leave it untouched", mainPreview)
58 }
59 btw.mu.Lock()
60 btwPreview := btw.turns[0].Tools[0].Preview
61 btw.mu.Unlock()
62 if btwPreview != "-old\n+new\n" {
63 t.Fatalf("/btw tool preview = %q", btwPreview)
64 }
65 combined := strings.Join(renderBtwConfirmation(tui.Dark, 80, btw, dialog), "\n")
66 for _, want := range []string{"btw - side chat", "old", "new", "confirm tool call"} {
67 if !strings.Contains(combined, want) {
68 t.Fatalf("combined /btw confirmation missing %q:\n%s", want, combined)
69 }
70 }
71
72 dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
73 select {
74 case got := <-decision:
75 if !got.Allow {
76 t.Fatalf("confirmation decision = %+v", got)
77 }
78 case <-time.After(time.Second):
79 t.Fatal("confirmation did not return")
80 }
81}
82
83func TestConfirmToolCallRejectsClosedSideChatGeneration(t *testing.T) {
84 i := newConfirmationInputInteractive()
85 i.btwDialog.Open(tui.Dark, i.agent, i.agent.System, i.agent.Model, t.TempDir(), "", false, false, true, nil)
86 i.btwDialog.mu.Lock()
87 origin := btwOrigin(i.btwDialog.generation)
88 i.btwDialog.mu.Unlock()
89 i.btwDialog.Close()
90
91 decision := i.ConfirmToolCall(core.ToolCallConfirmation{
92 ID: "call-1",
93 Name: "edit",
94 Origin: origin,
95 })
96 if decision.Allow || decision.Reason != "side-chat tool call canceled" {
97 t.Fatalf("stale side-chat decision = %+v", decision)
98 }
99 if i.confirmDialog.Active() {
100 t.Fatal("stale side-chat confirmation activated the main dialog")
101 }
102}
103
104func TestBtwCloseCancelsSideChatConfirmation(t *testing.T) {
105 i := newConfirmationInputInteractive()
106 d := i.btwDialog
107 d.mu.Lock()
108 d.active = true
109 d.generation = 1
110 d.turns = []btwTurn{{Tools: []tui.ToolCallView{{ID: "call-1", Name: "edit"}}}}
111 d.mu.Unlock()
112
113 decision := make(chan core.ConfirmDecision, 1)
114 go func() {
115 decision <- i.ConfirmToolCall(core.ToolCallConfirmation{
116 ID: "call-1",
117 Name: "edit",
118 Origin: btwOrigin(1),
119 })
120 }()
121
122 deadline := time.Now().Add(time.Second)
123 for !i.confirmDialog.Active() && time.Now().Before(deadline) {
124 runtime.Gosched()
125 }
126 if !i.confirmDialog.Active() {
127 t.Fatal("side-chat confirmation did not enter the queue")
128 }
129 d.Close()
130
131 select {
132 case got := <-decision:
133 if got.Allow || got.Reason != "side chat closed" {
134 t.Fatalf("close decision = %+v", got)
135 }
136 case <-time.After(time.Second):
137 t.Fatal("closing /btw did not unblock its confirmation")
138 }
139 if i.confirmDialog.Active() {
140 t.Fatal("side-chat confirmation remained active after /btw closed")
141 }
142}
143
144func newConfirmationInputInteractive() *Interactive {
145 agent := core.NewAgent(nil, "test-model", "test system", nil)
146 return NewInteractive(InteractiveConfig{
147 Theme: tui.Dark,
148 Terminal: tui.NewProcTerm(),
149 Agent: agent,
150 Sandbox: tools.NewSandbox("."),
151 })
152}
153
154func enqueueConfirmation(i *Interactive) <-chan core.ConfirmDecision {
155 resp := make(chan core.ConfirmDecision, 1)
156 i.confirmDialog.Enqueue(&confirmRequest{toolName: "bash", preview: "pwd", resp: resp})
157 return resp
158}
159
160func TestConfirmDialogSlashOpensBtwBeforeDecision(t *testing.T) {
161 i := newConfirmationInputInteractive()
162 resp := enqueueConfirmation(i)
163
164 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '/'})
165 if got := i.ed.Value(); got != "/" {
166 t.Fatalf("editor = %q, want slash command input", got)
167 }
168 if i.confirmDialog.Focused() {
169 t.Fatal("confirmation retained focus after slash")
170 }
171
172 i.ed.SetValue("/btw")
173 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
174 if !i.btwDialog.Active() {
175 t.Fatal("/btw did not open while confirmation was pending")
176 }
177 if i.confirmDialog.Focused() {
178 t.Fatal("confirmation stole focus from /btw")
179 }
180 select {
181 case decision := <-resp:
182 t.Fatalf("/btw unexpectedly answered confirmation: %+v", decision)
183 default:
184 }
185
186 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'x'})
187 i.btwDialog.mu.Lock()
188 btwInput := i.btwDialog.editor.Value()
189 i.btwDialog.mu.Unlock()
190 if btwInput != "x" {
191 t.Fatalf("/btw editor = %q, want %q", btwInput, "x")
192 }
193
194 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
195 if i.btwDialog.Active() {
196 t.Fatal("esc did not close /btw")
197 }
198 if !i.confirmDialog.Focused() {
199 t.Fatal("closing /btw did not restore confirmation focus")
200 }
201}
202
203func TestConfirmDialogPrioritizesSideChatToolAndReturnsToSideChat(t *testing.T) {
204 dialog := newConfirmDialog()
205 mainResp := make(chan core.ConfirmDecision, 1)
206 sideResp := make(chan core.ConfirmDecision, 1)
207 dialog.Enqueue(&confirmRequest{toolName: "main", resp: mainResp})
208 dialog.Blur()
209 dialog.Enqueue(&confirmRequest{toolName: "side", resp: sideResp, returnToChild: true})
210
211 dialog.mu.Lock()
212 first := dialog.pending[0].toolName
213 dialog.mu.Unlock()
214 if first != "side" {
215 t.Fatalf("first confirmation = %q, want side-chat tool", first)
216 }
217 if !dialog.Focused() {
218 t.Fatal("side-chat tool confirmation did not take focus")
219 }
220
221 dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
222 select {
223 case decision := <-sideResp:
224 if !decision.Allow {
225 t.Fatalf("side-chat decision = %+v", decision)
226 }
227 default:
228 t.Fatal("side-chat confirmation was not resolved")
229 }
230 if !dialog.Active() {
231 t.Fatal("main confirmation was lost")
232 }
233 if dialog.Focused() {
234 t.Fatal("main confirmation stole focus after side-chat tool decision")
235 }
236 select {
237 case decision := <-mainResp:
238 t.Fatalf("main confirmation was unexpectedly resolved: %+v", decision)
239 default:
240 }
241}
242
243func TestConfirmDialogEscCancelsSlashInputOnly(t *testing.T) {
244 i := newConfirmationInputInteractive()
245 resp := enqueueConfirmation(i)
246
247 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '/'})
248 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'h'})
249 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
250
251 if !i.ed.IsEmpty() {
252 t.Fatalf("editor was not cleared: %q", i.ed.Value())
253 }
254 if !i.confirmDialog.Focused() {
255 t.Fatal("esc did not return focus to confirmation")
256 }
257 select {
258 case decision := <-resp:
259 t.Fatalf("esc from slash input answered confirmation: %+v", decision)
260 default:
261 }
262}
263
264func TestConfirmDialogHelpEscDismissesHelpOnly(t *testing.T) {
265 i := newConfirmationInputInteractive()
266 resp := enqueueConfirmation(i)
267
268 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '/'})
269 i.ed.SetValue("/help")
270 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
271
272 if len(i.helpBlock) == 0 {
273 t.Fatal("/help did not open while confirmation was pending")
274 }
275 if i.confirmDialog.Focused() {
276 t.Fatal("confirmation stole focus from /help")
277 }
278
279 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
280
281 if len(i.helpBlock) != 0 {
282 t.Fatal("esc did not dismiss /help")
283 }
284 if !i.confirmDialog.Focused() {
285 t.Fatal("dismissing /help did not restore confirmation focus")
286 }
287 select {
288 case decision := <-resp:
289 t.Fatalf("esc from /help answered confirmation: %+v", decision)
290 default:
291 }
292}
293
294func TestConfirmDialogExtensionNoteEscDismissesNoteOnly(t *testing.T) {
295 i := newConfirmationInputInteractive()
296 resp := enqueueConfirmation(i)
297
298 i.Display("example", "command output")
299 if len(i.extNotes) == 0 {
300 t.Fatal("extension note was not displayed")
301 }
302 if i.confirmDialog.Focused() {
303 t.Fatal("confirmation retained focus over extension note")
304 }
305
306 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
307
308 if len(i.extNotes) != 0 {
309 t.Fatal("esc did not dismiss extension note")
310 }
311 if !i.confirmDialog.Focused() {
312 t.Fatal("dismissing extension note did not restore confirmation focus")
313 }
314 select {
315 case decision := <-resp:
316 t.Fatalf("esc from extension note answered confirmation: %+v", decision)
317 default:
318 }
319}
320
321func TestConfirmDialogExtensionPanelTakesAndReturnsFocus(t *testing.T) {
322 i := newConfirmationInputInteractive()
323 resp := enqueueConfirmation(i)
324
325 i.OpenPanel("todo", extproto.PanelSpec{ID: "todos", Title: "Todos", Lines: []string{"one"}})
326 if !i.extPanel.Active() {
327 t.Fatal("extension panel did not open")
328 }
329 if i.confirmDialog.Focused() {
330 t.Fatal("confirmation retained focus over extension panel")
331 }
332
333 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc})
334 if i.extPanel.Active() {
335 t.Fatal("esc did not close extension panel")
336 }
337 if !i.confirmDialog.Focused() {
338 t.Fatal("closing extension panel did not restore confirmation focus")
339 }
340 select {
341 case decision := <-resp:
342 t.Fatalf("extension panel answered confirmation: %+v", decision)
343 default:
344 }
345}
346
347func TestCancelAndWaitDrainsPendingConfirmation(t *testing.T) {
348 i := newConfirmationInputInteractive()
349 resp := enqueueConfirmation(i)
350 i.busy = true
351 i.cancelTurn = func() {
352 i.mu.Lock()
353 i.busy = false
354 i.mu.Unlock()
355 }
356
357 i.cancelAndWaitForIdle()
358
359 if i.confirmDialog.Active() {
360 t.Fatal("confirmation remained active after turn cancellation")
361 }
362 select {
363 case decision := <-resp:
364 if decision.Allow || decision.Reason != "turn cancelled" {
365 t.Fatalf("cancellation decision = %+v", decision)
366 }
367 default:
368 t.Fatal("pending confirmation was not resolved")
369 }
370}
371
372func TestConfirmDialogAllowsToolExpansion(t *testing.T) {
373 resp := make(chan core.ConfirmDecision, 1)
374 dialog := newConfirmDialog()
375 dialog.Enqueue(&confirmRequest{
376 toolName: "edit",
377 preview: "large edit",
378 resp: resp,
379 })
380 args, err := json.Marshal(map[string]any{
381 "path": "sample.ts",
382 "edits": []map[string]string{{
383 "oldText": "old",
384 "newText": strings.Repeat("const value = 1\n", 20),
385 }},
386 })
387 if err != nil {
388 t.Fatal(err)
389 }
390 view := &tui.View{
391 ToolCalls: []tui.ToolCallView{{
392 ID: "call-1",
393 Name: "edit",
394 RawJSONBuf: string(args),
395 LivePath: "sample.ts",
396 }},
397 }
398 i := &Interactive{
399 view: view,
400 confirmDialog: dialog,
401 dirty: make(chan struct{}, 1),
402 }
403 if rendered := strings.Join(view.Build(80), "\n"); !strings.Contains(rendered, "ctrl+o to expand") {
404 t.Fatalf("test preview was not initially collapsed:\n%s", rendered)
405 }
406
407 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlO})
408
409 if !i.view.ExpandAll {
410 t.Fatal("ctrl+o did not expand tool previews while confirmation was active")
411 }
412 if rendered := strings.Join(view.Build(80), "\n"); strings.Contains(rendered, "ctrl+o to expand") {
413 t.Fatalf("live edit preview remained collapsed after ctrl+o:\n%s", rendered)
414 }
415 if !dialog.Active() {
416 t.Fatal("ctrl+o closed the confirmation dialog")
417 }
418 select {
419 case decision := <-resp:
420 t.Fatalf("ctrl+o unexpectedly answered confirmation: %+v", decision)
421 default:
422 }
423
424 i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlO})
425 if i.view.ExpandAll {
426 t.Fatal("second ctrl+o did not collapse tool previews during confirmation")
427 }
428 if rendered := strings.Join(view.Build(80), "\n"); !strings.Contains(rendered, "ctrl+o to expand") {
429 t.Fatalf("live edit preview remained expanded after second ctrl+o:\n%s", rendered)
430 }
431 if !dialog.Active() {
432 t.Fatal("second ctrl+o closed the confirmation dialog")
433 }
434}