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