| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/base64" |
| 6 | "encoding/json" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "reasonix/internal/agent" |
| 14 | "reasonix/internal/boot" |
| 15 | "reasonix/internal/control" |
| 16 | "reasonix/internal/event" |
| 17 | "reasonix/internal/provider" |
| 18 | "reasonix/internal/session" |
| 19 | ) |
| 20 | |
| 21 | func TestTabMetaDoesNotCompareLegacyFingerprintWithCanonicalSession(t *testing.T) { |
| 22 | isolateDesktopUserDirs(t) |
| 23 | app := NewApp() |
| 24 | t.Cleanup(app.closeSessionServices) |
| 25 | sessionPath := filepath.Join(t.TempDir(), "legacy.jsonl") |
| 26 | if err := os.WriteFile(sessionPath, nil, 0o600); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | if err := agent.SaveBranchMetaPreserveUpdated(sessionPath, agent.BranchMeta{ |
| 30 | ID: "legacy", Revision: 41, ContentDigest: "legacy-digest", |
| 31 | }); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | tab := &WorkspaceTab{ |
| 35 | ID: "canonical-without-head", SessionID: "missing-canonical-session", |
| 36 | SessionPath: sessionPath, disabledMCP: map[string]ServerView{}, |
| 37 | } |
| 38 | meta := app.tabMeta(tab, true) |
| 39 | if meta.SessionRevision != 0 || meta.SessionDigest != "" { |
| 40 | t.Fatalf("canonical tab fell back to legacy fingerprint (%d, %q)", meta.SessionRevision, meta.SessionDigest) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | func TestDesktopHistorySliceUsesCanonicalDurableIndex(t *testing.T) { |
| 45 | isolateDesktopUserDirs(t) |
| 46 | model, _ := configureSwitchableDefaultModels(t) |
| 47 | app := NewApp() |
| 48 | t.Cleanup(app.closeSessionServices) |
| 49 | app.ctx = context.Background() |
| 50 | root := t.TempDir() |
| 51 | dir := desktopSessionDir(root) |
| 52 | service := app.desktopSessionService(dir) |
| 53 | runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "canonical-history"}) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | appendSessionTestMessage(t, runtime, "history-user", provider.Message{ID: "history-user", Role: provider.RoleUser, Origin: provider.MessageOriginUser, Content: "durable user turn"}) |
| 58 | |
| 59 | ctrl, err := app.buildTabControllerBoot(app.ctx, boot.Options{Model: model, WorkspaceRoot: root, SessionDir: dir, Sink: event.Discard}) |
| 60 | if err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | if _, err := ctrl.(control.IdentityLifecycle).OpenSession(t.Context(), runtime.Ref()); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | large := "durable assistant " + strings.Repeat("x", historyInlineRefThreshold+1024) |
| 67 | // Append after the controller's agent projection was created. The legacy |
| 68 | // live-history path cannot see this message; the canonical query can. |
| 69 | appendSessionTestMessage(t, runtime, "history-assistant", provider.Message{ID: "history-assistant", Role: provider.RoleAssistant, Content: large}) |
| 70 | legacyPath := filepath.Join(dir, "legacy-projection.jsonl") |
| 71 | if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | if err := os.WriteFile(legacyPath, nil, 0o600); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | tab := &WorkspaceTab{ID: "canonical-history-tab", Scope: "project", WorkspaceRoot: root, SessionID: runtime.Ref().SessionID, SessionPath: legacyPath, Ready: true, Ctrl: ctrl, sink: &tabEventSink{tabID: "canonical-history-tab", app: app}, disabledMCP: map[string]ServerView{}} |
| 78 | app.tabs = map[string]*WorkspaceTab{tab.ID: tab} |
| 79 | app.tabOrder = []string{tab.ID} |
| 80 | app.activeTabID = tab.ID |
| 81 | t.Cleanup(func() { ctrl.Close() }) |
| 82 | |
| 83 | openView, err := app.SessionOpenForTab(tab.ID) |
| 84 | if err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | if err := agent.SaveBranchMetaPreserveUpdated(legacyPath, agent.BranchMeta{ |
| 88 | ID: "legacy-projection", |
| 89 | Revision: int64(openView.SnapshotSequence) + 100, |
| 90 | ContentDigest: "legacy-projection-digest", |
| 91 | }); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | meta := app.tabMeta(tab, true) |
| 95 | if meta.SessionRevision != int64(openView.SnapshotSequence) || meta.SessionDigest != openView.StorageGeneration { |
| 96 | t.Fatalf("tab canonical fingerprint = (%d, %q), want (%d, %q)", |
| 97 | meta.SessionRevision, meta.SessionDigest, openView.SnapshotSequence, openView.StorageGeneration) |
| 98 | } |
| 99 | |
| 100 | page := app.HistorySliceForTab(tab.ID, HistorySliceRequest{Turns: 12, Entries: 120, Bytes: 512 << 10}) |
| 101 | if page.Error != "" || page.Source != "canonical-index" { |
| 102 | t.Fatalf("canonical history page = source %q error %q", page.Source, page.Error) |
| 103 | } |
| 104 | if page.TotalTurns != 1 || len(page.Entries) != 2 { |
| 105 | t.Fatalf("canonical history shape = turns %d entries %d, want 1/2", page.TotalTurns, len(page.Entries)) |
| 106 | } |
| 107 | assistant := page.Entries[1] |
| 108 | if len(assistant.Refs) != 1 || assistant.Refs[0].Field != "content" { |
| 109 | t.Fatalf("large canonical message refs = %+v", assistant.Refs) |
| 110 | } |
| 111 | chunk := app.HistoryContentForTab(tab.ID, assistant.Refs[0], 0) |
| 112 | if chunk.Stale || !chunk.Done || chunk.Data != large { |
| 113 | t.Fatalf("canonical expanded content = stale:%v done:%v bytes:%d, want %d", chunk.Stale, chunk.Done, len(chunk.Data), len(large)) |
| 114 | } |
| 115 | |
| 116 | appendSessionTestMessage(t, runtime, "history-next", provider.Message{ID: "history-next", Role: provider.RoleUser, Origin: provider.MessageOriginUser, Content: "new turn"}) |
| 117 | if stale := app.HistoryContentForTab(tab.ID, assistant.Refs[0], 0); !stale.Stale { |
| 118 | t.Fatal("content ref from older durable snapshot must become stale after append") |
| 119 | } |
| 120 | // Stop/recovery keeps message identities while rewriting their transcript. |
| 121 | // All desktop readers must accept the runtime's reason metadata and index |
| 122 | // new versions without colliding with the previously displayed messages. |
| 123 | payload, err := json.Marshal(map[string]any{ |
| 124 | "reason": "cancel-or-recovery-rewrite", |
| 125 | "messages": []provider.Message{ |
| 126 | {ID: "history-user", Role: provider.RoleUser, Origin: provider.MessageOriginUser, Content: "durable user turn"}, |
| 127 | {ID: "history-assistant", Role: provider.RoleAssistant, Content: large}, |
| 128 | }, |
| 129 | }) |
| 130 | if err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | if _, err := runtime.Session().Append(t.Context(), session.Batch{OperationID: "cancel-rewrite", Events: []session.Event{{Kind: "history/replace", Payload: payload}}}); err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | if _, err := runtime.Session().Flush(t.Context()); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | if page := app.HistorySliceForTab(tab.ID, HistorySliceRequest{Turns: 12}); page.Error != "" || len(page.Entries) != 2 { |
| 140 | t.Fatalf("compatibility reader after cancel rewrite: %+v", page) |
| 141 | } |
| 142 | if page, err := app.SessionHistoryWindowForTab(tab.ID, session.HistoryWindowRequest{Anchor: "newest"}); err != nil || page.Status != "ready" || len(page.Messages) != 2 { |
| 143 | t.Fatalf("window reader after cancel rewrite: %+v, %v", page, err) |
| 144 | } |
| 145 | if page, err := app.SessionHistoryPageForTab(tab.ID, "", 10); err != nil || page.Status != "ready" || len(page.Messages) != 2 { |
| 146 | t.Fatalf("page reader after cancel rewrite: %+v, %v", page, err) |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | func TestDesktopCanonicalHistoryRemainsReadableBeforeControllerReady(t *testing.T) { |
| 151 | isolateDesktopUserDirs(t) |
| 152 | app := NewApp() |
| 153 | t.Cleanup(app.closeSessionServices) |
| 154 | root := t.TempDir() |
| 155 | dir := desktopSessionDir(root) |
| 156 | service := app.desktopSessionService(dir) |
| 157 | runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "canonical-cold-history"}) |
| 158 | if err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | appendSessionTestMessage(t, runtime, "cold-history-user", provider.Message{ |
| 162 | ID: "cold-history-user", |
| 163 | Role: provider.RoleUser, |
| 164 | Origin: provider.MessageOriginUser, |
| 165 | Content: "history survives an unavailable configured model", |
| 166 | }) |
| 167 | large := "large history survives too " + strings.Repeat("x", historyInlineRefThreshold+1024) |
| 168 | appendSessionTestMessage(t, runtime, "cold-history-assistant", provider.Message{ |
| 169 | ID: "cold-history-assistant", Role: provider.RoleAssistant, Content: large, |
| 170 | }) |
| 171 | workspaceID, err := app.ensureDesktopWorkspace(t.Context(), "project", root) |
| 172 | if err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | if err := app.workspaceRegistry().AttachSession(t.Context(), "", workspaceID, runtime.Ref().SessionID, ""); err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | |
| 179 | tab := &WorkspaceTab{ |
| 180 | ID: "canonical-cold-history-tab", |
| 181 | Scope: "project", |
| 182 | WorkspaceRoot: root, |
| 183 | SessionID: runtime.Ref().SessionID, |
| 184 | Ready: false, |
| 185 | Ctrl: nil, |
| 186 | } |
| 187 | app.tabs = map[string]*WorkspaceTab{tab.ID: tab} |
| 188 | app.tabOrder = []string{tab.ID} |
| 189 | app.activeTabID = tab.ID |
| 190 | |
| 191 | page := app.HistorySliceForTab(tab.ID, HistorySliceRequest{Turns: 12}) |
| 192 | if page.Error != "" || page.Source != "canonical-index" { |
| 193 | t.Fatalf("cold canonical history page = source %q error %q", page.Source, page.Error) |
| 194 | } |
| 195 | if len(page.Entries) != 2 || page.Entries[0].Message.Content != "history survives an unavailable configured model" { |
| 196 | t.Fatalf("cold canonical history entries = %+v", page.Entries) |
| 197 | } |
| 198 | if len(page.Entries[1].Refs) != 1 { |
| 199 | t.Fatalf("cold canonical large history refs = %+v", page.Entries[1].Refs) |
| 200 | } |
| 201 | content := app.HistoryContentForTab(tab.ID, page.Entries[1].Refs[0], 0) |
| 202 | if content.Stale || !content.Done || content.Data != large { |
| 203 | t.Fatalf("cold canonical expanded history = stale:%v done:%v bytes:%d, want %d", content.Stale, content.Done, len(content.Data), len(large)) |
| 204 | } |
| 205 | |
| 206 | opened, err := app.SessionOpenForTab(tab.ID) |
| 207 | if err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | if opened.Ref != runtime.Ref() || len(opened.Recent.Entries) != 2 || opened.Recent.Entries[0].Preview != "history survives an unavailable configured model" { |
| 211 | t.Fatalf("cold canonical session open = %+v", opened) |
| 212 | } |
| 213 | |
| 214 | canonical, err := app.SessionHistoryPageForTab(tab.ID, "", 12) |
| 215 | if err != nil { |
| 216 | t.Fatal(err) |
| 217 | } |
| 218 | if len(canonical.Messages) != 2 || canonical.Messages[0].Preview != "history survives an unavailable configured model" { |
| 219 | t.Fatalf("cold canonical history records = %+v", canonical.Messages) |
| 220 | } |
| 221 | var search session.SearchHistoryPage |
| 222 | for deadline := time.Now().Add(5 * time.Second); search.Status != "ready"; time.Sleep(time.Millisecond) { |
| 223 | search, err = app.SearchSessionHistoryForTab(tab.ID, "unavailable configured model", "", 12) |
| 224 | if err != nil || (search.Status != "preparing" && search.Status != "ready") || time.Now().After(deadline) { |
| 225 | t.Fatalf("cold canonical search = %+v, %v", search, err) |
| 226 | } |
| 227 | } |
| 228 | if len(search.Hits) != 1 || search.Hits[0].MessageID != "cold-history-user" { |
| 229 | t.Fatalf("cold canonical search hits = %+v", search.Hits) |
| 230 | } |
| 231 | location, err := app.LocateSessionMessageForTab(tab.ID, "cold-history-assistant", canonical.SnapshotSequence) |
| 232 | if err != nil || location.Status != "ready" || location.MessageID != "cold-history-assistant" { |
| 233 | t.Fatalf("cold canonical location = %+v, %v", location, err) |
| 234 | } |
| 235 | ref := canonical.Messages[1].ContentRef |
| 236 | if ref == nil { |
| 237 | t.Fatal("cold canonical large message has no content ref") |
| 238 | } |
| 239 | chunk, err := app.SessionHistoryContentForTab(tab.ID, *ref, 0) |
| 240 | decoded, decodeErr := base64.StdEncoding.DecodeString(chunk.Data) |
| 241 | if err != nil || decodeErr != nil || !chunk.Done || !strings.Contains(string(decoded), large) { |
| 242 | t.Fatalf("cold canonical content = done:%v bytes:%d, errors:%v/%v", chunk.Done, len(decoded), err, decodeErr) |
| 243 | } |
| 244 | |
| 245 | // Explicit target readers remain bound to the durable session even after |
| 246 | // the tab disappears. |
| 247 | app.tabs = map[string]*WorkspaceTab{} |
| 248 | app.tabOrder = nil |
| 249 | app.activeTabID = "" |
| 250 | selector := SessionSelector{Ref: &session.SessionRef{HostID: localDesktopHostID, SessionID: runtime.Ref().SessionID}} |
| 251 | compatTarget, err := app.HistorySliceForTarget(selector, HistorySliceRequest{Turns: 12}) |
| 252 | if err != nil || len(compatTarget.Entries) != 2 || len(compatTarget.Entries[1].Refs) != 1 { |
| 253 | t.Fatalf("target compatibility history = %+v, %v", compatTarget, err) |
| 254 | } |
| 255 | compatChunk, err := app.HistoryContentForTarget(selector, compatTarget.Entries[1].Refs[0], 0) |
| 256 | if err != nil || compatChunk.Stale || !compatChunk.Done || compatChunk.Data != large { |
| 257 | t.Fatalf("target compatibility content = %+v, %v", compatChunk, err) |
| 258 | } |
| 259 | targetPage, err := app.SessionHistoryPageForTarget(selector, "", 12) |
| 260 | if err != nil || len(targetPage.Messages) != 2 { |
| 261 | t.Fatalf("target history page = %+v, %v", targetPage, err) |
| 262 | } |
| 263 | targetLocation, err := app.LocateSessionMessageForTarget(selector, "cold-history-assistant", targetPage.SnapshotSequence) |
| 264 | if err != nil || targetLocation.Status != "ready" || targetLocation.MessageID != "cold-history-assistant" { |
| 265 | t.Fatalf("target location = %+v, %v", targetLocation, err) |
| 266 | } |
| 267 | targetRef := targetPage.Messages[1].ContentRef |
| 268 | if targetRef == nil { |
| 269 | t.Fatal("target large message has no content ref") |
| 270 | } |
| 271 | targetChunk, err := app.SessionHistoryContentForTarget(selector, *targetRef, 0) |
| 272 | targetDecoded, decodeErr := base64.StdEncoding.DecodeString(targetChunk.Data) |
| 273 | if err != nil || decodeErr != nil || !targetChunk.Done || !strings.Contains(string(targetDecoded), large) { |
| 274 | t.Fatalf("target content = done:%v bytes:%d, errors:%v/%v", targetChunk.Done, len(targetDecoded), err, decodeErr) |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | func appendSessionTestMessage(t *testing.T, runtime *session.Runtime, operationID string, message provider.Message) { |
| 279 | t.Helper() |
| 280 | payload, err := json.Marshal(map[string]any{"message": message}) |
| 281 | if err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | if _, err := runtime.Session().AppendBatch(t.Context(), operationID, []session.Event{{Kind: "message/complete", Payload: payload}}); err != nil { |
| 285 | t.Fatal(err) |
| 286 | } |
| 287 | if _, err := runtime.Session().Flush(t.Context()); err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | func appendSessionTestModel(t *testing.T, runtime *session.Runtime, operationID, modelRef string) { |
| 293 | t.Helper() |
| 294 | payload, err := json.Marshal(map[string]string{"modelRef": modelRef}) |
| 295 | if err != nil { |
| 296 | t.Fatal(err) |
| 297 | } |
| 298 | if _, err := runtime.Session().AppendBatch(t.Context(), operationID, []session.Event{{Kind: "session/config", Payload: payload}}); err != nil { |
| 299 | t.Fatal(err) |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | func TestDesktopV3CatalogResumeRenameAndDeleteUseSessionIdentity(t *testing.T) { |
| 304 | isolateDesktopUserDirs(t) |
| 305 | model, targetModel := configureSwitchableDefaultModels(t) |
| 306 | app := NewApp() |
| 307 | t.Cleanup(app.closeSessionServices) |
| 308 | app.ctx = context.Background() |
| 309 | root := t.TempDir() |
| 310 | dir := desktopSessionDir(root) |
| 311 | service := app.desktopSessionService(dir) |
| 312 | |
| 313 | first, err := service.Create(t.Context(), session.CreateOptions{ |
| 314 | SessionID: "first-v3", CWD: root, Origin: session.SessionOriginNew, |
| 315 | }) |
| 316 | if err != nil { |
| 317 | t.Fatal(err) |
| 318 | } |
| 319 | appendSessionTestModel(t, first, "first-model", model) |
| 320 | appendSessionTestMessage(t, first, "first-message", provider.Message{ID: "user-first", Role: provider.RoleUser, Content: "first conversation"}) |
| 321 | second, err := service.Create(t.Context(), session.CreateOptions{ |
| 322 | SessionID: "second-v3", CWD: root, Origin: session.SessionOriginNew, |
| 323 | }) |
| 324 | if err != nil { |
| 325 | t.Fatal(err) |
| 326 | } |
| 327 | appendSessionTestModel(t, second, "second-model", targetModel) |
| 328 | appendSessionTestMessage(t, second, "second-message", provider.Message{ID: "user-second", Role: provider.RoleUser, Content: "second conversation"}) |
| 329 | for _, ref := range []session.SessionRef{first.Ref(), second.Ref()} { |
| 330 | if _, err := app.attachDesktopSession(t.Context(), "project", root, ref); err != nil { |
| 331 | t.Fatal(err) |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | ctrl, err := app.buildTabControllerBoot(app.ctx, boot.Options{Model: model, WorkspaceRoot: root, SessionDir: dir, Sink: event.Discard}) |
| 336 | if err != nil { |
| 337 | t.Fatal(err) |
| 338 | } |
| 339 | identity := ctrl.(control.IdentityLifecycle) |
| 340 | if _, err := identity.OpenSession(t.Context(), first.Ref()); err != nil { |
| 341 | t.Fatal(err) |
| 342 | } |
| 343 | tab := &WorkspaceTab{ID: "v3-tab", Scope: "project", WorkspaceRoot: root, SessionID: first.Ref().SessionID, Ready: true, Ctrl: ctrl, sink: &tabEventSink{tabID: "v3-tab", app: app}, disabledMCP: map[string]ServerView{}} |
| 344 | app.tabs[tab.ID] = tab |
| 345 | app.tabOrder = []string{tab.ID} |
| 346 | app.activeTabID = tab.ID |
| 347 | app.newSessionRuntimeLocked(tab, sessionRuntimeKey(tab.currentSessionIdentity())) |
| 348 | t.Cleanup(func() { |
| 349 | if live := app.controllerForTab(tab); live != nil { |
| 350 | live.Close() |
| 351 | } |
| 352 | }) |
| 353 | |
| 354 | rows := app.ListSessions() |
| 355 | if len(rows) != 2 || rows[0].SessionID == "" || rows[1].SessionID == "" { |
| 356 | t.Fatalf("v3 catalog rows = %+v", rows) |
| 357 | } |
| 358 | if _, err := app.ResumeSessionForTab(tab.ID, sessionRoute(second.Ref().SessionID)); err != nil { |
| 359 | t.Fatal(err) |
| 360 | } |
| 361 | if tab.SessionID != second.Ref().SessionID || tab.SessionPath != "" { |
| 362 | t.Fatalf("resumed identity = id %q path %q", tab.SessionID, tab.SessionPath) |
| 363 | } |
| 364 | if tab.Ctrl == ctrl || tab.Ctrl.ModelRef() != targetModel { |
| 365 | t.Fatalf("target model runtime = ctrl changed %v model %q, want true/%q", tab.Ctrl != ctrl, tab.Ctrl.ModelRef(), targetModel) |
| 366 | } |
| 367 | if got := tab.Ctrl.History(); len(got) != 1 || got[0].Content != "second conversation" { |
| 368 | t.Fatalf("resumed history = %+v", got) |
| 369 | } |
| 370 | if err := app.RenameSession(sessionRoute(second.Ref().SessionID), "renamed v3"); err != nil { |
| 371 | t.Fatal(err) |
| 372 | } |
| 373 | if snap, err := service.Query().Snapshot(t.Context(), second.Ref()); err != nil || snap.Projection.Title != "renamed v3" { |
| 374 | t.Fatalf("renamed snapshot = %+v, %v", snap, err) |
| 375 | } |
| 376 | if err := app.DeleteSession(sessionRoute(second.Ref().SessionID)); err != nil { |
| 377 | t.Fatal(err) |
| 378 | } |
| 379 | if !tab.removed { |
| 380 | t.Fatal("archived runtime remains attached") |
| 381 | } |
| 382 | state, err := app.workspaceRegistry().Load(t.Context()) |
| 383 | if err != nil || state.SessionStates[second.Ref().SessionID].Lifecycle != "archived" { |
| 384 | t.Fatalf("archive state=%+v err=%v", state, err) |
| 385 | } |
| 386 | if _, err := service.Query().Snapshot(t.Context(), second.Ref()); err != nil { |
| 387 | t.Fatalf("archive lost canonical content: %v", err) |
| 388 | } |
| 389 | } |
| 390 | |
| 391 | func TestDesktopSessionRefOpenFallsBackAndPublishesHydrationReady(t *testing.T) { |
| 392 | isolateDesktopUserDirs(t) |
| 393 | model, fallbackModel := configureSwitchableDefaultModels(t) |
| 394 | app := NewApp() |
| 395 | t.Cleanup(app.closeSessionServices) |
| 396 | app.ctx = context.Background() |
| 397 | root := t.TempDir() |
| 398 | dir := desktopSessionDir(root) |
| 399 | service := app.desktopSessionService(dir) |
| 400 | |
| 401 | source, err := service.Create(t.Context(), session.CreateOptions{SessionID: "resume-source"}) |
| 402 | if err != nil { |
| 403 | t.Fatal(err) |
| 404 | } |
| 405 | appendSessionTestModel(t, source, "source-model", model) |
| 406 | appendSessionTestMessage(t, source, "source-message", provider.Message{ID: "source-user", Role: provider.RoleUser, Content: "source remains"}) |
| 407 | target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "resume-broken-target", CWD: root, Origin: session.SessionOriginNew}) |
| 408 | if err != nil { |
| 409 | t.Fatal(err) |
| 410 | } |
| 411 | appendSessionTestModel(t, target, "target-model", "missing/model") |
| 412 | appendSessionTestMessage(t, target, "target-message", provider.Message{ID: "target-user", Role: provider.RoleUser, Content: "target restored"}) |
| 413 | if _, err := app.attachDesktopSession(t.Context(), "project", root, target.Ref()); err != nil { |
| 414 | t.Fatal(err) |
| 415 | } |
| 416 | |
| 417 | ctrl, err := app.buildTabControllerBoot(app.ctx, boot.Options{Model: model, WorkspaceRoot: root, SessionDir: dir, Sink: event.Discard}) |
| 418 | if err != nil { |
| 419 | t.Fatal(err) |
| 420 | } |
| 421 | identity := ctrl.(control.IdentityLifecycle) |
| 422 | if _, err := identity.OpenSession(t.Context(), source.Ref()); err != nil { |
| 423 | t.Fatal(err) |
| 424 | } |
| 425 | tab := &WorkspaceTab{ID: "source-tab", Scope: "project", WorkspaceRoot: root, SessionID: source.Ref().SessionID, Ready: true, Ctrl: ctrl, model: model, sink: &tabEventSink{tabID: "source-tab", app: app}, disabledMCP: map[string]ServerView{}} |
| 426 | app.tabs[tab.ID] = tab |
| 427 | app.tabOrder = []string{tab.ID} |
| 428 | app.activeTabID = tab.ID |
| 429 | app.newSessionRuntimeLocked(tab, sessionRuntimeKey(tab.currentSessionIdentity())) |
| 430 | t.Cleanup(func() { |
| 431 | if live := app.controllerForTab(tab); live != nil { |
| 432 | live.Close() |
| 433 | } |
| 434 | }) |
| 435 | |
| 436 | readySignals := 0 |
| 437 | app.readyHook = func() { readySignals++ } |
| 438 | if _, err := app.OpenSession(target.Ref()); err != nil { |
| 439 | t.Fatal(err) |
| 440 | } |
| 441 | if readySignals != 1 { |
| 442 | t.Fatalf("SessionRef open ready signals = %d, want 1", readySignals) |
| 443 | } |
| 444 | if tab.Ctrl == ctrl || tab.SessionID != target.Ref().SessionID || tab.SessionPath != "" || tab.Ctrl.ModelRef() != fallbackModel { |
| 445 | t.Fatalf("fallback binding: replaced=%v session=%q path=%q model=%q", tab.Ctrl != ctrl, tab.SessionID, tab.SessionPath, tab.Ctrl.ModelRef()) |
| 446 | } |
| 447 | if got := tab.Ctrl.History(); len(got) != 1 || got[0].Content != "target restored" { |
| 448 | t.Fatalf("fallback history = %+v", got) |
| 449 | } |
| 450 | if snapshot, err := service.Query().Snapshot(t.Context(), target.Ref()); err != nil || snapshot.Projection.ModelRef != fallbackModel { |
| 451 | t.Fatalf("fallback config was not persisted on target: %+v, %v", snapshot.Projection, err) |
| 452 | } |
| 453 | } |
| 454 |