1package agent
2
3import (
4 "bytes"
5 "io/fs"
6 "os"
7 "path/filepath"
8 "reflect"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/bnema/zut/packages/core"
14 "github.com/bnema/zut/packages/provider"
15)
16
17func TestParseSessionSelection(t *testing.T) {
18 tests := []struct {
19 input string
20 count int
21 want []int
22 wantErr bool
23 }{
24 {input: "", count: 3},
25 {input: "none", count: 3},
26 {input: "all", count: 3, want: []int{0, 1, 2}},
27 {input: "1,3-4,3", count: 4, want: []int{0, 2, 3}},
28 {input: "0", count: 3, wantErr: true},
29 {input: "4", count: 3, wantErr: true},
30 {input: "3-2", count: 3, wantErr: true},
31 {input: "1,,2", count: 3, wantErr: true},
32 }
33 for _, tt := range tests {
34 t.Run("select/"+tt.input, func(t *testing.T) {
35 got, err := parseSessionSelection(tt.input, tt.count)
36 if (err != nil) != tt.wantErr {
37 t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
38 }
39 if !reflect.DeepEqual(got, tt.want) {
40 t.Fatalf("selection = %v, want %v", got, tt.want)
41 }
42 })
43 }
44}
45
46func TestParseSessionAge(t *testing.T) {
47 now := time.Date(2026, time.August, 22, 12, 0, 0, 0, time.UTC)
48 tests := []struct {
49 input string
50 want time.Time
51 wantErr bool
52 }{
53 {input: "30m", want: now.Add(-30 * time.Minute)},
54 {input: "4h", want: now.Add(-4 * time.Hour)},
55 {input: "30d", want: now.Add(-30 * 24 * time.Hour)},
56 {input: "2w", want: now.Add(-14 * 24 * time.Hour)},
57 {input: "1mo", want: now.AddDate(0, -1, 0)},
58 {input: "1y", want: now.AddDate(-1, 0, 0)},
59 {input: "", wantErr: true},
60 {input: "0d", wantErr: true},
61 {input: "-1d", wantErr: true},
62 {input: "1.5h", wantErr: true},
63 {input: "1s", wantErr: true},
64 {input: "99999999999999999999d", wantErr: true},
65 }
66 for _, tt := range tests {
67 t.Run("age/"+tt.input, func(t *testing.T) {
68 age, err := parseSessionAge(tt.input)
69 if (err != nil) != tt.wantErr {
70 t.Fatalf("parseSessionAge(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
71 }
72 if tt.wantErr {
73 return
74 }
75 got, err := age.cutoff(now)
76 if err != nil {
77 t.Fatal(err)
78 }
79 if !got.Equal(tt.want) {
80 t.Fatalf("cutoff = %v, want %v", got, tt.want)
81 }
82 })
83 }
84}
85
86func TestSessionAgeClampsCalendarBoundaries(t *testing.T) {
87 month, err := parseSessionAge("1mo")
88 if err != nil {
89 t.Fatal(err)
90 }
91 got, err := month.cutoff(time.Date(2025, time.March, 31, 12, 0, 0, 0, time.UTC))
92 if err != nil {
93 t.Fatal(err)
94 }
95 want := time.Date(2025, time.February, 28, 12, 0, 0, 0, time.UTC)
96 if !got.Equal(want) {
97 t.Fatalf("one month cutoff = %v, want %v", got, want)
98 }
99
100 year, err := parseSessionAge("1y")
101 if err != nil {
102 t.Fatal(err)
103 }
104 got, err = year.cutoff(time.Date(2024, time.February, 29, 12, 0, 0, 0, time.UTC))
105 if err != nil {
106 t.Fatal(err)
107 }
108 want = time.Date(2023, time.February, 28, 12, 0, 0, 0, time.UTC)
109 if !got.Equal(want) {
110 t.Fatalf("one year cutoff = %v, want %v", got, want)
111 }
112}
113
114func TestParseSessionsPruneOptions(t *testing.T) {
115 if _, err := parseSessionsPruneOptions([]string{"--yes"}); err == nil {
116 t.Fatal("--yes without --all was accepted")
117 }
118 if _, err := parseSessionsPruneOptions([]string{"--cwd", "/tmp/project"}); err == nil {
119 t.Fatal("--cwd without --older-than was accepted")
120 }
121 if _, err := parseSessionsPruneOptions([]string{"--all", "--yes", "--dry-run"}); err == nil {
122 t.Fatal("--yes with --dry-run was accepted")
123 }
124 if _, err := parseSessionsPruneOptions([]string{"--bogus"}); err == nil {
125 t.Fatal("unknown flag was accepted")
126 }
127 if _, err := parseSessionsPruneOptions([]string{"--older-than"}); err == nil {
128 t.Fatal("missing --older-than value was accepted")
129 }
130 opts, err := parseSessionsPruneOptions([]string{"--older-than", "1mo", "--cwd", "/tmp/project", "--all", "--yes"})
131 if err != nil {
132 t.Fatal(err)
133 }
134 if !opts.all || !opts.yes || opts.olderThan == nil || opts.olderThan.unit != "mo" || opts.cwd != "/tmp/project" {
135 t.Fatalf("options = %#v", opts)
136 }
137}
138
139func TestSessionsPruneDryRunPreservesSessions(t *testing.T) {
140 root := t.TempDir()
141 missing := filepath.Join(root, "missing-project")
142 existing := t.TempDir()
143 missingPath := createPruneTestSession(t, root, missing)
144 existingPath := createPruneTestSession(t, root, existing)
145
146 var out, errOut bytes.Buffer
147 if err := runSessionsPrune(root, sessionsPruneOptions{dryRun: true, all: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
148 t.Fatal(err)
149 }
150 if !strings.Contains(out.String(), missing) || strings.Contains(out.String(), existing) {
151 t.Fatalf("output = %q, want only missing cwd", out.String())
152 }
153 if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") {
154 t.Fatalf("output = %q, want dry-run summary", out.String())
155 }
156 for _, path := range []string{missingPath, existingPath} {
157 if _, err := os.Stat(path); err != nil {
158 t.Fatalf("dry run removed %s: %v", path, err)
159 }
160 }
161}
162
163func TestSessionsPruneInteractiveDeletesSelectedGroup(t *testing.T) {
164 root := t.TempDir()
165 firstCWD := filepath.Join(root, "gone-a")
166 secondCWD := filepath.Join(root, "gone-b")
167 first := createPruneTestSession(t, root, firstCWD)
168 second := createPruneTestSession(t, root, secondCWD)
169
170 var out, errOut bytes.Buffer
171 if err := runSessionsPrune(root, sessionsPruneOptions{}, strings.NewReader("2\ny\n"), &out, &errOut, os.Stat); err != nil {
172 t.Fatal(err)
173 }
174 if _, err := os.Stat(first); err != nil {
175 t.Fatalf("unselected session was removed: %v", err)
176 }
177 if _, err := os.Stat(second); !os.IsNotExist(err) {
178 t.Fatalf("selected session still exists or stat failed unexpectedly: %v", err)
179 }
180 if !strings.Contains(out.String(), "deleted 1 session from 1 directory") {
181 t.Fatalf("output = %q", out.String())
182 }
183}
184
185func TestSessionsPruneDeclinedConfirmationDeletesNothing(t *testing.T) {
186 root := t.TempDir()
187 path := createPruneTestSession(t, root, filepath.Join(root, "gone"))
188
189 var out, errOut bytes.Buffer
190 if err := runSessionsPrune(root, sessionsPruneOptions{all: true}, strings.NewReader("n\n"), &out, &errOut, os.Stat); err != nil {
191 t.Fatal(err)
192 }
193 if _, err := os.Stat(path); err != nil {
194 t.Fatalf("declined confirmation removed the session: %v", err)
195 }
196 if !strings.Contains(out.String(), "no sessions deleted") {
197 t.Fatalf("output = %q, want declined message", out.String())
198 }
199}
200
201func TestSessionsPruneAllYesDeletesWithoutPrompting(t *testing.T) {
202 root := t.TempDir()
203 first := createPruneTestSession(t, root, filepath.Join(root, "gone-a"))
204 second := createPruneTestSession(t, root, filepath.Join(root, "gone-b"))
205
206 var out, errOut bytes.Buffer
207 if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
208 t.Fatal(err)
209 }
210 for _, path := range []string{first, second} {
211 if _, err := os.Stat(path); !os.IsNotExist(err) {
212 t.Fatalf("session %s still exists: %v", path, err)
213 }
214 }
215 if !strings.Contains(out.String(), "deleted 2 sessions from 2 directories") {
216 t.Fatalf("output = %q", out.String())
217 }
218}
219
220func TestSessionsPrunePreservesGroupWhenDirectoryCheckIsInconclusive(t *testing.T) {
221 root := t.TempDir()
222 cwd := filepath.Join(root, "unavailable-mount")
223 path := createPruneTestSession(t, root, cwd)
224 stat := func(string) (fs.FileInfo, error) { return nil, fs.ErrPermission }
225
226 var out, errOut bytes.Buffer
227 if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat); err != nil {
228 t.Fatal(err)
229 }
230 if _, err := os.Stat(path); err != nil {
231 t.Fatalf("session was removed after inconclusive stat: %v", err)
232 }
233 if !strings.Contains(errOut.String(), "permission denied") {
234 t.Fatalf("stderr = %q, want stat warning", errOut.String())
235 }
236 if !strings.Contains(out.String(), "no stale session directories found") {
237 t.Fatalf("output = %q", out.String())
238 }
239}
240
241func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) {
242 root := t.TempDir()
243 cwd := filepath.Join(root, "temporarily-missing")
244 path := createPruneTestSession(t, root, cwd)
245 calls := 0
246 stat := func(string) (fs.FileInfo, error) {
247 calls++
248 if calls == 1 {
249 return nil, fs.ErrNotExist
250 }
251 return nil, nil
252 }
253
254 var out, errOut bytes.Buffer
255 if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat); err != nil {
256 t.Fatal(err)
257 }
258 if _, err := os.Stat(path); err != nil {
259 t.Fatalf("session was removed after cwd reappeared: %v", err)
260 }
261 if !strings.Contains(errOut.String(), "directory now exists") {
262 t.Fatalf("stderr = %q, want recheck warning", errOut.String())
263 }
264}
265
266func TestSessionsPruneOlderThanFiltersByLastActivity(t *testing.T) {
267 root := t.TempDir()
268 cwd := t.TempDir()
269 oldPath := createPruneTestSession(t, root, cwd)
270 newPath := createPruneTestSession(t, root, cwd)
271 oldTime := time.Now().Add(-48 * time.Hour)
272 if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
273 t.Fatal(err)
274 }
275
276 age, err := parseSessionAge("24h")
277 if err != nil {
278 t.Fatal(err)
279 }
280 var out, errOut bytes.Buffer
281 opts := sessionsPruneOptions{dryRun: true, all: true, olderThan: &age}
282 if err := runSessionsPrune(root, opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
283 t.Fatal(err)
284 }
285 if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") {
286 t.Fatalf("output = %q", out.String())
287 }
288 for _, path := range []string{oldPath, newPath} {
289 if _, err := os.Stat(path); err != nil {
290 t.Fatalf("dry run removed %s: %v", path, err)
291 }
292 }
293}
294
295func TestSessionsPruneOlderThanLimitsDeletionToCWD(t *testing.T) {
296 root := t.TempDir()
297 firstCWD := t.TempDir()
298 secondCWD := t.TempDir()
299 first := createPruneTestSession(t, root, firstCWD)
300 second := createPruneTestSession(t, root, secondCWD)
301 oldTime := time.Now().Add(-48 * time.Hour)
302 for _, path := range []string{first, second} {
303 if err := os.Chtimes(path, oldTime, oldTime); err != nil {
304 t.Fatal(err)
305 }
306 }
307
308 age, err := parseSessionAge("24h")
309 if err != nil {
310 t.Fatal(err)
311 }
312 var out, errOut bytes.Buffer
313 opts := sessionsPruneOptions{all: true, yes: true, olderThan: &age, cwd: firstCWD}
314 if err := runSessionsPrune(root, opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
315 t.Fatal(err)
316 }
317 if _, err := os.Stat(first); !os.IsNotExist(err) {
318 t.Fatalf("matching session still exists or stat failed unexpectedly: %v", err)
319 }
320 if _, err := os.Stat(second); err != nil {
321 t.Fatalf("session from another cwd was removed: %v", err)
322 }
323}
324
325func TestSessionsPruneWarnsOnScanIssues(t *testing.T) {
326 root := t.TempDir()
327 kept := createPruneTestSession(t, root, filepath.Join(root, "gone"))
328 if err := os.WriteFile(filepath.Join(root, "broken.jsonl"), []byte("{not json\n"), 0o600); err != nil {
329 t.Fatal(err)
330 }
331
332 var out, errOut bytes.Buffer
333 if err := runSessionsPrune(root, sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
334 t.Fatal(err)
335 }
336 if !strings.Contains(errOut.String(), "warning: preserving") {
337 t.Fatalf("stderr = %q, want scan-issue warning", errOut.String())
338 }
339 if _, err := os.Stat(filepath.Join(root, "broken.jsonl")); err != nil {
340 t.Fatalf("malformed entry was removed: %v", err)
341 }
342 if _, err := os.Stat(kept); !os.IsNotExist(err) {
343 t.Fatalf("stale session still exists: %v", err)
344 }
345}
346
347func TestSessionsCommandDispatch(t *testing.T) {
348 if handled, err := runSessionsCommand(nil); handled || err != nil {
349 t.Fatalf("handled=%v err=%v, want passthrough", handled, err)
350 }
351 if handled, err := runSessionsCommand([]string{"other"}); handled || err != nil {
352 t.Fatalf("handled=%v err=%v, want passthrough", handled, err)
353 }
354 if handled, err := runSessionsCommand([]string{"sessions", "bogus"}); !handled || err == nil {
355 t.Fatalf("handled=%v err=%v, want handled error", handled, err)
356 }
357}
358
359func createPruneTestSession(t *testing.T, root, cwd string) string {
360 t.Helper()
361 session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
362 if err != nil {
363 t.Fatal(err)
364 }
365 if err := session.AppendMessage(provider.Message{
366 Role: provider.RoleUser,
367 Content: []provider.Content{provider.TextBlock{Text: "hello"}},
368 }); err != nil {
369 _ = session.Close()
370 t.Fatal(err)
371 }
372 if err := session.Close(); err != nil {
373 t.Fatal(err)
374 }
375 return session.Path
376}