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