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