返回 DeepSeek-Reasonix
resume_catalog_test.go
根目录 / internal / cli / resume_catalog_test.go
1 package cli
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strconv"
10 "strings"
11 "testing"
12 "time"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/config"
16 "reasonix/internal/provider"
17 "reasonix/internal/session"
18 )
19
20 func createCanonicalTestSession(t *testing.T, v4root, id, prompt string) {
21 t.Helper()
22 persistence := session.NewFilesystemPersistence(v4root)
23 sess, err := persistence.Create(session.CreateOptions{SessionID: id})
24 if err != nil {
25 t.Fatal(err)
26 }
27 if prompt != "" {
28 messagePayload, err := json.Marshal(map[string]any{
29 "message": provider.Message{ID: id + "-m1", Role: provider.RoleUser, Content: prompt},
30 })
31 if err != nil {
32 t.Fatal(err)
33 }
34 if _, err := sess.Append(context.Background(), session.Batch{OperationID: "seed", TurnID: id + "-turn", Events: []session.Event{
35 {Kind: "message/complete", Payload: messagePayload},
36 {Kind: "turn/start"},
37 {Kind: "turn/end", Payload: json.RawMessage(`{"status":"completed"}`)},
38 }}); err != nil {
39 t.Fatal(err)
40 }
41 }
42 if _, err := sess.Flush(context.Background()); err != nil {
43 t.Fatal(err)
44 }
45 if err := sess.Close(context.Background()); err != nil {
46 t.Fatal(err)
47 }
48 }
49
50 func waitForCatalogMetadata(t *testing.T, sessionDir string, ids ...string) {
51 t.Helper()
52 deadline := time.Now().Add(10 * time.Second)
53 service := cliSessionService(sessionDir)
54 if service == nil {
55 t.Fatal("no session service for test workspace")
56 }
57 for time.Now().Before(deadline) {
58 page, err := service.Query().List(context.Background(), "", 100)
59 if err != nil {
60 t.Fatal(err)
61 }
62 ready := map[string]bool{}
63 for _, info := range page.Sessions {
64 ready[info.SessionID] = info.MetadataStatus == session.MetadataReady
65 }
66 all := true
67 for _, id := range ids {
68 if !ready[id] {
69 all = false
70 break
71 }
72 }
73 if all {
74 return
75 }
76 time.Sleep(50 * time.Millisecond)
77 }
78 t.Fatalf("catalog metadata for %v did not become ready", ids)
79 }
80
81 func writeTestMigrationMap(t *testing.T, v4root, sourcePath, targetID string) {
82 t.Helper()
83 mapping := session.MigrationMapping{SchemaVersion: session.SchemaVersion, Entries: []session.MigrationEntry{{
84 SourcePath: sourcePath, TargetCodec: session.Codec, TargetID: targetID,
85 }}}
86 data, err := json.Marshal(mapping)
87 if err != nil {
88 t.Fatal(err)
89 }
90 if err := os.WriteFile(filepath.Join(v4root, "migration-map.json"), data, 0o600); err != nil {
91 t.Fatal(err)
92 }
93 }
94
95 // TestMergedResumeEntriesUnifiesStores proves the picker offers the same
96 // conversations the desktop tree shows: final-format catalog rows appear,
97 // never-chatted canonical placeholders stay hidden, and a legacy source with
98 // exactly one canonical successor is folded into that successor.
99 func TestMergedResumeEntriesUnifiesStores(t *testing.T) {
100 dir := t.TempDir()
101 sessionDir := filepath.Join(dir, "sessions")
102 v4root := filepath.Join(dir, "sessions-v4")
103 if err := os.MkdirAll(sessionDir, 0o700); err != nil {
104 t.Fatal(err)
105 }
106
107 migrated := saveQueryTestSession(t, sessionDir, "migrated-source.jsonl", "old conversation")
108 fresh := saveQueryTestSession(t, sessionDir, "fresh-legacy.jsonl", "fresh legacy conversation")
109 createCanonicalTestSession(t, v4root, "canchat1", "canonical hello")
110 createCanonicalTestSession(t, v4root, "canempty1", "")
111 writeTestMigrationMap(t, v4root, migrated, "canchat1")
112 waitForCatalogMetadata(t, sessionDir, "canchat1", "canempty1")
113
114 entries := mergedResumeEntries(sessionDir, resumeListCap)
115 var sawFresh, sawMigrated, sawChatted, sawEmpty bool
116 for _, entry := range entries {
117 switch {
118 case entry.session.Path == fresh:
119 sawFresh = true
120 if entry.target.canonical() || entry.target.path != fresh {
121 t.Fatalf("legacy row carries wrong target: %+v", entry.target)
122 }
123 case entry.session.Path == migrated:
124 sawMigrated = true
125 case entry.target.ref.SessionID == "canchat1":
126 sawChatted = true
127 if entry.session.Turns != 1 || entry.session.Preview != "canonical hello" {
128 t.Fatalf("canonical row = %+v", entry.session)
129 }
130 case entry.target.ref.SessionID == "canempty1":
131 sawEmpty = true
132 }
133 }
134 if !sawFresh {
135 t.Fatal("fresh legacy session missing from merged picker list")
136 }
137 if !sawChatted {
138 t.Fatal("chatted canonical session missing from merged picker list")
139 }
140 if sawMigrated {
141 t.Fatal("migrated legacy source still listed beside its canonical successor")
142 }
143 if sawEmpty {
144 t.Fatal("never-chatted canonical session listed")
145 }
146 }
147
148 func TestCanonicalResumeHidden(t *testing.T) {
149 if !canonicalResumeHidden(session.SessionInfo{MetadataStatus: session.MetadataReady}) {
150 t.Fatal("ready empty session should be hidden")
151 }
152 if canonicalResumeHidden(session.SessionInfo{MetadataStatus: session.MetadataReady, Turns: 2}) {
153 t.Fatal("session with turns should stay listed")
154 }
155 if canonicalResumeHidden(session.SessionInfo{MetadataStatus: session.MetadataReady, Preview: "hi"}) {
156 t.Fatal("session with preview should stay listed")
157 }
158 if canonicalResumeHidden(session.SessionInfo{MetadataStatus: session.MetadataPending}) {
159 t.Fatal("pending metadata must stay listed while the rebuild runs")
160 }
161 }
162
163 // TestMergedResumeEntriesCapsAcrossStores keeps the picker cap honest when
164 // both stores contribute rows: the cap applies to the merged stream, not per
165 // store.
166 func TestMergedResumeEntriesCapsAcrossStores(t *testing.T) {
167 dir := t.TempDir()
168 sessionDir := filepath.Join(dir, "sessions")
169 v4root := filepath.Join(dir, "sessions-v4")
170 if err := os.MkdirAll(sessionDir, 0o700); err != nil {
171 t.Fatal(err)
172 }
173 for i := range 4 {
174 saveQueryTestSession(t, sessionDir, "legacy-"+string(rune('a'+i))+"-session.jsonl", "legacy prompt "+string(rune('a'+i)))
175 }
176 var ids []string
177 for i := range 4 {
178 id := "bbbb" + string(rune('0'+i)) + "cccc"
179 createCanonicalTestSession(t, v4root, id, "canonical prompt "+string(rune('0'+i)))
180 ids = append(ids, id)
181 }
182 waitForCatalogMetadata(t, sessionDir, ids...)
183
184 entries := mergedResumeEntries(sessionDir, 5)
185 if len(entries) > 5+1 {
186 t.Fatalf("merged list kept %d entries above the cap", len(entries))
187 }
188 if len(entries) == 0 {
189 t.Fatal("merged list is empty")
190 }
191 newest := time.Time{}
192 for _, entry := range entries {
193 if entry.session.ModTime.After(newest) {
194 newest = entry.session.ModTime
195 }
196 }
197 if newest.IsZero() {
198 t.Fatal("merged rows carry no recency stamps")
199 }
200 }
201
202 func resumeStoreLegacyRow(name string, at time.Time) agent.SessionInfo {
203 return agent.SessionInfo{Path: "/sessions/" + name + ".jsonl", ModTime: at, Preview: name, Turns: 1}
204 }
205
206 func resumeStoreCanonicalRow(id string, at time.Time) resumeEntry {
207 return resumeEntry{
208 session: agent.SessionInfo{Path: "/sessions-v4/" + id, ModTime: at, Preview: id, Turns: 1},
209 target: cliResumeTarget{ref: session.SessionRef{HostID: "local", SessionID: id}},
210 }
211 }
212
213 func resumeEntryPreviews(entries []resumeEntry) []string {
214 out := make([]string, 0, len(entries))
215 for _, entry := range entries {
216 out = append(out, entry.session.Preview)
217 }
218 return out
219 }
220
221 // TestMergeResumeStoresOrdersByRecency pins the interleave contract: both
222 // stores arrive newest-first and a canonical row is emitted ahead of every
223 // legacy run it is newer than. An inverted comparison pushed newer canonical
224 // rows behind the whole legacy list, where the display cap dropped them and a
225 // desktop-created session vanished from /resume, the picker, and /takeover <n>.
226 func TestMergeResumeStoresOrdersByRecency(t *testing.T) {
227 base := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
228 at := func(minutes int) time.Time { return base.Add(time.Duration(minutes) * time.Minute) }
229 legacy := []agent.SessionInfo{resumeStoreLegacyRow("L10", at(10)), resumeStoreLegacyRow("L5", at(5))}
230 cases := []struct {
231 name string
232 canonical []resumeEntry
233 want []string
234 }{
235 {"newer canonical row leads", []resumeEntry{resumeStoreCanonicalRow("C12", at(12))}, []string{"C12", "L10", "L5"}},
236 {"canonical row slots between legacy rows", []resumeEntry{resumeStoreCanonicalRow("C7", at(7))}, []string{"L10", "C7", "L5"}},
237 {"older canonical row trails", []resumeEntry{resumeStoreCanonicalRow("C1", at(1))}, []string{"L10", "L5", "C1"}},
238 {"tie keeps the legacy row first", []resumeEntry{resumeStoreCanonicalRow("C10", at(10))}, []string{"L10", "C10", "L5"}},
239 {"several canonical rows keep their own order", []resumeEntry{
240 resumeStoreCanonicalRow("C12", at(12)), resumeStoreCanonicalRow("C7", at(7)), resumeStoreCanonicalRow("C6", at(6)),
241 }, []string{"C12", "L10", "C7", "C6", "L5"}},
242 }
243 for _, tc := range cases {
244 t.Run(tc.name, func(t *testing.T) {
245 got := resumeEntryPreviews(mergeResumeStores(legacy, tc.canonical, resumeListCap))
246 if strings.Join(got, ",") != strings.Join(tc.want, ",") {
247 t.Fatalf("merged order = %v, want %v", got, tc.want)
248 }
249 })
250 }
251 }
252
253 // TestMergeResumeStoresCapKeepsNewestCanonicalRow proves the display cap trims
254 // the oldest rows, not the canonical store: with a full page of legacy
255 // transcripts the newest conversation stays listed when it is a catalog row.
256 func TestMergeResumeStoresCapKeepsNewestCanonicalRow(t *testing.T) {
257 base := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
258 var legacy []agent.SessionInfo
259 for i := range resumeListCap {
260 minute := 20 - i
261 legacy = append(legacy, resumeStoreLegacyRow("L"+strconv.Itoa(minute), base.Add(time.Duration(minute)*time.Minute)))
262 }
263 canonical := []resumeEntry{resumeStoreCanonicalRow("C21", base.Add(21*time.Minute))}
264
265 got := resumeEntryPreviews(mergeResumeStores(legacy, canonical, resumeListCap))
266 if len(got) != resumeListCap {
267 t.Fatalf("merged list has %d rows, want the cap of %d", len(got), resumeListCap)
268 }
269 if got[0] != "C21" {
270 t.Fatalf("newest row = %q, want the canonical row C21 (order %v)", got[0], got)
271 }
272 if got[len(got)-1] != "L12" {
273 t.Fatalf("cap dropped %q instead of the oldest legacy row (order %v)", got[len(got)-1], got)
274 }
275 }
276
277 // fakeSessionCatalog pages a synthetic catalog in session-id order with the
278 // same cursor contract as FilesystemPersistence.List.
279 type fakeSessionCatalog struct {
280 infos []session.SessionInfo
281 calls int
282 }
283
284 func (f *fakeSessionCatalog) List(_ context.Context, cursor string, limit int) (session.SessionPage, error) {
285 f.calls++
286 page := session.SessionPage{Sessions: []session.SessionInfo{}}
287 for _, info := range f.infos {
288 if info.SessionID <= cursor {
289 continue
290 }
291 if len(page.Sessions) == limit {
292 page.NextCursor = page.Sessions[len(page.Sessions)-1].SessionID
293 break
294 }
295 page.Sessions = append(page.Sessions, info)
296 }
297 return page, nil
298 }
299
300 func newFakeSessionCatalog(rows int, updatedAt func(i int) time.Time) *fakeSessionCatalog {
301 catalog := &fakeSessionCatalog{}
302 for i := range rows {
303 id := fmt.Sprintf("s%05d", i)
304 catalog.infos = append(catalog.infos, session.SessionInfo{
305 SessionID: id, Ref: session.SessionRef{HostID: "local", SessionID: id}, Codec: session.Codec,
306 MetadataStatus: session.MetadataReady, Turns: 1, Preview: id, UpdatedAt: updatedAt(i), Path: "/sessions-v4/" + id,
307 })
308 }
309 return catalog
310 }
311
312 // TestCanonicalResumeEntriesRankNewestAcrossCatalogPages proves the listing
313 // walks every catalog page before ranking: with more than one page of
314 // sessions whose newest activity sorts last by id, the newest rows are the
315 // ones offered (and therefore the ones --continue picks), not the first page
316 // in lexical order.
317 func TestCanonicalResumeEntriesRankNewestAcrossCatalogPages(t *testing.T) {
318 base := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
319 catalog := newFakeSessionCatalog(250, func(i int) time.Time { return base.Add(time.Duration(i) * time.Minute) })
320
321 entries := canonicalResumeEntriesFrom(context.Background(), catalog)
322
323 if len(entries) != canonicalResumeScanCap {
324 t.Fatalf("listed %d rows, want the display cap of %d", len(entries), canonicalResumeScanCap)
325 }
326 if got := entries[0].target.ref.SessionID; got != "s00249" {
327 t.Fatalf("newest row = %q, want s00249 from the last catalog page", got)
328 }
329 if got := entries[len(entries)-1].target.ref.SessionID; got != "s00150" {
330 t.Fatalf("oldest offered row = %q, want s00150", got)
331 }
332 for i := 1; i < len(entries); i++ {
333 if entries[i].session.ModTime.After(entries[i-1].session.ModTime) {
334 t.Fatalf("rows are not newest-first at %d: %v after %v", i, entries[i].session.ModTime, entries[i-1].session.ModTime)
335 }
336 }
337 if catalog.calls != 3 {
338 t.Fatalf("catalog pages walked = %d, want 3 (250 rows at %d per page)", catalog.calls, canonicalResumeScanCap)
339 }
340 }
341
342 // TestCanonicalResumeEntriesBoundTheCatalogWalk pins the documented hard cap:
343 // a store larger than canonicalResumeWalkCap stops paging instead of scanning
344 // every directory on each /resume.
345 func TestCanonicalResumeEntriesBoundTheCatalogWalk(t *testing.T) {
346 base := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
347 catalog := newFakeSessionCatalog(canonicalResumeWalkCap+canonicalResumeScanCap, func(int) time.Time { return base })
348
349 entries := canonicalResumeEntriesFrom(context.Background(), catalog)
350
351 if want := canonicalResumeWalkCap / canonicalResumeScanCap; catalog.calls != want {
352 t.Fatalf("catalog pages walked = %d, want %d (walk cap %d)", catalog.calls, want, canonicalResumeWalkCap)
353 }
354 if len(entries) != canonicalResumeScanCap {
355 t.Fatalf("listed %d rows, want the display cap of %d", len(entries), canonicalResumeScanCap)
356 }
357 }
358
359 // TestOtherProjectResumeRowsUseMigrationMapWithoutForeignService proves the
360 // cross-project rows come from the foreign workspace's migration map alone: a
361 // migrated source is hidden, the live transcript is offered, and no session
362 // service is opened (and cached for the process lifetime) for that root.
363 func TestOtherProjectResumeRowsUseMigrationMapWithoutForeignService(t *testing.T) {
364 currentDir := t.TempDir()
365 otherRoot := t.TempDir()
366 otherDir := config.ProjectSessionDir(otherRoot)
367 if otherDir == "" {
368 t.Skip("project session dir unavailable")
369 }
370 if err := os.MkdirAll(otherDir, 0o755); err != nil {
371 t.Fatal(err)
372 }
373 projectsFile := filepath.Join(config.ReasonixHomeDir(), "desktop-projects.json")
374 previous, readErr := os.ReadFile(projectsFile)
375 t.Cleanup(func() {
376 if readErr != nil {
377 os.Remove(projectsFile)
378 return
379 }
380 _ = os.WriteFile(projectsFile, previous, 0o644)
381 })
382 if err := os.WriteFile(projectsFile, []byte(`{"projects":[{"root":`+strconv.Quote(filepath.ToSlash(otherRoot))+`}]}`), 0o644); err != nil {
383 t.Fatal(err)
384 }
385 migrated := saveQueryTestSession(t, otherDir, "migrated-source.jsonl", "migrated work")
386 fresh := saveQueryTestSession(t, otherDir, "fresh-legacy.jsonl", "fresh work")
387 v4root := session.RootForLegacyDir(otherDir)
388 if err := os.MkdirAll(filepath.Join(v4root, "target0001"), 0o700); err != nil {
389 t.Fatal(err)
390 }
391 writeTestMigrationMap(t, v4root, migrated, "target0001")
392
393 entries := otherProjectResumeEntries(currentDir)
394
395 var foreign []resumeEntry
396 for _, entry := range entries {
397 if entry.project == filepath.Base(otherRoot) {
398 foreign = append(foreign, entry)
399 }
400 }
401 if len(foreign) != 1 || foreign[0].session.Path != fresh || foreign[0].target.canonical() {
402 t.Fatalf("foreign project rows = %+v, want exactly the live transcript %q", foreign, fresh)
403 }
404 cliSessionServices.Lock()
405 _, opened := cliSessionServices.byRoot[v4root]
406 cliSessionServices.Unlock()
407 if opened {
408 t.Fatalf("listing another project opened a session service for %s", v4root)
409 }
410 }
411
411 lines GO