| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/checkpoint" |
| 13 | "reasonix/internal/config" |
| 14 | "reasonix/internal/control" |
| 15 | "reasonix/internal/event" |
| 16 | "reasonix/internal/provider" |
| 17 | "reasonix/internal/sessioncatalog" |
| 18 | "reasonix/internal/store" |
| 19 | ) |
| 20 | |
| 21 | // schemaTwoTabFixture opens tab "test" on a five-message schema-2 session with |
| 22 | // a checkpoint at turn 1 whose boundary keeps the first three messages, and a |
| 23 | // workspace file the checkpoint can restore. |
| 24 | type schemaTwoTabFixture struct { |
| 25 | app *App |
| 26 | ctrl *control.Controller |
| 27 | session *agent.Session |
| 28 | path string |
| 29 | filePath string |
| 30 | } |
| 31 | |
| 32 | func newSchemaTwoTabFixture(t *testing.T) schemaTwoTabFixture { |
| 33 | t.Helper() |
| 34 | isolateDesktopUserDirs(t) |
| 35 | dir := config.SessionDir() |
| 36 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | root := robustTempDir(t) |
| 40 | if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(""), 0o644); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | path := agent.NewSessionPath(dir, "heads") |
| 44 | ckptDir := strings.TrimSuffix(path, ".jsonl") + ".ckpt" |
| 45 | if err := os.MkdirAll(ckptDir, 0o755); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | filePath := filepath.Join(root, "a.txt") |
| 49 | if err := os.WriteFile(filePath, []byte("after"), 0o644); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | info, err := os.Stat(filePath) |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | mode := uint32(info.Mode().Perm()) |
| 57 | before, afterExists := "before", true |
| 58 | seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{ |
| 59 | SchemaVersion: checkpoint.SchemaV2, Turn: 1, Time: time.Now(), Prompt: "edit", MsgIndex: 3, |
| 60 | Coverage: checkpoint.CoverageComplete, |
| 61 | Files: []checkpoint.FileSnap{{ |
| 62 | Path: "a.txt", Content: &before, SHA256: checkpoint.Digest([]byte(before)), Mode: mode, |
| 63 | AfterExisted: &afterExists, AfterSHA256: checkpoint.Digest([]byte("after")), AfterMode: mode, |
| 64 | CaptureSource: checkpoint.CaptureBeforeMutation, |
| 65 | }}, |
| 66 | }) |
| 67 | session := agent.NewSession("") |
| 68 | session.Replace([]provider.Message{ |
| 69 | {Role: provider.RoleSystem, Content: "sys"}, |
| 70 | {Role: provider.RoleUser, Content: "first"}, |
| 71 | {Role: provider.RoleAssistant, Content: "answer"}, |
| 72 | {Role: provider.RoleUser, Content: "edit"}, |
| 73 | {Role: provider.RoleAssistant, Content: "done"}, |
| 74 | }) |
| 75 | if err := session.Save(path); err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | ag := agent.New(nil, nil, session, agent.Options{}, event.Discard) |
| 79 | ctrl := control.New(control.Options{Executor: ag, Runner: ag, Sink: event.Discard, SessionDir: dir, SessionPath: path, WorkspaceRoot: root, Label: "test"}) |
| 80 | app := NewApp() |
| 81 | app.setTestCtrl(ctrl, "deepseek/test") |
| 82 | app.tabs["test"].Scope = "project" |
| 83 | app.tabs["test"].WorkspaceRoot = root |
| 84 | app.tabs["test"].TopicID = "topic_heads" |
| 85 | app.tabs["test"].TopicTitle = "Source topic" |
| 86 | t.Cleanup(ctrl.Close) |
| 87 | if _, ok := ctrl.SessionHead(); !ok { |
| 88 | t.Fatal("fixture session must be schema 2") |
| 89 | } |
| 90 | return schemaTwoTabFixture{app: app, ctrl: ctrl, session: session, path: path, filePath: filePath} |
| 91 | } |
| 92 | |
| 93 | func transcriptFilesIn(t *testing.T, dir string) int { |
| 94 | t.Helper() |
| 95 | entries, err := os.ReadDir(dir) |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | n := 0 |
| 100 | for _, entry := range entries { |
| 101 | if !entry.IsDir() && store.IsSessionTranscriptName(entry.Name()) { |
| 102 | n++ |
| 103 | } |
| 104 | } |
| 105 | return n |
| 106 | } |
| 107 | |
| 108 | func TestForkForTabCreatesIndependentTabFromSchemaTwo(t *testing.T) { |
| 109 | fx := newSchemaTwoTabFixture(t) |
| 110 | meta, err := fx.app.ForkForTab("test", 1) |
| 111 | if err != nil { |
| 112 | t.Fatalf("ForkForTab: %v", err) |
| 113 | } |
| 114 | if meta.ID == "" || meta.ID == "test" || !meta.Active || meta.SessionPath == "" || meta.SessionPath == fx.path { |
| 115 | t.Fatalf("fork meta = id %q active %v path %q, want an independent active tab", meta.ID, meta.Active, meta.SessionPath) |
| 116 | } |
| 117 | if meta.TopicTitle != "Source topic (1)" { |
| 118 | t.Fatalf("fork title = %q, want Harness-style numbering", meta.TopicTitle) |
| 119 | } |
| 120 | if len(fx.app.tabs) != 2 || fx.ctrl.SessionPath() != fx.path { |
| 121 | t.Fatalf("tabs = %d source path %q, want source plus an independent child", len(fx.app.tabs), fx.ctrl.SessionPath()) |
| 122 | } |
| 123 | if got := len(fx.ctrl.History()); got != 5 { |
| 124 | t.Fatalf("source history after fork = %d, want all 5 messages unchanged", got) |
| 125 | } |
| 126 | forked, err := agent.LoadSession(meta.SessionPath) |
| 127 | if err != nil { |
| 128 | t.Fatalf("load fork session: %v", err) |
| 129 | } |
| 130 | if got := len(forked.Messages); got != 3 { |
| 131 | t.Fatalf("fork history = %d, want the prefix before turn 1", got) |
| 132 | } |
| 133 | branch, ok, err := agent.LoadBranchMeta(meta.SessionPath) |
| 134 | if err != nil || !ok { |
| 135 | t.Fatalf("load fork branch metadata: ok=%v err=%v", ok, err) |
| 136 | } |
| 137 | if branch.ParentID != agent.BranchID(fx.path) || branch.ForkTurn != 1 || branch.ForkMessageIndex != 3 || branch.TopicTitle != "Source topic (1)" { |
| 138 | t.Fatalf("fork branch metadata = %+v", branch) |
| 139 | } |
| 140 | heads, err := agent.ListSessionHeads(fx.path) |
| 141 | if err != nil || len(heads) != 1 || !heads[0].Selected || heads[0].MessageCount != 5 { |
| 142 | t.Fatalf("heads = %+v err=%v", heads, err) |
| 143 | } |
| 144 | if got := transcriptFilesIn(t, filepath.Dir(fx.path)); got != 2 { |
| 145 | t.Fatalf("transcript files = %d, want source and child logs", got) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func TestCommitRewindForTabOpensIndependentChild(t *testing.T) { |
| 150 | fx := newSchemaTwoTabFixture(t) |
| 151 | plan := fx.app.PreviewRewindForTab("test", 1, "both") |
| 152 | if !plan.OK || !plan.CanFiles || !plan.CanConversation { |
| 153 | t.Fatalf("preview = %+v", plan) |
| 154 | } |
| 155 | result := fx.app.CommitRewindForTab("test", plan.PlanID, 1, "both") |
| 156 | if !result.OK || !result.ConversationForked || result.Branch == "" || !strings.HasSuffix(result.Branch, ".jsonl") { |
| 157 | t.Fatalf("commit = %+v, want an independent session path", result) |
| 158 | } |
| 159 | if result.TabID == "" || result.TabID == "test" || result.Tab == nil || result.Tab.ID != result.TabID { |
| 160 | t.Fatalf("commit tab wiring = tab %q meta %+v, want the child tab", result.TabID, result.Tab) |
| 161 | } |
| 162 | if got, err := os.ReadFile(fx.filePath); err != nil || string(got) != "before" { |
| 163 | t.Fatalf("file after commit = %q err=%v", got, err) |
| 164 | } |
| 165 | if got := len(fx.ctrl.History()); got != 5 || fx.ctrl.SessionPath() != fx.path || len(fx.app.tabs) != 2 { |
| 166 | t.Fatalf("after commit: history %d path %q tabs %d", got, fx.ctrl.SessionPath(), len(fx.app.tabs)) |
| 167 | } |
| 168 | childSession, err := agent.LoadSession(result.Branch) |
| 169 | if err != nil || childSession == nil || len(childSession.Messages) != 3 { |
| 170 | t.Fatalf("rewind child = %+v err=%v", childSession, err) |
| 171 | } |
| 172 | undo := fx.app.UndoRewindForTab("test", result.TransactionID) |
| 173 | if !undo.OK { |
| 174 | t.Fatalf("undo = %+v", undo) |
| 175 | } |
| 176 | if got, err := os.ReadFile(fx.filePath); err != nil || string(got) != "after" { |
| 177 | t.Fatalf("file after undo = %q err=%v", got, err) |
| 178 | } |
| 179 | if got := fx.ctrl.History(); len(got) != 5 || got[4].Content != "done" { |
| 180 | t.Fatalf("history after undo = %d messages, want the rewound turn back", len(got)) |
| 181 | } |
| 182 | reloaded, err := agent.LoadSession(fx.path) |
| 183 | if err != nil || len(reloaded.Messages) != 5 { |
| 184 | t.Fatalf("reload after undo = %d messages err=%v, want the parent head persisted as current", len(reloaded.Messages), err) |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | func TestChooseLegacyRecoveryHeadMaterializesIndependentSession(t *testing.T) { |
| 189 | fx := newSchemaTwoTabFixture(t) |
| 190 | from := fx.session.Snapshot()[2].ID |
| 191 | fork, err := fx.session.ForkHead(fx.path, from, agent.HeadKindFork, "alternative") |
| 192 | if err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | req := RecoveryPreferenceRequest{Scope: "global", TopicID: "topic", Path: fx.path, HeadID: agent.SessionMainHead} |
| 196 | if err := fx.app.ChooseRecoveryBranch(req); err != nil { |
| 197 | t.Fatalf("ChooseRecoveryBranch(main): %v", err) |
| 198 | } |
| 199 | if got := len(fx.ctrl.History()); got != 5 || fx.ctrl.SessionPath() == fx.path { |
| 200 | t.Fatalf("after choosing main: history %d path %q", got, fx.ctrl.SessionPath()) |
| 201 | } |
| 202 | if gen := fx.app.tabs["test"].SessionGeneration; gen != 1 { |
| 203 | t.Fatalf("session generation = %d, want one materialization bump", gen) |
| 204 | } |
| 205 | heads, err := agent.ListSessionHeads(fx.path) |
| 206 | if err != nil || len(heads) != 2 || heads[1].ID != fork || !heads[1].Selected { |
| 207 | t.Fatalf("legacy source was modified while materializing: %+v err=%v", heads, err) |
| 208 | } |
| 209 | if err := fx.app.ChooseRecoveryBranch(RecoveryPreferenceRequest{Scope: "global", TopicID: "topic", Path: fx.path, HeadID: "missing"}); err == nil { |
| 210 | t.Fatal("choosing an unknown head must fail") |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | func TestGetRecoveryLineageListsHeadsAndCleansCoveredOnes(t *testing.T) { |
| 215 | isolateDesktopUserDirs(t) |
| 216 | ctx := context.Background() |
| 217 | dir := config.SessionDir() |
| 218 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 219 | t.Fatal(err) |
| 220 | } |
| 221 | catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{InMemory: true, DisableRepair: true}) |
| 222 | if err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | t.Cleanup(func() { _ = catalog.Close(ctx) }) |
| 226 | path := filepath.Join(dir, "log.jsonl") |
| 227 | session := agent.NewSession("system") |
| 228 | session.Add(provider.Message{Role: provider.RoleUser, Content: "shared question"}) |
| 229 | session.Add(provider.Message{Role: provider.RoleAssistant, Content: "shared answer"}) |
| 230 | if err := session.Save(path); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | fork, err := session.ForkHead(path, session.Snapshot()[2].ID, agent.HeadKindFork, "alt") |
| 234 | if err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | session.Add(provider.Message{Role: provider.RoleUser, Content: "alt question"}) |
| 238 | if err := session.Save(path); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | if err := agent.UpdateBranchMeta(path, false, func(meta *agent.BranchMeta) error { |
| 242 | meta.Scope, meta.TopicID, meta.TopicTitle, meta.CustomTitle = "global", "topic", "Topic", "log note" |
| 243 | return nil |
| 244 | }); err != nil { |
| 245 | t.Fatal(err) |
| 246 | } |
| 247 | if err := catalog.ReconcileDirectory(ctx, sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}); err != nil { |
| 248 | t.Fatal(err) |
| 249 | } |
| 250 | app := NewApp() |
| 251 | app.sessionCatalog.Store(catalog) |
| 252 | key := ProjectTopicKey{Scope: "global", TopicID: "topic"} |
| 253 | |
| 254 | view := app.GetRecoveryLineage(key) |
| 255 | if view.State != sessionHeadLineageState || len(view.Members) != 2 || view.CleanupEligible != 1 { |
| 256 | t.Fatalf("heads view = %+v", view) |
| 257 | } |
| 258 | main, alt := view.Members[0], view.Members[1] |
| 259 | if main.HeadID != agent.SessionMainHead || main.Canonical || main.VersionNote != "log note" || main.Path != path { |
| 260 | t.Fatalf("main member = %+v", main) |
| 261 | } |
| 262 | if alt.HeadID != fork || !alt.Canonical || !alt.Selected || alt.HeadName != "alt" || alt.VersionNote != "alt" || alt.Path != path { |
| 263 | t.Fatalf("fork member = %+v", alt) |
| 264 | } |
| 265 | if state := app.GetSessionVersionState(key); state.ActiveVersionID != fork || state.ActivePath != path { |
| 266 | t.Fatalf("version state = %+v", state) |
| 267 | } |
| 268 | |
| 269 | if err := app.ChooseRecoveryBranch(RecoveryPreferenceRequest{Scope: "global", TopicID: "topic", Path: path, HeadID: agent.SessionMainHead}); err != nil { |
| 270 | t.Fatalf("ChooseRecoveryBranch: %v", err) |
| 271 | } |
| 272 | view = app.GetRecoveryLineage(key) |
| 273 | if !view.Members[0].Canonical || view.Members[1].Canonical || view.CleanupEligible != 0 { |
| 274 | t.Fatalf("after selecting main = %+v", view) |
| 275 | } |
| 276 | if dry := app.CleanRecoveryLineage(RecoveryCleanupRequest{Scope: "global", TopicID: "topic"}); dry.Eligible != 0 { |
| 277 | t.Fatalf("diverged fork must not be cleanup-eligible: %+v", dry) |
| 278 | } |
| 279 | |
| 280 | if err := app.ChooseRecoveryBranch(RecoveryPreferenceRequest{Scope: "global", TopicID: "topic", Path: path, HeadID: fork}); err != nil { |
| 281 | t.Fatal(err) |
| 282 | } |
| 283 | t.Cleanup(func() { sessionHeadQuietPeriod = time.Minute }) |
| 284 | sessionHeadQuietPeriod = time.Hour |
| 285 | busy := app.CleanRecoveryLineage(RecoveryCleanupRequest{Scope: "global", TopicID: "topic", Apply: true}) |
| 286 | if busy.Eligible != 1 || busy.Busy != 1 || busy.Moved != 0 || busy.Items[0].HeadID != agent.SessionMainHead || busy.Items[0].Status != "busy" { |
| 287 | t.Fatalf("cleanup inside the quiet period = %+v", busy) |
| 288 | } |
| 289 | sessionHeadQuietPeriod = 0 |
| 290 | applied := app.CleanRecoveryLineage(RecoveryCleanupRequest{Scope: "global", TopicID: "topic", Apply: true}) |
| 291 | if applied.Eligible != 1 || applied.Moved != 1 || applied.Items[0].Status != "retired" { |
| 292 | t.Fatalf("cleanup = %+v", applied) |
| 293 | } |
| 294 | heads, err := agent.ListSessionHeads(path) |
| 295 | if err != nil || len(heads) != 2 || !heads[0].Retired || heads[1].Retired { |
| 296 | t.Fatalf("heads after cleanup = %+v err=%v", heads, err) |
| 297 | } |
| 298 | if view = app.GetRecoveryLineage(key); len(view.Members) != 1 || view.Members[0].HeadID != fork { |
| 299 | t.Fatalf("view after cleanup = %+v", view) |
| 300 | } |
| 301 | if got := transcriptFilesIn(t, dir); got != 1 { |
| 302 | t.Fatalf("transcript files = %d, want cleanup to leave the log alone", got) |
| 303 | } |
| 304 | } |
| 305 |