| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "reasonix/internal/agent" |
| 10 | "reasonix/internal/control" |
| 11 | "reasonix/internal/event" |
| 12 | "reasonix/internal/session" |
| 13 | "reasonix/internal/taskmonitor" |
| 14 | ) |
| 15 | |
| 16 | func TestAuditCanonicalRestoreReturnsToSidebar(t *testing.T) { |
| 17 | isolateDesktopUserDirs(t) |
| 18 | app := NewApp() |
| 19 | app.ctx = t.Context() |
| 20 | pinDesktopSessionRoot(t, app) |
| 21 | installNoopRuntimeEvents(app, nil) |
| 22 | root := globalWorkspaceRoot() |
| 23 | dir := desktopSessionDir(root) |
| 24 | if err := os.MkdirAll(dir, 0755); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | service := app.desktopSessionService("") |
| 28 | runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "restore-audit", CWD: root, Origin: session.SessionOriginNew}) |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | ref := runtime.Ref() |
| 33 | payload, _ := json.Marshal(map[string]any{"message": map[string]any{"id": "user-1", "role": "user", "content": "preserved after restore"}}) |
| 34 | if _, err := runtime.Session().AppendBatch(t.Context(), "turn", []session.Event{{Kind: "message/complete", Payload: payload}}); err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | if _, err := runtime.Session().Flush(t.Context()); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | workspaceID, err := app.attachDesktopSession(t.Context(), "global", "", ref) |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | if err := service.Close(t.Context(), ref); err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | installSessionCatalogForTest(t, app, dir, "global", "") |
| 48 | if err := app.ArchiveCanonicalSession(ref); err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | archived, err := app.ListWorkspaceSessions(workspaceID, "", "", 10, true) |
| 52 | if err != nil || len(archived.Sessions) != 1 || !archived.Sessions[0].Archived { |
| 53 | t.Fatalf("archive fixture: %+v, %v", archived, err) |
| 54 | } |
| 55 | if err := app.RestoreCanonicalSession(ref); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | reconcileSessionCatalogForTest(t, app, dir, "global", "") |
| 59 | page, err := app.ListWorkspaceSessions(workspaceID, "", "", 10, true) |
| 60 | if err != nil || len(page.Sessions) != 1 || page.Sessions[0].Archived { |
| 61 | t.Fatalf("restore registry: %+v, %v", page, err) |
| 62 | } |
| 63 | history, err := app.ReadSessionHistory(ref, "", 10) |
| 64 | if err != nil || len(history.Messages) != 1 || history.Messages[0].Content != "preserved after restore" { |
| 65 | t.Fatalf("restore history: %+v, %v", history, err) |
| 66 | } |
| 67 | t.Log("restore removed archive marker and preserved the full test history") |
| 68 | if deleted := app.ListTrashedSessions(); len(deleted) != 0 { |
| 69 | t.Fatalf("unexpected trash: %+v", deleted) |
| 70 | } |
| 71 | topics, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 10}) |
| 72 | if err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | if len(topics.Items) == 0 { |
| 76 | t.Fatal("restore succeeded and history still exists, but sidebar has no restored session; archive and deleted lists are both empty") |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | func auditMigratedTab(t *testing.T) (*App, *WorkspaceTab, *control.Controller, string) { |
| 81 | t.Helper() |
| 82 | isolateDesktopUserDirs(t) |
| 83 | model, _ := configureSwitchableDefaultModels(t) |
| 84 | app := NewApp() |
| 85 | app.ctx = t.Context() |
| 86 | app.readyHook = func() {} |
| 87 | pinDesktopSessionRoot(t, app) |
| 88 | root := canonicalRuntimeRoot(t.TempDir()) |
| 89 | dir := desktopSessionDir(root) |
| 90 | if err := os.MkdirAll(dir, 0755); err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | if err := addProject(root, ""); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | if err := setTopicTitle(root, "audit-topic", "Audit"); err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | path := writeTopicSessionWithPrompt(t, dir, "audit.jsonl", "audit-topic", "Audit", root, "preserved history", time.Now()) |
| 100 | ctrl := control.New(control.Options{ |
| 101 | WorkspaceRoot: root, SessionDir: dir, |
| 102 | Executor: agent.New(nil, nil, agent.NewSession("test"), agent.Options{}, event.Discard), |
| 103 | SessionService: app.desktopSessionService(dir), ExclusiveSession: true, |
| 104 | OnSessionRotation: app.prepareDesktopSessionRotation, |
| 105 | }) |
| 106 | t.Cleanup(ctrl.Close) |
| 107 | ref, err := ctrl.ContinueLegacySessionWithOptions(t.Context(), path, "", desktopLegacyImportOptions(root)) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | workspaceID, err := app.attachDesktopSession(t.Context(), "project", root, ref) |
| 112 | if err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | tab := &WorkspaceTab{ID: "audit", Scope: "project", WorkspaceRoot: root, TopicID: "audit-topic", SessionID: ref.SessionID, Ctrl: ctrl, Ready: true, model: model, disabledMCP: map[string]ServerView{}} |
| 116 | tab.SessionWorkspace.ID = workspaceID |
| 117 | tab.sink = &tabEventSink{tabID: tab.ID, app: app} |
| 118 | installNoopRuntimeEvents(app, tab.sink) |
| 119 | app.tabs[tab.ID], app.tabOrder, app.activeTabID = tab, []string{tab.ID}, tab.ID |
| 120 | if err := tab.ensureSessionLease(path); err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | t.Cleanup(tab.releaseSessionLease) |
| 124 | app.mu.Lock() |
| 125 | app.newSessionRuntimeLocked(tab, sessionRuntimeKey(tab.currentSessionIdentity())) |
| 126 | app.advanceSessionRuntimeEpochLocked(tab) |
| 127 | app.mu.Unlock() |
| 128 | return app, tab, ctrl, path |
| 129 | } |
| 130 | |
| 131 | func TestAuditMigratedSessionReopen(t *testing.T) { |
| 132 | app, tab, ctrl, path := auditMigratedTab(t) |
| 133 | _, err := app.OpenTopicSession("project", tab.WorkspaceRoot, tab.TopicID, path) |
| 134 | if err != nil { |
| 135 | t.Fatalf("reopen current migrated session: %v", err) |
| 136 | } |
| 137 | if tab.Ctrl != ctrl { |
| 138 | t.Fatal("reopening current migrated session replaced its controller") |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func TestAuditCanonicalIdleClearRegistry(t *testing.T) { |
| 143 | app, tab, ctrl, _ := auditMigratedTab(t) |
| 144 | oldRef, _ := ctrl.SessionRef() |
| 145 | result, err := app.ClearSessionForTab(tab.ID) |
| 146 | if err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | newRef, _ := ctrl.SessionRef() |
| 150 | if newRef == oldRef || result.SessionID != newRef.SessionID { |
| 151 | t.Fatalf("rotation identities: old=%s new=%s result=%s", oldRef.SessionID, newRef.SessionID, result.SessionID) |
| 152 | } |
| 153 | app.mu.RLock() |
| 154 | stale := app.liveRuntimeTabMatchingLocked(nil, sessionRoute(oldRef.SessionID)) |
| 155 | app.mu.RUnlock() |
| 156 | if stale != nil { |
| 157 | t.Errorf("old session lookup still returns controller now bound to %s", newRef.SessionID) |
| 158 | } |
| 159 | target := &WorkspaceTab{ID: "target", Scope: "project", WorkspaceRoot: tab.WorkspaceRoot, Ready: true} |
| 160 | target.sink = &tabEventSink{tabID: target.ID, app: app} |
| 161 | installNoopRuntimeEvents(app, target.sink) |
| 162 | app.tabs[target.ID] = target |
| 163 | app.tabOrder = append(app.tabOrder, target.ID) |
| 164 | app.activeTabID = target.ID |
| 165 | if _, err := app.OpenSession(oldRef); err != nil { |
| 166 | t.Fatalf("reopen old canonical session: %v", err) |
| 167 | } |
| 168 | t.Cleanup(target.Ctrl.Close) |
| 169 | actual, ok := target.Ctrl.(control.IdentityLifecycle).SessionRef() |
| 170 | if !ok || actual != oldRef { |
| 171 | t.Errorf("OpenSession returned success but controller=%s UI=%s requested=%s", actual.SessionID, target.SessionID, oldRef.SessionID) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | func TestAuditCanonicalTaskFilter(t *testing.T) { |
| 176 | app, tab, _, _ := auditMigratedTab(t) |
| 177 | for _, id := range []string{tab.SessionID, "other-session"} { |
| 178 | if err := app.taskStore().SaveTask(t.Context(), tab.WorkspaceRoot, taskmonitor.TaskSnapshot{ |
| 179 | SchemaVersion: 1, TaskID: id + "--task-1", JobID: "task-1", SessionID: id, |
| 180 | State: taskmonitor.TaskStateRunning, RuntimeState: taskmonitor.RuntimeStateAlive, |
| 181 | Version: 1, CreatedAt: time.Now(), UpdatedAt: time.Now(), |
| 182 | }); err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | } |
| 186 | tasks, err := app.ListTasksForTab(tab.ID) |
| 187 | if err != nil { |
| 188 | t.Fatal(err) |
| 189 | } |
| 190 | if len(tasks) != 1 { |
| 191 | t.Errorf("current-session task list includes %d sessions, want 1", len(tasks)) |
| 192 | } |
| 193 | _, sessionID, err := app.taskProjectKeys(TaskPageRequest{Scope: "session", TabID: tab.ID}) |
| 194 | if err != nil { |
| 195 | t.Fatal(err) |
| 196 | } |
| 197 | if sessionID != tab.SessionID { |
| 198 | t.Errorf("catalog session filter = %q, want %q", sessionID, tab.SessionID) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | type auditJobController struct { |
| 203 | *control.Controller |
| 204 | killed bool |
| 205 | } |
| 206 | |
| 207 | func (c *auditJobController) CancelJob(string) bool { c.killed = true; return true } |
| 208 | func (c *auditJobController) TaskRuntimeOwnerID() string { return "audit-runtime-owner" } |
| 209 | |
| 210 | func TestAuditCanonicalTaskStop(t *testing.T) { |
| 211 | app, tab, ctrl, _ := auditMigratedTab(t) |
| 212 | wrapper := &auditJobController{Controller: ctrl} |
| 213 | tab.Ctrl = wrapper |
| 214 | id := tab.SessionID + "--task-1" |
| 215 | if err := app.taskStore().SaveTask(t.Context(), tab.WorkspaceRoot, taskmonitor.TaskSnapshot{ |
| 216 | SchemaVersion: 1, TaskID: id, JobID: "task-1", SessionID: tab.SessionID, |
| 217 | State: taskmonitor.TaskStateRunning, RuntimeState: taskmonitor.RuntimeStateAlive, |
| 218 | RuntimeOwnerID: wrapper.TaskRuntimeOwnerID(), |
| 219 | Version: 1, CreatedAt: time.Now(), UpdatedAt: time.Now(), |
| 220 | }); err != nil { |
| 221 | t.Fatal(err) |
| 222 | } |
| 223 | result, err := app.StopTaskForTab(tab.ID, id, 1, "audit", "audit-stop") |
| 224 | if err != nil { |
| 225 | t.Fatal(err) |
| 226 | } |
| 227 | if !result.Accepted || !wrapper.killed { |
| 228 | t.Fatalf("live canonical task was not stopped: accepted=%v error=%+v", result.Accepted, result.Error) |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | func TestTaskControlRejectsOldRecorderGeneration(t *testing.T) { |
| 233 | app, tab, ctrl, _ := auditMigratedTab(t) |
| 234 | wrapper := &auditJobController{Controller: ctrl} |
| 235 | tab.Ctrl = wrapper |
| 236 | for _, owner := range []string{"", "previous-runtime-owner"} { |
| 237 | id := tab.SessionID + "--task-1-" + owner |
| 238 | if err := app.taskStore().SaveTask(t.Context(), tab.WorkspaceRoot, taskmonitor.TaskSnapshot{ |
| 239 | SchemaVersion: 1, TaskID: id, JobID: "task-1", SessionID: tab.SessionID, |
| 240 | RuntimeOwnerID: owner, State: taskmonitor.TaskStateRunning, RuntimeState: taskmonitor.RuntimeStateAlive, |
| 241 | Version: 1, CreatedAt: time.Now(), UpdatedAt: time.Now(), |
| 242 | }); err != nil { |
| 243 | t.Fatal(err) |
| 244 | } |
| 245 | result, err := app.StopTaskForTab(tab.ID, id, 1, "test", "old-owner-"+owner) |
| 246 | if err != nil || result.Accepted || wrapper.killed { |
| 247 | t.Fatalf("old owner accepted: %+v %v", result, err) |
| 248 | } |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | func TestAuditMigratedCloseReleasesLease(t *testing.T) { |
| 253 | app, tab, _, path := auditMigratedTab(t) |
| 254 | keep := &WorkspaceTab{ID: "keep", Scope: "project", WorkspaceRoot: tab.WorkspaceRoot, Ready: true} |
| 255 | app.tabs[keep.ID] = keep |
| 256 | app.tabOrder = append(app.tabOrder, keep.ID) |
| 257 | if err := app.CloseTab(tab.ID); err != nil { |
| 258 | t.Fatal(err) |
| 259 | } |
| 260 | lease, err := agent.TryAcquireSessionLease(path) |
| 261 | if err != nil { |
| 262 | t.Fatalf("closed migrated tab retained legacy lease: %v", err) |
| 263 | } |
| 264 | lease.Release() |
| 265 | } |
| 266 | |
| 267 | func TestAuditTopicArchiveCanonicalVisibility(t *testing.T) { |
| 268 | app, tab, _, _ := auditMigratedTab(t) |
| 269 | keep := &WorkspaceTab{ID: "keep", Scope: "project", WorkspaceRoot: tab.WorkspaceRoot, TopicID: "keep", Ready: true} |
| 270 | app.tabs[keep.ID] = keep |
| 271 | app.tabOrder = append(app.tabOrder, keep.ID) |
| 272 | if err := app.TrashTopic(tab.TopicID); err != nil { |
| 273 | t.Fatal(err) |
| 274 | } |
| 275 | page, err := app.ListWorkspaceSessions(tab.SessionWorkspace.ID, "", "", 10, false) |
| 276 | if err != nil { |
| 277 | t.Fatal(err) |
| 278 | } |
| 279 | for _, row := range page.Sessions { |
| 280 | if row.Ref.SessionID == tab.SessionID { |
| 281 | t.Fatalf("archived topic remains visible as canonical session %s", row.Ref.SessionID) |
| 282 | } |
| 283 | } |
| 284 | } |
| 285 |