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/patriceckhart/zot/packages/core"
14 "github.com/patriceckhart/zot/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(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 }
65 for _, tt := range tests {
66 t.Run(tt.input, func(t *testing.T) {
67 age, err := parseSessionAge(tt.input)
68 if (err != nil) != tt.wantErr {
69 t.Fatalf("parseSessionAge(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
70 }
71 if tt.wantErr {
72 return
73 }
74 got, err := age.cutoff(now)
75 if err != nil {
76 t.Fatal(err)
77 }
78 if !got.Equal(tt.want) {
79 t.Fatalf("cutoff = %v, want %v", got, tt.want)
80 }
81 })
82 }
83}
84
85func TestSessionsPruneDryRunPreservesSessions(t *testing.T) {
86 home := t.TempDir()
87 t.Setenv("ZOT_HOME", home)
88 missing := filepath.Join(home, "missing-project")
89 existing := t.TempDir()
90 missingPath := createPruneTestSession(t, home, missing)
91 existingPath := createPruneTestSession(t, home, existing)
92
93 var out, errOut bytes.Buffer
94 if err := runSessionsPrune(sessionsPruneOptions{dryRun: true}, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
95 t.Fatal(err)
96 }
97 if !strings.Contains(out.String(), missing) || strings.Contains(out.String(), existing) {
98 t.Fatalf("output = %q, want only missing cwd", out.String())
99 }
100 if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") {
101 t.Fatalf("output = %q, want dry-run summary", out.String())
102 }
103 for _, path := range []string{missingPath, existingPath} {
104 if _, err := os.Stat(path); err != nil {
105 t.Fatalf("dry run removed %s: %v", path, err)
106 }
107 }
108}
109
110func TestSessionsPruneInteractiveDeletesSelectedGroup(t *testing.T) {
111 home := t.TempDir()
112 t.Setenv("ZOT_HOME", home)
113 firstCWD := filepath.Join(home, "gone-a")
114 secondCWD := filepath.Join(home, "gone-b")
115 first := createPruneTestSession(t, home, firstCWD)
116 second := createPruneTestSession(t, home, secondCWD)
117
118 var out, errOut bytes.Buffer
119 if err := runSessionsPrune(sessionsPruneOptions{}, strings.NewReader("2\ny\n"), &out, &errOut, os.Stat); err != nil {
120 t.Fatal(err)
121 }
122 if _, err := os.Stat(first); err != nil {
123 t.Fatalf("unselected session was removed: %v", err)
124 }
125 if _, err := os.Stat(second); !os.IsNotExist(err) {
126 t.Fatalf("selected session still exists or stat failed unexpectedly: %v", err)
127 }
128 if !strings.Contains(out.String(), "deleted 1 session from 1 directory") {
129 t.Fatalf("output = %q", out.String())
130 }
131}
132
133func TestSessionsPrunePreservesGroupWhenDirectoryCheckIsInconclusive(t *testing.T) {
134 home := t.TempDir()
135 t.Setenv("ZOT_HOME", home)
136 cwd := filepath.Join(home, "unavailable-mount")
137 path := createPruneTestSession(t, home, cwd)
138 stat := func(string) (fs.FileInfo, error) { return nil, fs.ErrPermission }
139
140 var out, errOut bytes.Buffer
141 if err := runSessionsPrune(sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat); err != nil {
142 t.Fatal(err)
143 }
144 if _, err := os.Stat(path); err != nil {
145 t.Fatalf("session was removed after inconclusive stat: %v", err)
146 }
147 if !strings.Contains(errOut.String(), "permission denied") {
148 t.Fatalf("stderr = %q, want stat warning", errOut.String())
149 }
150 if !strings.Contains(out.String(), "no stale session directories found") {
151 t.Fatalf("output = %q", out.String())
152 }
153}
154
155func TestSessionAgeClampsCalendarBoundaries(t *testing.T) {
156 month, err := parseSessionAge("1mo")
157 if err != nil {
158 t.Fatal(err)
159 }
160 got, err := month.cutoff(time.Date(2025, time.March, 31, 12, 0, 0, 0, time.UTC))
161 if err != nil {
162 t.Fatal(err)
163 }
164 want := time.Date(2025, time.February, 28, 12, 0, 0, 0, time.UTC)
165 if !got.Equal(want) {
166 t.Fatalf("one month cutoff = %v, want %v", got, want)
167 }
168
169 year, err := parseSessionAge("1y")
170 if err != nil {
171 t.Fatal(err)
172 }
173 got, err = year.cutoff(time.Date(2024, time.February, 29, 12, 0, 0, 0, time.UTC))
174 if err != nil {
175 t.Fatal(err)
176 }
177 want = time.Date(2023, time.February, 28, 12, 0, 0, 0, time.UTC)
178 if !got.Equal(want) {
179 t.Fatalf("one year cutoff = %v, want %v", got, want)
180 }
181}
182
183func TestSessionsPruneOlderThanFiltersByLastActivity(t *testing.T) {
184 home := t.TempDir()
185 t.Setenv("ZOT_HOME", home)
186 cwd := t.TempDir()
187 oldPath := createPruneTestSession(t, home, cwd)
188 newPath := createPruneTestSession(t, home, cwd)
189 oldTime := time.Now().Add(-48 * time.Hour)
190 if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
191 t.Fatal(err)
192 }
193
194 age, err := parseSessionAge("24h")
195 if err != nil {
196 t.Fatal(err)
197 }
198 var out, errOut bytes.Buffer
199 opts := sessionsPruneOptions{dryRun: true, olderThan: &age}
200 if err := runSessionsPrune(opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
201 t.Fatal(err)
202 }
203 if !strings.Contains(out.String(), "dry run: 1 session in 1 directory would be deleted") {
204 t.Fatalf("output = %q", out.String())
205 }
206 for _, path := range []string{oldPath, newPath} {
207 if _, err := os.Stat(path); err != nil {
208 t.Fatalf("dry run removed %s: %v", path, err)
209 }
210 }
211}
212
213func TestSessionsPruneOlderThanLimitsDeletionToCWD(t *testing.T) {
214 home := t.TempDir()
215 t.Setenv("ZOT_HOME", home)
216 firstCWD := t.TempDir()
217 secondCWD := t.TempDir()
218 first := createPruneTestSession(t, home, firstCWD)
219 second := createPruneTestSession(t, home, secondCWD)
220 oldTime := time.Now().Add(-48 * time.Hour)
221 for _, path := range []string{first, second} {
222 if err := os.Chtimes(path, oldTime, oldTime); err != nil {
223 t.Fatal(err)
224 }
225 }
226
227 age, err := parseSessionAge("24h")
228 if err != nil {
229 t.Fatal(err)
230 }
231 var out, errOut bytes.Buffer
232 opts := sessionsPruneOptions{all: true, yes: true, olderThan: &age, cwd: firstCWD}
233 if err := runSessionsPrune(opts, strings.NewReader(""), &out, &errOut, os.Stat); err != nil {
234 t.Fatal(err)
235 }
236 if _, err := os.Stat(first); !os.IsNotExist(err) {
237 t.Fatalf("matching session still exists or stat failed unexpectedly: %v", err)
238 }
239 if _, err := os.Stat(second); err != nil {
240 t.Fatalf("session from another cwd was removed: %v", err)
241 }
242}
243
244func TestSessionsPruneRechecksDirectoryBeforeDeleting(t *testing.T) {
245 home := t.TempDir()
246 t.Setenv("ZOT_HOME", home)
247 cwd := filepath.Join(home, "temporarily-missing")
248 path := createPruneTestSession(t, home, cwd)
249 calls := 0
250 stat := func(path string) (fs.FileInfo, error) {
251 calls++
252 if calls == 1 {
253 return nil, fs.ErrNotExist
254 }
255 return nil, nil
256 }
257
258 var out, errOut bytes.Buffer
259 err := runSessionsPrune(sessionsPruneOptions{all: true, yes: true}, strings.NewReader(""), &out, &errOut, stat)
260 if err != nil {
261 t.Fatal(err)
262 }
263 if _, err := os.Stat(path); err != nil {
264 t.Fatalf("session was removed after cwd reappeared: %v", err)
265 }
266 if !strings.Contains(errOut.String(), "directory now exists") {
267 t.Fatalf("stderr = %q, want recheck warning", errOut.String())
268 }
269}
270
271func TestParseSessionsPruneOptions(t *testing.T) {
272 if _, err := parseSessionsPruneOptions([]string{"--yes"}); err == nil {
273 t.Fatal("--yes without --all was accepted")
274 }
275 if _, err := parseSessionsPruneOptions([]string{"--cwd", "/tmp/project"}); err == nil {
276 t.Fatal("--cwd without --older-than was accepted")
277 }
278 opts, err := parseSessionsPruneOptions([]string{"--older-than", "1mo", "--cwd", "/tmp/project", "--all", "--yes"})
279 if err != nil {
280 t.Fatal(err)
281 }
282 if !opts.all || !opts.yes || opts.olderThan == nil || opts.olderThan.unit != "mo" || opts.cwd != "/tmp/project" {
283 t.Fatalf("options = %#v", opts)
284 }
285}
286
287func createPruneTestSession(t *testing.T, root, cwd string) string {
288 t.Helper()
289 session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
290 if err != nil {
291 t.Fatal(err)
292 }
293 if err := session.AppendMessage(provider.Message{
294 Role: provider.RoleUser,
295 Content: []provider.Content{provider.TextBlock{Text: "hello"}},
296 }); err != nil {
297 t.Fatal(err)
298 }
299 if err := session.Close(); err != nil {
300 t.Fatal(err)
301 }
302 return session.Path
303}