| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/sessioncatalog" |
| 10 | ) |
| 11 | |
| 12 | func TestSessionMetaFromCatalogPropagatesRecoveryCopy(t *testing.T) { |
| 13 | meta := sessionMetaFromCatalog(sessioncatalog.SessionRecord{ |
| 14 | Path: "/s/copy.jsonl", Preview: "hello", Turns: 2, TurnsState: sessioncatalog.TurnsValid, |
| 15 | Recovered: true, RecoveryCopy: true, LastActivityAt: 42, |
| 16 | }, false, false) |
| 17 | if !meta.Recovered || !meta.RecoveryCopy { |
| 18 | t.Fatalf("meta = %+v, want recovered recoveryCopy", meta) |
| 19 | } |
| 20 | meta = sessionMetaFromCatalog(sessioncatalog.SessionRecord{ |
| 21 | Path: "/s/adopted.jsonl", Recovered: true, RecoveryCopy: false, |
| 22 | }, false, false) |
| 23 | if !meta.Recovered || meta.RecoveryCopy { |
| 24 | t.Fatalf("meta = %+v, want recovered without recoveryCopy", meta) |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | func TestProjectNodeFromCatalogTopicFiltersIdleRecoveryCopies(t *testing.T) { |
| 29 | app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}} |
| 30 | topic := sessioncatalog.TopicRecord{ |
| 31 | Scope: "global", TopicID: "t1", Title: "Topic", Turns: 3, |
| 32 | TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, |
| 33 | LastActivityAt: 100, Sessions: []sessioncatalog.SessionRecord{ |
| 34 | {Path: "/s/parent.jsonl", Turns: 3, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 90}, |
| 35 | {Path: "/s/copy.jsonl", Turns: 3, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, |
| 36 | Recovered: true, RecoveryCopy: true, LastActivityAt: 100}, |
| 37 | {Path: "/s/open-copy.jsonl", Turns: 1, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, |
| 38 | Recovered: true, RecoveryCopy: true, LastActivityAt: 95}, |
| 39 | }, |
| 40 | } |
| 41 | sessionOverlays := map[string]catalogRuntimeOverlay{ |
| 42 | sessionRuntimeKey("/s/open-copy.jsonl"): {open: true}, |
| 43 | } |
| 44 | node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, sessionOverlays, nil) |
| 45 | if !ok { |
| 46 | t.Fatal("mixed topic should stay visible") |
| 47 | } |
| 48 | // Ordinary list is always one logical row; open recovery only flips Open. |
| 49 | if len(node.Children) != 0 { |
| 50 | t.Fatalf("children = %d, want collapsed logical row", len(node.Children)) |
| 51 | } |
| 52 | if !node.Open { |
| 53 | t.Fatal("open recovery member must aggregate onto the logical row") |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | func TestProjectNodesFromCatalogTopicKeepsSharedTopicSessionsIndependent(t *testing.T) { |
| 58 | app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}} |
| 59 | dir := t.TempDir() |
| 60 | pathA := filepath.Join(dir, "a.jsonl") |
| 61 | pathB := filepath.Join(dir, "b.jsonl") |
| 62 | topic := sessioncatalog.TopicRecord{ |
| 63 | Scope: "global", TopicID: "shared", Title: "Shared title", Turns: 5, |
| 64 | TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, |
| 65 | Sessions: []sessioncatalog.SessionRecord{ |
| 66 | {Path: pathA, CustomTitle: "A", Turns: 5, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 100}, |
| 67 | {Path: pathB, CustomTitle: "B", Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 90}, |
| 68 | }, |
| 69 | } |
| 70 | overlays := map[string]catalogRuntimeOverlay{ |
| 71 | sessionRuntimeKey(pathA): {open: true, running: true, status: topicStatusThinking}, |
| 72 | } |
| 73 | nodes := app.projectNodesFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, overlays, nil) |
| 74 | if len(nodes) != 2 { |
| 75 | t.Fatalf("nodes = %#v, want two independent rows", nodes) |
| 76 | } |
| 77 | byPath := map[string]ProjectNode{} |
| 78 | for _, node := range nodes { |
| 79 | byPath[node.SessionPath] = node |
| 80 | } |
| 81 | if !byPath[pathA].Running || byPath[pathA].Label != "A" { |
| 82 | t.Fatalf("A = %+v, want its own runtime and title", byPath[pathA]) |
| 83 | } |
| 84 | if byPath[pathB].Running || byPath[pathB].Label != "B" { |
| 85 | t.Fatalf("B = %+v, must not inherit A runtime", byPath[pathB]) |
| 86 | } |
| 87 | if byPath[pathA].Key == byPath[pathB].Key { |
| 88 | t.Fatal("shared-topic sessions must have distinct row keys") |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func TestProjectNodeFromCatalogTopicShowsNonEmptyRecoveryOnlyTopic(t *testing.T) { |
| 93 | app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}} |
| 94 | topic := sessioncatalog.TopicRecord{ |
| 95 | Scope: "global", TopicID: "only-copy", Title: "Copy", RecoveryState: "recovery_only", |
| 96 | Sessions: []sessioncatalog.SessionRecord{ |
| 97 | {Path: "/s/only.jsonl", Recovered: true, RecoveryCopy: true, Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK}, |
| 98 | }, |
| 99 | } |
| 100 | node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil) |
| 101 | if !ok { |
| 102 | t.Fatal("non-empty recovery-only topic must remain discoverable") |
| 103 | } |
| 104 | if !node.Recovered || node.RecoveryState != "recovery_only" || node.SessionPath != "/s/only.jsonl" { |
| 105 | t.Fatalf("recovery-only node = %+v, want a recoverable logical row", node) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | func TestProjectNodeFromCatalogTopicMarksCanonicalRecovery(t *testing.T) { |
| 110 | app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}} |
| 111 | topic := sessioncatalog.TopicRecord{ |
| 112 | Scope: "global", TopicID: "adopted", Title: "Recovered", RecoveryState: "adopted", |
| 113 | Sessions: []sessioncatalog.SessionRecord{ |
| 114 | {Path: "/s/root.jsonl", Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK}, |
| 115 | {Path: "/s/canonical.jsonl", Recovered: true, RecoveryRole: sessioncatalog.RecoveryRoleAdopted, |
| 116 | RecoveryCanonical: true, Turns: 4, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK}, |
| 117 | }, |
| 118 | } |
| 119 | node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil) |
| 120 | if !ok || !node.Recovered || node.RecoveryState != "adopted" { |
| 121 | t.Fatalf("canonical recovery node = %+v, visible=%v; want recovered adopted row", node, ok) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | func TestProjectNodeFromCatalogTopicKeepsPinnedRecoveryOnlyTopic(t *testing.T) { |
| 126 | app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}} |
| 127 | topic := sessioncatalog.TopicRecord{ |
| 128 | Scope: "global", TopicID: "pinned-copy", Title: "Pinned", Pinned: true, RecoveryState: "recovery_only", |
| 129 | Sessions: []sessioncatalog.SessionRecord{ |
| 130 | {Path: "/s/pinned.jsonl", Recovered: true, RecoveryCopy: true, Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK}, |
| 131 | }, |
| 132 | } |
| 133 | node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil) |
| 134 | if !ok { |
| 135 | t.Fatal("pinned recovery-only topic must remain visible") |
| 136 | } |
| 137 | // After filtering idle copies nothing is left as children, so single-line topic. |
| 138 | if len(node.Children) != 0 { |
| 139 | t.Fatalf("children = %d, want collapsed single-line pinned topic", len(node.Children)) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | func TestProjectNodeFromCatalogTopicCollapsesWhenOnlyOneVisible(t *testing.T) { |
| 144 | app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}} |
| 145 | topic := sessioncatalog.TopicRecord{ |
| 146 | Scope: "global", TopicID: "collapse", Title: "Collapse", Turns: 2, |
| 147 | Sessions: []sessioncatalog.SessionRecord{ |
| 148 | {Path: "/s/parent.jsonl", Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK}, |
| 149 | {Path: "/s/copy.jsonl", Recovered: true, RecoveryCopy: true, Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK}, |
| 150 | }, |
| 151 | } |
| 152 | node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil) |
| 153 | if !ok { |
| 154 | t.Fatal("topic with parent should stay visible") |
| 155 | } |
| 156 | if len(node.Children) != 0 { |
| 157 | t.Fatalf("children = %d, want collapsed single effective session", len(node.Children)) |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | func TestProjectNodeFromCatalogTopicCollapsesDivergedForksToOne(t *testing.T) { |
| 162 | app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}} |
| 163 | topic := sessioncatalog.TopicRecord{ |
| 164 | Scope: "global", TopicID: "forks", Title: "WebView", Turns: 5, |
| 165 | Sessions: []sessioncatalog.SessionRecord{ |
| 166 | {Path: "/s/root.jsonl", Turns: 3, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 10}, |
| 167 | {Path: "/s/fork-a.jsonl", Turns: 5, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, |
| 168 | Recovered: true, RecoveryRole: sessioncatalog.RecoveryRoleDiverged, ParentID: "root", RecoveryGroupID: "root", LastActivityAt: 30}, |
| 169 | {Path: "/s/fork-b.jsonl", Turns: 4, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, |
| 170 | Recovered: true, RecoveryRole: sessioncatalog.RecoveryRoleDiverged, ParentID: "root", RecoveryGroupID: "root", LastActivityAt: 40}, |
| 171 | }, |
| 172 | } |
| 173 | node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil) |
| 174 | if !ok { |
| 175 | t.Fatal("topic with parent should stay visible") |
| 176 | } |
| 177 | // Parent remains; idle diverged forks hide while the normal root exists. |
| 178 | if len(node.Children) != 0 { |
| 179 | t.Fatalf("children = %d, want collapsed parent-only row, children=%+v", len(node.Children), node.Children) |
| 180 | } |
| 181 | if node.RecoveryCopyCount != 0 { |
| 182 | t.Fatalf("recoveryCopyCount = %d, want compatibility field hidden from ordinary tree", node.RecoveryCopyCount) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | func TestProjectNodeFromCatalogTopicHidesFoldedCoveredCopyCount(t *testing.T) { |
| 187 | app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}} |
| 188 | topic := sessioncatalog.TopicRecord{ |
| 189 | Scope: "global", TopicID: "t1", Title: "Topic", Turns: 3, |
| 190 | TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, |
| 191 | LastActivityAt: 100, Sessions: []sessioncatalog.SessionRecord{ |
| 192 | {Path: "/s/parent.jsonl", Turns: 3, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, LastActivityAt: 90}, |
| 193 | {Path: "/s/copy-a.jsonl", Turns: 3, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, |
| 194 | Recovered: true, RecoveryCopy: true, LastActivityAt: 100}, |
| 195 | {Path: "/s/copy-b.jsonl", Turns: 3, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, |
| 196 | Recovered: true, RecoveryCopy: true, LastActivityAt: 95}, |
| 197 | }, |
| 198 | } |
| 199 | node, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, nil) |
| 200 | if !ok { |
| 201 | t.Fatal("topic with parent should stay visible") |
| 202 | } |
| 203 | if node.RecoveryCopyCount != 0 { |
| 204 | t.Fatalf("recoveryCopyCount = %d, want compatibility field hidden from ordinary tree", node.RecoveryCopyCount) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | func TestProjectNodeFromCatalogTopicHidesOrphanForkTopicsWhenPreferredElsewhere(t *testing.T) { |
| 209 | app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}} |
| 210 | // Separate topic that only holds a non-preferred diverged fork. |
| 211 | topic := sessioncatalog.TopicRecord{ |
| 212 | Scope: "global", TopicID: "orphan-fork", Title: "WebView", |
| 213 | Sessions: []sessioncatalog.SessionRecord{ |
| 214 | {Path: "/s/fork-only.jsonl", Turns: 2, TurnsState: sessioncatalog.TurnsValid, Health: sessioncatalog.HealthOK, |
| 215 | Recovered: true, RecoveryRole: sessioncatalog.RecoveryRoleDiverged, ParentID: "root", RecoveryGroupID: "root"}, |
| 216 | }, |
| 217 | } |
| 218 | // Workspace preference already chose the parent path for this lineage. |
| 219 | preferred := map[string]struct{}{"/s/root.jsonl": {}} |
| 220 | if _, ok := app.projectNodeFromCatalogTopic(topic, map[string]catalogRuntimeOverlay{}, map[string]catalogRuntimeOverlay{}, preferred); ok { |
| 221 | t.Fatal("orphan non-preferred recovery topic must stay out of the ordinary tree") |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func TestListProjectTopicsSkipsRecoveryOnlyPages(t *testing.T) { |
| 226 | isolateDesktopUserDirs(t) |
| 227 | ctx := context.Background() |
| 228 | catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{ |
| 229 | Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true, |
| 230 | }) |
| 231 | if err != nil { |
| 232 | t.Fatal(err) |
| 233 | } |
| 234 | t.Cleanup(func() { _ = catalog.Close(context.Background()) }) |
| 235 | base := time.Date(2026, 8, 11, 10, 0, 0, 0, time.UTC) |
| 236 | // Two idle recovery-only topics (newer) then one ordinary topic (older). |
| 237 | for i, topicID := range []string{"copy-b", "copy-a", "real"} { |
| 238 | record := sessioncatalog.SessionRecord{ |
| 239 | Path: filepath.Join("/s", topicID+".jsonl"), Directory: "/s", Scope: "global", |
| 240 | TopicID: topicID, TopicTitle: topicID, Turns: 1, TurnsState: sessioncatalog.TurnsValid, |
| 241 | Health: sessioncatalog.HealthOK, LastActivityAt: base.Add(time.Duration(2-i) * time.Minute).UnixMilli(), |
| 242 | } |
| 243 | if topicID != "real" { |
| 244 | record.Recovered = true |
| 245 | record.RecoveryCopy = true |
| 246 | } |
| 247 | if err := catalog.UpsertSession(ctx, record); err != nil { |
| 248 | t.Fatal(err) |
| 249 | } |
| 250 | } |
| 251 | app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}} |
| 252 | app.sessionCatalog.Store(catalog) |
| 253 | page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 1}) |
| 254 | if err != nil { |
| 255 | t.Fatal(err) |
| 256 | } |
| 257 | if len(page.Items) != 1 || page.Items[0].TopicID != "real" { |
| 258 | t.Fatalf("page = %+v, want the ordinary topic after skipping recovery-only pages", page.Items) |
| 259 | } |
| 260 | } |
| 261 |