1package modes
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/bnema/zut/packages/core"
9 "github.com/bnema/zut/packages/provider"
10 "github.com/bnema/zut/packages/tui"
11)
12
13func applySessionTreeLoads(d *sessionTreeDialog, events <-chan sessionTreeLoadEvent) error {
14 var loadErr error
15 for event := range events {
16 if err := d.ApplyLoad(event); err != nil {
17 loadErr = err
18 }
19 }
20 d.ApplyLoadClosed()
21 return loadErr
22}
23
24func TestInteractiveCtrlCExitMarker(t *testing.T) {
25 i := NewInteractive(InteractiveConfig{})
26 key := tui.Key{Kind: tui.KeyCtrlC}
27 if i.handleKey(context.Background(), key) {
28 t.Fatal("first Ctrl+C exited interactive mode")
29 }
30 if i.ExitedViaCtrlC() {
31 t.Fatal("first Ctrl+C marked an exit")
32 }
33 if !i.handleKey(context.Background(), key) {
34 t.Fatal("second Ctrl+C did not exit interactive mode")
35 }
36 if !i.ExitedViaCtrlC() {
37 t.Fatal("Ctrl+C exit was not marked")
38 }
39}
40
41func TestDoubleEscapeTrackerWindowAndReset(t *testing.T) {
42 base := time.Unix(100, 0)
43 var tracker doubleEscapeTracker
44
45 tracker.Arm(base)
46 if !tracker.Consume(base.Add(sessionTreeEscapeWindow)) {
47 t.Fatal("tap at the 500ms boundary should count")
48 }
49 if tracker.Consume(base.Add(sessionTreeEscapeWindow)) {
50 t.Fatal("a consumed gesture must not trigger again")
51 }
52
53 tracker.Arm(base)
54 if tracker.Consume(base.Add(sessionTreeEscapeWindow + time.Nanosecond)) {
55 t.Fatal("tap after the gesture window should not count")
56 }
57 tracker.Arm(base)
58 if tracker.Consume(base.Add(-time.Nanosecond)) {
59 t.Fatal("a clock moving backwards must reset the gesture")
60 }
61
62 // Zero is a valid value for a deterministic test clock; it must not be
63 // confused with the tracker's unarmed sentinel.
64 tracker.Arm(time.Time{})
65 if !tracker.Consume(time.Time{}.Add(100 * time.Millisecond)) {
66 t.Fatal("zero-valued test timestamp should still arm the gesture")
67 }
68}
69
70func TestUnmodifiedEscapeRequiresBareParsedKey(t *testing.T) {
71 bare := tui.Key{Kind: tui.KeyEsc}
72 for _, key := range []tui.Key{
73 {Kind: tui.KeyEsc, Alt: true},
74 {Kind: tui.KeyEsc, Ctrl: true},
75 {Kind: tui.KeyEsc, Shift: true},
76 {Kind: tui.KeyEsc, Super: true},
77 } {
78 if isUnmodifiedEscape(key) {
79 t.Fatalf("modified key %#v was treated as a bare Escape", key)
80 }
81 }
82 if !isUnmodifiedEscape(bare) {
83 t.Fatal("bare parsed Escape was not recognized")
84 }
85}
86
87func TestSessionTreeSelectionRestoresCompleteUserDraft(t *testing.T) {
88 image := provider.ImageBlock{MimeType: "image/png", Data: []byte("png"), ThoughtSignature: "sig"}
89 msgs := []provider.Message{
90 {
91 Role: provider.RoleUser,
92 Content: []provider.Content{
93 provider.TextBlock{Text: "before"},
94 image,
95 provider.TextBlock{Text: "after"},
96 },
97 },
98 }
99 target := sessionTreeTarget{
100 EffectiveIndex: 0,
101 SelectionBoundary: 0,
102 Role: provider.RoleUser,
103 UserDraft: "fallback must not replace message text",
104 }
105
106 got, err := sessionTreeSelection(msgs, target)
107 if err != nil {
108 t.Fatalf("sessionTreeSelection: %v", err)
109 }
110 if got.upTo != 0 || !got.restoreDraft {
111 t.Fatalf("selection = %#v, want a pre-user draft boundary", got)
112 }
113 if got.draftText != "before\nafter [clipboard image #1] " {
114 t.Fatalf("draft text = %q", got.draftText)
115 }
116 if len(got.images) != 1 || got.images[0].Marker != "[clipboard image #1]" {
117 t.Fatalf("restored images = %#v", got.images)
118 }
119 if string(got.images[0].Image.Data) != "png" || got.images[0].Image.MimeType != image.MimeType || got.images[0].Image.ThoughtSignature != image.ThoughtSignature {
120 t.Fatalf("restored image = %#v", got.images[0].Image)
121 }
122}
123
124func TestSessionTreeSelectionKeepsToolExchangeAtomic(t *testing.T) {
125 msgs := []provider.Message{
126 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "ask"}}},
127 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "read"}}},
128 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1"}}},
129 {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}},
130 }
131
132 got, err := sessionTreeSelection(msgs, sessionTreeTarget{
133 EffectiveIndex: 1,
134 SelectionBoundary: 2,
135 Role: provider.RoleAssistant,
136 })
137 if err != nil {
138 t.Fatalf("tool-call selection: %v", err)
139 }
140 if got.upTo != 3 {
141 t.Fatalf("tool-call boundary = %d, want 3", got.upTo)
142 }
143
144 got, err = sessionTreeSelection(msgs, sessionTreeTarget{
145 EffectiveIndex: 2,
146 SelectionBoundary: 3,
147 Role: provider.RoleTool,
148 })
149 if err != nil {
150 t.Fatalf("tool-result selection: %v", err)
151 }
152 if got.upTo != 3 {
153 t.Fatalf("tool-result boundary = %d, want 3", got.upTo)
154 }
155}
156
157func TestSessionTreeSelectionAllowsDeferredToolActivation(t *testing.T) {
158 msgs := []provider.Message{
159 {
160 Role: provider.RoleTool,
161 AddedToolNames: []string{"lookup_weather"},
162 Content: []provider.Content{provider.ToolResultBlock{CallID: "call-1"}},
163 },
164 {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "next"}}},
165 }
166 got, err := sessionTreeSelection(msgs, sessionTreeTarget{
167 EffectiveIndex: 0,
168 SelectionBoundary: 1,
169 Role: provider.RoleTool,
170 })
171 if err != nil {
172 t.Fatalf("deferred-tool selection: %v", err)
173 }
174 if got.upTo != 1 {
175 t.Fatalf("deferred-tool boundary = %d, want 1", got.upTo)
176 }
177}
178
179func TestSessionTreeGateFailureDoesNotFlushOrOpenOverlay(t *testing.T) {
180 i := NewInteractive(InteractiveConfig{})
181 flushes := 0
182 i.cfg.FlushSession = func() { flushes++ }
183
184 i.doSessionTree()
185 if flushes != 0 {
186 t.Fatalf("FlushSession called %d times on a failed gate", flushes)
187 }
188 if i.sessionTreeDialog.Active() {
189 t.Fatal("session tree overlay opened after a failed gate")
190 }
191}
192
193func TestSessionTreeGateRejectsModelRefreshInFlight(t *testing.T) {
194 root := t.TempDir()
195 cwd := "/workspace/model-refresh-gate"
196 session, err := core.NewSession(root, cwd, "test", "model", "test")
197 if err != nil {
198 t.Fatal(err)
199 }
200 message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "message"}}}
201 if err := session.AppendMessage(message); err != nil {
202 t.Fatal(err)
203 }
204 path := session.Path
205 if err := session.Close(); err != nil {
206 t.Fatal(err)
207 }
208 i := NewInteractive(InteractiveConfig{
209 Agent: core.NewAgent(nil, "model", "", nil),
210 CWD: cwd,
211 SessionsRoot: root,
212 CurrentSessionPath: func() string { return path },
213 LoadSession: func(string) error { return nil },
214 })
215 i.agent.SetMessages([]provider.Message{message})
216 i.mu.Lock()
217 i.modelRefreshing = true
218 i.mu.Unlock()
219 if i.canOpenSessionTree() {
220 t.Fatal("tree gate opened while model refresh was in flight")
221 }
222}
223
224func TestSessionTreeSelectionGateAllowsActiveTree(t *testing.T) {
225 root := t.TempDir()
226 cwd := "/workspace/selection-gate"
227 session, err := core.NewSession(root, cwd, "test", "model", "test")
228 if err != nil {
229 t.Fatal(err)
230 }
231 message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "message"}}}
232 if err := session.AppendMessage(message); err != nil {
233 t.Fatal(err)
234 }
235 path := session.Path
236 if err := session.Close(); err != nil {
237 t.Fatal(err)
238 }
239 ag := core.NewAgent(nil, "model", "", nil)
240 ag.SetMessages([]provider.Message{message})
241 i := NewInteractive(InteractiveConfig{
242 Agent: ag,
243 CWD: cwd,
244 SessionsRoot: root,
245 CurrentSessionPath: func() string {
246 return path
247 },
248 LoadSession: func(string) error { return nil },
249 })
250 i.sessionTreeDialog.active = true
251 if i.canOpenSessionTree() {
252 t.Fatal("open gate accepted an already-active tree")
253 }
254 if !i.canCommitSessionTreeSelection() {
255 t.Fatal("selection gate rejected the active tree dialog")
256 }
257}
258
259func TestDoubleEscapeOpensReadableTreeThroughSharedGate(t *testing.T) {
260 root := t.TempDir()
261 cwd := "/workspace/double-escape"
262 session, err := core.NewSession(root, cwd, "test", "model", "test")
263 if err != nil {
264 t.Fatal(err)
265 }
266 message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
267 if err := session.AppendMessage(message); err != nil {
268 t.Fatal(err)
269 }
270 path := session.Path
271 if err := session.Close(); err != nil {
272 t.Fatal(err)
273 }
274
275 ag := core.NewAgent(nil, "model", "", nil)
276 ag.SetMessages([]provider.Message{message})
277 now := time.Unix(200, 0)
278 flushes := 0
279 i := NewInteractive(InteractiveConfig{
280 Agent: ag,
281 CWD: cwd,
282 SessionsRoot: root,
283 CurrentSessionPath: func() string {
284 return path
285 },
286 LoadSession: func(string) error { return nil },
287 FlushSession: func() {
288 flushes++
289 },
290 })
291 i.clock = func() time.Time { return now }
292
293 if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
294 t.Fatal("first Escape exited")
295 }
296 if i.sessionTreeDialog.Active() || flushes != 0 {
297 t.Fatalf("first Escape opened/flushed: active=%v flushes=%d", i.sessionTreeDialog.Active(), flushes)
298 }
299 now = now.Add(499 * time.Millisecond)
300 if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
301 t.Fatal("second Escape exited")
302 }
303 if !i.sessionTreeDialog.Active() {
304 t.Fatal("session tree did not open before its background load")
305 }
306 if err := applySessionTreeLoads(i.sessionTreeDialog, i.sessionTreeLoads); err != nil {
307 t.Fatalf("background session tree load: %v", err)
308 }
309 if flushes != 1 {
310 t.Fatalf("FlushSession called %d times, want once in the background", flushes)
311 }
312 if got := len(ag.Messages()); got != 1 {
313 t.Fatalf("double Escape changed transcript with a provider turn: %d messages", got)
314 }
315}
316
317func TestSecondEscapeFallsThroughToCancellationWhenTurnBecomesBusy(t *testing.T) {
318 root := t.TempDir()
319 cwd := "/workspace/double-escape-busy"
320 session, err := core.NewSession(root, cwd, "test", "model", "test")
321 if err != nil {
322 t.Fatal(err)
323 }
324 message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
325 if err := session.AppendMessage(message); err != nil {
326 t.Fatal(err)
327 }
328 path := session.Path
329 if err := session.Close(); err != nil {
330 t.Fatal(err)
331 }
332
333 ag := core.NewAgent(nil, "model", "", nil)
334 ag.SetMessages([]provider.Message{message})
335 now := time.Unix(300, 0)
336 cancelled := 0
337 i := NewInteractive(InteractiveConfig{
338 Agent: ag,
339 CWD: cwd,
340 SessionsRoot: root,
341 CurrentSessionPath: func() string { return path },
342 LoadSession: func(string) error { return nil },
343 })
344 i.clock = func() time.Time { return now }
345
346 if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
347 t.Fatal("first Escape exited")
348 }
349 i.mu.Lock()
350 i.busy = true
351 i.cancelTurn = func() { cancelled++ }
352 i.mu.Unlock()
353 now = now.Add(100 * time.Millisecond)
354 if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
355 t.Fatal("second Escape exited")
356 }
357 if cancelled != 1 {
358 t.Fatalf("second Escape did not fall through to cancellation: got %d cancels", cancelled)
359 }
360 if i.sessionTreeDialog.Active() {
361 t.Fatal("session tree opened after the turn became busy")
362 }
363 if i.doubleEscape.armed {
364 t.Fatal("stale double-Escape gesture remained armed after falling through")
365 }
366}
367
368func TestSecondEscapeStillConsumesFailClosedTreeGate(t *testing.T) {
369 root := t.TempDir()
370 cwd := "/workspace/double-escape-gate"
371 session, err := core.NewSession(root, cwd, "test", "model", "test")
372 if err != nil {
373 t.Fatal(err)
374 }
375 message := provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "draft"}}}
376 if err := session.AppendMessage(message); err != nil {
377 t.Fatal(err)
378 }
379 path := session.Path
380 if err := session.Close(); err != nil {
381 t.Fatal(err)
382 }
383
384 ag := core.NewAgent(nil, "model", "", nil)
385 ag.SetMessages([]provider.Message{message})
386 now := time.Unix(400, 0)
387 pathCalls := 0
388 flushes := 0
389 i := NewInteractive(InteractiveConfig{
390 Agent: ag,
391 CWD: cwd,
392 SessionsRoot: root,
393 CurrentSessionPath: func() string {
394 pathCalls++
395 if pathCalls == 1 {
396 return path
397 }
398 // Keep the cheap gesture eligibility true while making the
399 // asynchronous family read fail after the overlay opens.
400 return path + ".missing"
401 },
402 LoadSession: func(string) error { return nil },
403 FlushSession: func() {
404 flushes++
405 },
406 })
407 i.clock = func() time.Time { return now }
408
409 if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
410 t.Fatal("first Escape exited")
411 }
412 now = now.Add(100 * time.Millisecond)
413 if done := i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}); done {
414 t.Fatal("second Escape exited")
415 }
416 if !i.sessionTreeDialog.Active() {
417 t.Fatal("tree did not open before the asynchronous family validation")
418 }
419 if err := applySessionTreeLoads(i.sessionTreeDialog, i.sessionTreeLoads); err == nil {
420 t.Fatal("unreadable family did not fail in the background")
421 }
422 if i.sessionTreeDialog.Active() {
423 t.Fatal("unreadable family left the tree overlay active")
424 }
425 if flushes != 1 {
426 t.Fatalf("FlushSession called %d times, want one asynchronous flush", flushes)
427 }
428}
429
430func TestForkCommandsWithoutSessionPathReportError(t *testing.T) {
431 for _, command := range []string{"/fork", "/session fork"} {
432 i := NewInteractive(InteractiveConfig{})
433 i.runSlash(context.Background(), command)
434 if i.statusErr != "fork: no session is active (running with --no-session?)" {
435 t.Fatalf("%s status = %q", command, i.statusErr)
436 }
437 }
438}