| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/agent" |
| 11 | "reasonix/internal/control" |
| 12 | "reasonix/internal/provider" |
| 13 | "reasonix/internal/sessioncatalog" |
| 14 | ) |
| 15 | |
| 16 | func TestStartTopicActivationKeepsExplicitRepresentativeSeparateFromLiveSibling(t *testing.T) { |
| 17 | isolateDesktopUserDirs(t) |
| 18 | projectRoot := t.TempDir() |
| 19 | sessionDir := desktopSessionDir(projectRoot) |
| 20 | if err := os.MkdirAll(sessionDir, 0o755); err != nil { |
| 21 | t.Fatalf("mkdir sessions: %v", err) |
| 22 | } |
| 23 | oldPath := writeTopicSessionWithPrompt(t, sessionDir, "old.jsonl", "topic-b", "Topic B", projectRoot, "yesterday unsigned todos", time.Now().Add(-24*time.Hour)) |
| 24 | livePath := writeTopicSessionWithPrompt(t, sessionDir, "live.jsonl", "topic-b", "Topic B", projectRoot, "today live turn", time.Now()) |
| 25 | |
| 26 | app := NewApp() |
| 27 | app.ctx = context.Background() |
| 28 | app.readyHook = func() {} |
| 29 | events := newActivationEventRecorder(app) |
| 30 | t.Cleanup(func() { app.shutdown(context.Background()) }) |
| 31 | |
| 32 | stub := &activationStubController{sessionPath: livePath} |
| 33 | live := &WorkspaceTab{ |
| 34 | ID: "tab-live", |
| 35 | Scope: "project", |
| 36 | WorkspaceRoot: projectRoot, |
| 37 | TopicID: "topic-b", |
| 38 | TopicTitle: "Topic B", |
| 39 | SessionPath: livePath, |
| 40 | Ctrl: stub, |
| 41 | Label: "stub-model", |
| 42 | Ready: true, |
| 43 | ActivityStatus: topicStatusPaused, |
| 44 | disabledMCP: map[string]ServerView{}, |
| 45 | } |
| 46 | live.sink = &tabEventSink{tabID: live.ID, app: app} |
| 47 | installNoopRuntimeEvents(app, live.sink) |
| 48 | if err := live.ensureSessionLease(livePath); err != nil { |
| 49 | t.Fatalf("ensureSessionLease live: %v", err) |
| 50 | } |
| 51 | app.detachedSessions[sessionRuntimeKey(livePath)] = live |
| 52 | |
| 53 | ticket, err := app.StartTopicActivation(TopicActivationRequest{ |
| 54 | Scope: "project", |
| 55 | WorkspaceRoot: projectRoot, |
| 56 | TopicID: "topic-b", |
| 57 | SessionPath: oldPath, |
| 58 | RequestID: "req-live", |
| 59 | }) |
| 60 | if err != nil { |
| 61 | t.Fatalf("StartTopicActivation: %v", err) |
| 62 | } |
| 63 | events.waitFor(t, activationEventFor("req-live", "ready")) |
| 64 | |
| 65 | app.mu.RLock() |
| 66 | tab := app.tabs[ticket.TabID] |
| 67 | app.mu.RUnlock() |
| 68 | if tab == nil { |
| 69 | t.Fatal("activated tab missing") |
| 70 | } |
| 71 | if tab.Ctrl == stub { |
| 72 | t.Fatal("explicit history selection reused the same-topic sibling controller") |
| 73 | } |
| 74 | assertActivatedSourceMapping(t, app, tab, oldPath) |
| 75 | if stub.closed.Load() { |
| 76 | t.Fatal("live controller was closed while attaching") |
| 77 | } |
| 78 | if ticket.Meta.SessionPath != "" && sessionRuntimeKey(ticket.Meta.SessionPath) != sessionRuntimeKey(oldPath) { |
| 79 | t.Fatalf("ticket session path = %q, want selected %q", ticket.Meta.SessionPath, oldPath) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | func TestStartTopicActivationReadyDoesNotWaitForRebuildMutex(t *testing.T) { |
| 84 | isolateDesktopUserDirs(t) |
| 85 | projectRoot := t.TempDir() |
| 86 | sessionDir := desktopSessionDir(projectRoot) |
| 87 | if err := os.MkdirAll(sessionDir, 0o755); err != nil { |
| 88 | t.Fatalf("mkdir sessions: %v", err) |
| 89 | } |
| 90 | pathA := writeTopicSession(t, sessionDir, "a.jsonl", "topic-a", "Topic A", projectRoot) |
| 91 | writeTopicSessionWithPrompt(t, sessionDir, "b-old.jsonl", "topic-b", "Topic B", projectRoot, "old b", time.Now().Add(-time.Hour)) |
| 92 | liveB := writeTopicSessionWithPrompt(t, sessionDir, "b-live.jsonl", "topic-b", "Topic B", projectRoot, "live b", time.Now()) |
| 93 | |
| 94 | app := NewApp() |
| 95 | app.ctx = context.Background() |
| 96 | app.readyHook = func() {} |
| 97 | events := newActivationEventRecorder(app) |
| 98 | t.Cleanup(func() { app.shutdown(context.Background()) }) |
| 99 | |
| 100 | stubA := &activationStubController{sessionPath: pathA} |
| 101 | tabA := &WorkspaceTab{ |
| 102 | ID: "tab-a", |
| 103 | Scope: "project", |
| 104 | WorkspaceRoot: projectRoot, |
| 105 | TopicID: "topic-a", |
| 106 | TopicTitle: "Topic A", |
| 107 | SessionPath: pathA, |
| 108 | Ctrl: stubA, |
| 109 | Label: "stub-a", |
| 110 | Ready: true, |
| 111 | disabledMCP: map[string]ServerView{}, |
| 112 | } |
| 113 | tabA.sink = &tabEventSink{tabID: tabA.ID, app: app} |
| 114 | installNoopRuntimeEvents(app, tabA.sink) |
| 115 | if err := tabA.ensureSessionLease(pathA); err != nil { |
| 116 | t.Fatalf("ensureSessionLease A: %v", err) |
| 117 | } |
| 118 | app.tabs[tabA.ID] = tabA |
| 119 | app.tabOrder = []string{tabA.ID} |
| 120 | app.activeTabID = tabA.ID |
| 121 | |
| 122 | stubB := &activationStubController{sessionPath: liveB} |
| 123 | live := &WorkspaceTab{ |
| 124 | ID: "tab-b", |
| 125 | Scope: "project", |
| 126 | WorkspaceRoot: projectRoot, |
| 127 | TopicID: "topic-b", |
| 128 | TopicTitle: "Topic B", |
| 129 | SessionPath: liveB, |
| 130 | Ctrl: stubB, |
| 131 | Label: "stub-b", |
| 132 | Ready: true, |
| 133 | disabledMCP: map[string]ServerView{}, |
| 134 | } |
| 135 | live.sink = &tabEventSink{tabID: live.ID, app: app} |
| 136 | installNoopRuntimeEvents(app, live.sink) |
| 137 | if err := live.ensureSessionLease(liveB); err != nil { |
| 138 | t.Fatalf("ensureSessionLease B: %v", err) |
| 139 | } |
| 140 | app.detachedSessions[sessionRuntimeKey(liveB)] = live |
| 141 | |
| 142 | app.runtimeRebuildMu.Lock() |
| 143 | defer app.runtimeRebuildMu.Unlock() |
| 144 | |
| 145 | ticket, err := app.StartTopicActivation(TopicActivationRequest{ |
| 146 | Scope: "project", |
| 147 | WorkspaceRoot: projectRoot, |
| 148 | TopicID: "topic-b", |
| 149 | SessionPath: liveB, |
| 150 | RequestID: "req-rebuild", |
| 151 | }) |
| 152 | if err != nil { |
| 153 | t.Fatalf("StartTopicActivation: %v", err) |
| 154 | } |
| 155 | // runtimeRebuildMu remains held, so obtaining a ticket proves that activation |
| 156 | // publication does not wait for the MCP rebuild mutex. |
| 157 | deadline := time.After(400 * time.Millisecond) |
| 158 | for { |
| 159 | select { |
| 160 | case ev := <-events.ch: |
| 161 | if ev.RequestID == "req-rebuild" && ev.Phase == "ready" { |
| 162 | if sessionRuntimeKey(ticket.Meta.SessionPath) != sessionRuntimeKey(liveB) { |
| 163 | t.Fatalf("ticket path = %q, want live %q", ticket.Meta.SessionPath, liveB) |
| 164 | } |
| 165 | return |
| 166 | } |
| 167 | case <-deadline: |
| 168 | t.Fatal("ready waited for keepOnlyVisibleTab to acquire the rebuild mutex") |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | func TestPreferLiveSessionPathKeepsExplicitNonRepresentativeInspect(t *testing.T) { |
| 174 | live := "/sessions/live.jsonl" |
| 175 | rep := "/sessions/rep.jsonl" |
| 176 | inspect := "/sessions/inspect.jsonl" |
| 177 | if got := preferLiveSessionPath(inspect, live, rep); sessionRuntimeKey(got) != sessionRuntimeKey(inspect) { |
| 178 | t.Fatalf("inspect path = %q, want %q", got, inspect) |
| 179 | } |
| 180 | if got := preferLiveSessionPath(rep, live, rep); sessionRuntimeKey(got) != sessionRuntimeKey(live) { |
| 181 | t.Fatalf("representative path = %q, want live %q", got, live) |
| 182 | } |
| 183 | if got := preferLiveSessionPath("", live, rep); sessionRuntimeKey(got) != sessionRuntimeKey(live) { |
| 184 | t.Fatalf("empty path = %q, want live %q", got, live) |
| 185 | } |
| 186 | if got := preferLiveSessionPath(rep, "", rep); sessionRuntimeKey(got) != sessionRuntimeKey(rep) { |
| 187 | t.Fatalf("no live runtime = %q, want representative", got) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func TestPreferLiveSessionPathTreatsRepresentativeAndCanonicalAsOrdinary(t *testing.T) { |
| 192 | live := "/sessions/live.jsonl" |
| 193 | parent := "/sessions/parent.jsonl" |
| 194 | leaf := "/sessions/leaf.jsonl" |
| 195 | inspect := "/sessions/inspect.jsonl" |
| 196 | if got := preferLiveSessionPath(parent, live, parent, leaf); sessionRuntimeKey(got) != sessionRuntimeKey(live) { |
| 197 | t.Fatalf("covering parent = %q, want live %q", got, live) |
| 198 | } |
| 199 | if got := preferLiveSessionPath(leaf, live, parent, leaf); sessionRuntimeKey(got) != sessionRuntimeKey(live) { |
| 200 | t.Fatalf("canonical leaf = %q, want live %q", got, live) |
| 201 | } |
| 202 | if got := preferLiveSessionPath(inspect, live, parent, leaf); sessionRuntimeKey(got) != sessionRuntimeKey(inspect) { |
| 203 | t.Fatalf("history inspect = %q, want %q", got, inspect) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | func installCoveringLeafTopicCatalog(t *testing.T, app *App) (parent, leaf, live string) { |
| 208 | t.Helper() |
| 209 | dir := desktopSessionDir(globalWorkspaceRoot()) |
| 210 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | q := provider.Message{Role: provider.RoleUser, Content: "question"} |
| 214 | a := provider.Message{Role: provider.RoleAssistant, Content: "answer"} |
| 215 | save := func(path, topic string, messages ...provider.Message) { |
| 216 | t.Helper() |
| 217 | session := agent.NewSession("sys") |
| 218 | for _, message := range messages { |
| 219 | session.Add(message) |
| 220 | } |
| 221 | if err := session.Save(path); err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | if err := agent.SaveBranchMetaPreserveUpdated(path, agent.BranchMeta{ |
| 225 | ID: agent.BranchID(path), Scope: "global", TopicID: topic, TopicTitle: "Upgraded", |
| 226 | }); err != nil { |
| 227 | t.Fatal(err) |
| 228 | } |
| 229 | } |
| 230 | parent = filepath.Join(dir, "root.jsonl") |
| 231 | leaf = filepath.Join(dir, "leaf.jsonl") |
| 232 | live = filepath.Join(dir, "live.jsonl") |
| 233 | save(parent, "conversation", q, a) |
| 234 | save(leaf, "legacy-leaf-topic", q, a, |
| 235 | provider.Message{Role: provider.RoleUser, Content: "next"}, |
| 236 | provider.Message{Role: provider.RoleAssistant, Content: "done"}) |
| 237 | leafSession, err := agent.LoadSession(leaf) |
| 238 | if err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | leafDigest, err := agent.ContentDigestForMessages(leafSession.Snapshot()) |
| 242 | if err != nil { |
| 243 | t.Fatal(err) |
| 244 | } |
| 245 | if err := agent.SaveBranchMetaPreserveUpdated(leaf, agent.BranchMeta{ |
| 246 | ID: "leaf", Scope: "global", TopicID: "legacy-leaf-topic", |
| 247 | Recovered: true, ParentID: "root", RecoveryDepth: 1, |
| 248 | Revision: 1, ContentDigest: leafDigest, RecoveryDigest: leafDigest, |
| 249 | }); err != nil { |
| 250 | t.Fatal(err) |
| 251 | } |
| 252 | save(live, "conversation", |
| 253 | provider.Message{Role: provider.RoleUser, Content: "today live turn"}, |
| 254 | provider.Message{Role: provider.RoleAssistant, Content: "paused here"}) |
| 255 | installSessionCatalogForTest(t, app, dir, "global", "") |
| 256 | return parent, leaf, live |
| 257 | } |
| 258 | |
| 259 | func TestResolveOpenTopicSessionPathKeepsPausedLiveOnCoveringParent(t *testing.T) { |
| 260 | isolateDesktopUserDirs(t) |
| 261 | app := NewApp() |
| 262 | parent, leaf, _ := installCoveringLeafTopicCatalog(t, app) |
| 263 | paused := &WorkspaceTab{ |
| 264 | ID: "paused", |
| 265 | Scope: "global", |
| 266 | TopicID: "conversation", |
| 267 | SessionPath: parent, |
| 268 | Ctrl: &retargetRuntimeController{status: control.RuntimeStatus{}, path: parent}, |
| 269 | ActivityStatus: topicStatusPaused, |
| 270 | } |
| 271 | app.detachedSessions[sessionRuntimeKey(parent)] = paused |
| 272 | |
| 273 | _, resolved := app.resolveOpenTopicSessionPath("global", "", parent) |
| 274 | if resolved != parent { |
| 275 | t.Fatalf("resolve paused parent = %q, want keep live parent %q (not covering leaf %q)", resolved, parent, leaf) |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | func TestStartTopicActivationKeepsRecoveryContinuationSeparateFromLiveSibling(t *testing.T) { |
| 280 | isolateDesktopUserDirs(t) |
| 281 | app := NewApp() |
| 282 | app.ctx = context.Background() |
| 283 | app.readyHook = func() {} |
| 284 | events := newActivationEventRecorder(app) |
| 285 | t.Cleanup(func() { app.shutdown(context.Background()) }) |
| 286 | |
| 287 | parent, leaf, livePath := installCoveringLeafTopicCatalog(t, app) |
| 288 | topic, ok := app.catalogTopicRecord("global", "", "conversation") |
| 289 | if !ok { |
| 290 | t.Fatal("catalog topic missing") |
| 291 | } |
| 292 | canonical := sessioncatalog.CanonicalSessionPathForTopic(topic.Sessions, "") |
| 293 | if sessionRuntimeKey(canonical) != sessionRuntimeKey(leaf) { |
| 294 | t.Fatalf("canonical = %q, want covering leaf %q", canonical, leaf) |
| 295 | } |
| 296 | if topic.RepresentativePath != "" && sessionRuntimeKey(topic.RepresentativePath) != sessionRuntimeKey(parent) && sessionRuntimeKey(topic.RepresentativePath) != sessionRuntimeKey(leaf) { |
| 297 | t.Fatalf("representative = %q, want parent %q or covering leaf %q", topic.RepresentativePath, parent, leaf) |
| 298 | } |
| 299 | |
| 300 | stub := &activationStubController{sessionPath: livePath, status: &control.RuntimeStatus{}} |
| 301 | live := &WorkspaceTab{ |
| 302 | ID: "tab-live", |
| 303 | Scope: "global", |
| 304 | TopicID: "conversation", |
| 305 | TopicTitle: "Upgraded", |
| 306 | SessionPath: livePath, |
| 307 | Ctrl: stub, |
| 308 | Label: "stub-model", |
| 309 | Ready: true, |
| 310 | ActivityStatus: topicStatusPaused, |
| 311 | disabledMCP: map[string]ServerView{}, |
| 312 | } |
| 313 | live.sink = &tabEventSink{tabID: live.ID, app: app} |
| 314 | installNoopRuntimeEvents(app, live.sink) |
| 315 | if err := live.ensureSessionLease(livePath); err != nil { |
| 316 | t.Fatalf("ensureSessionLease live: %v", err) |
| 317 | } |
| 318 | app.detachedSessions[sessionRuntimeKey(livePath)] = live |
| 319 | |
| 320 | ticket, err := app.StartTopicActivation(TopicActivationRequest{ |
| 321 | Scope: "global", |
| 322 | TopicID: "conversation", |
| 323 | SessionPath: parent, |
| 324 | RequestID: "req-covering-parent", |
| 325 | }) |
| 326 | if err != nil { |
| 327 | t.Fatalf("StartTopicActivation: %v", err) |
| 328 | } |
| 329 | events.waitFor(t, activationEventFor("req-covering-parent", "ready")) |
| 330 | |
| 331 | app.mu.RLock() |
| 332 | tab := app.tabs[ticket.TabID] |
| 333 | app.mu.RUnlock() |
| 334 | if tab == nil { |
| 335 | t.Fatal("activated tab missing") |
| 336 | } |
| 337 | if tab.Ctrl == stub { |
| 338 | t.Fatal("recovery parent selection reused an unrelated same-topic controller") |
| 339 | } |
| 340 | assertActivatedSourceMapping(t, app, tab, leaf) |
| 341 | if ticket.Meta.SessionPath != "" && sessionRuntimeKey(ticket.Meta.SessionPath) != sessionRuntimeKey(leaf) { |
| 342 | t.Fatalf("ticket session path = %q, want recovery leaf %q", ticket.Meta.SessionPath, leaf) |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | func assertActivatedSourceMapping(t *testing.T, app *App, tab *WorkspaceTab, sourcePath string) { |
| 347 | t.Helper() |
| 348 | state, err := app.workspaceRegistry().Load(t.Context()) |
| 349 | if err != nil { |
| 350 | t.Fatal(err) |
| 351 | } |
| 352 | for _, mapping := range state.SourceMappings { |
| 353 | if sessionRuntimeKey(mapping.Path) == sessionRuntimeKey(sourcePath) && mapping.SessionID == tab.SessionID && tab.SessionID != "" { |
| 354 | return |
| 355 | } |
| 356 | } |
| 357 | t.Fatalf("activated session %q has no verified mapping for %q: %#v", tab.SessionID, sourcePath, state.SourceMappings) |
| 358 | } |
| 359 |