| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "runtime" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/agent" |
| 11 | "reasonix/internal/config" |
| 12 | "reasonix/internal/control" |
| 13 | "reasonix/internal/event" |
| 14 | "reasonix/internal/provider" |
| 15 | "reasonix/internal/session" |
| 16 | ) |
| 17 | |
| 18 | func TestBootLegacyTabReusesMigratedIdentityAfterMetadataRefresh(t *testing.T) { |
| 19 | for _, dag := range []bool{false, true} { |
| 20 | name := "linear" |
| 21 | if dag { |
| 22 | name = "selected DAG head" |
| 23 | } |
| 24 | t.Run(name, func(t *testing.T) { testBootLegacyAdoption(t, dag) }) |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | func testBootLegacyAdoption(t *testing.T, dag bool) { |
| 29 | isolateDesktopUserDirs(t) |
| 30 | path := filepath.Join(config.SessionDir(), "boot # %20 中文.jsonl") |
| 31 | if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | messageCount := 1 |
| 35 | if dag { |
| 36 | legacy := agent.NewSession("system") |
| 37 | legacy.Add(provider.Message{ID: "user", Role: provider.RoleUser, Content: "preserved history"}) |
| 38 | if err := legacy.Save(path); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | messageCount = 2 |
| 42 | } else if err := os.WriteFile(path, []byte("{\"role\":\"user\",\"content\":\"preserved history\"}\n"), 0600); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | var expected session.SessionRef |
| 46 | for attempt := range 2 { |
| 47 | app := NewApp() |
| 48 | app.ctx = t.Context() |
| 49 | t.Cleanup(app.closeSessionServices) |
| 50 | if err := app.migrateLegacyDirectory(t.Context(), desktopMigrationSource{root: filepath.Dir(path), scope: "global", exact: map[string]bool{path: true}}); err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | ref, found, err := app.legacyCanonicalRef(t.Context(), path) |
| 54 | if err != nil || !found { |
| 55 | t.Fatalf("adoption: %v, %v", found, err) |
| 56 | } |
| 57 | if attempt == 0 { |
| 58 | expected = ref |
| 59 | } else if ref != expected { |
| 60 | t.Fatalf("restart changed identity: %v -> %v", expected, ref) |
| 61 | } |
| 62 | // Catalog refresh changes an import sidecar without changing history. |
| 63 | meta, _, err := agent.LoadBranchMeta(path) |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | meta.TopicTitle = "refreshed display title" |
| 68 | if err := agent.SaveBranchMeta(path, meta); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | ctrl := control.New(control.Options{ |
| 72 | Executor: agent.New(nil, nil, agent.NewSession("test"), agent.Options{}, event.Discard), |
| 73 | SessionService: app.desktopSessionService(""), ExclusiveSession: true, |
| 74 | }) |
| 75 | t.Cleanup(ctrl.Close) |
| 76 | lookup := path |
| 77 | if runtime.GOOS == "windows" { |
| 78 | lookup = strings.ToLower(filepath.VolumeName(path)) + path[len(filepath.VolumeName(path)):] |
| 79 | } |
| 80 | got, _, err := app.bindTabCanonicalSession(t.Context(), ctrl, &config.Config{}, "global", "", "", lookup, "", false) |
| 81 | if err != nil || got != expected { |
| 82 | t.Fatalf("boot must open adopted identity: got=%v want=%v err=%v", got, expected, err) |
| 83 | } |
| 84 | page, err := app.ReadSessionHistory(got, "", 10) |
| 85 | if err != nil || len(page.Messages) != messageCount || page.Messages[messageCount-1].Content != "preserved history" { |
| 86 | t.Fatalf("history=%+v err=%v", page, err) |
| 87 | } |
| 88 | tab := &WorkspaceTab{ID: "adopted", Scope: "global", Ctrl: ctrl, SessionID: got.SessionID, Ready: true} |
| 89 | tab.sink = &tabEventSink{tabID: tab.ID, app: app} |
| 90 | app.tabs[tab.ID], app.tabOrder, app.activeTabID = tab, []string{tab.ID}, tab.ID |
| 91 | installNoopRuntimeEvents(app, tab.sink) |
| 92 | if _, err := app.continueLegacySessionForTranscript(tab, ctrl, lookup, 10, true, false); err != nil { |
| 93 | t.Fatalf("transcript continuation: %v", err) |
| 94 | } |
| 95 | list, err := app.desktopSessionService("").Query().List(t.Context(), "", 10) |
| 96 | if err != nil || len(list.Sessions) != 1 { |
| 97 | t.Fatalf("duplicate import: %+v, %v", list, err) |
| 98 | } |
| 99 | if attempt == 1 && !dag { |
| 100 | original, err := os.ReadFile(path) |
| 101 | if err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | if err := os.Remove(path); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | got, _, err := app.bindTabCanonicalSession(t.Context(), ctrl, &config.Config{}, "global", "", "", lookup, "", false) |
| 108 | if err != nil || got != expected { |
| 109 | t.Fatalf("missing retained source replaced adoption: %v %v", got, err) |
| 110 | } |
| 111 | if err := os.WriteFile(path, append(original, []byte("{\"role\":\"user\",\"content\":\"changed\"}\n")...), 0600); err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | if got, _, err := app.bindTabCanonicalSession(t.Context(), ctrl, &config.Config{}, "global", "", "", lookup, "", false); err != nil || got != expected { |
| 115 | t.Fatalf("retained source changes must not replace the adopted conversation: %v %v", got, err) |
| 116 | } |
| 117 | if ref, _ := ctrl.SessionRef(); ref != expected { |
| 118 | t.Fatalf("failed bind replaced original: %v", ref) |
| 119 | } |
| 120 | } |
| 121 | ctrl.Close() |
| 122 | app.closeSessionServices() |
| 123 | } |
| 124 | if _, err := os.Stat(path); err != nil { |
| 125 | t.Fatalf("source lost: %v", err) |
| 126 | } |
| 127 | } |
| 128 |