| 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 |