返回 DeepSeek-Reasonix
session_heads_test.go
根目录 / desktop / session_heads_test.go
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
305 lines GO