返回 DeepSeek-Reasonix
recovery_lineage_test.go
根目录 / desktop / recovery_lineage_test.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "path/filepath"
7 "sync/atomic"
8 "testing"
9 "time"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/provider"
13 "reasonix/internal/sessioncatalog"
14 )
15
16 func TestGetRecoveryLineageIncludesOriginalAndUserFacingMetadata(t *testing.T) {
17 dir := schemaOneTempDir(t)
18 catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true})
19 if err != nil {
20 t.Fatal(err)
21 }
22 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
23 root := filepath.Join(dir, "root.jsonl")
24 branch := filepath.Join(dir, "branch.jsonl")
25 save := func(path string, messages ...string) {
26 t.Helper()
27 session := agent.NewSession("system")
28 for index, message := range messages {
29 role := provider.RoleUser
30 if index%2 == 1 {
31 role = provider.RoleAssistant
32 }
33 session.Add(provider.Message{Role: role, Content: message})
34 }
35 if err := session.Save(path); err != nil {
36 t.Fatal(err)
37 }
38 }
39 save(root, "shared question", "shared answer", "original preview", "original answer")
40 save(branch, "shared question", "shared answer", "branch preview", "branch answer")
41 created := time.UnixMilli(10)
42 for path, meta := range map[string]agent.BranchMeta{
43 root: {
44 ID: "root", Scope: "global", TopicID: "topic", TopicTitle: "Topic",
45 CustomTitle: "original note", CreatedAt: created, UpdatedAt: created,
46 },
47 branch: {
48 ID: "branch", Scope: "global", TopicID: "topic", TopicTitle: "Topic",
49 CustomTitle: "branch note", CreatedAt: created.Add(time.Second), UpdatedAt: created.Add(time.Second),
50 Recovered: true, ParentID: "root", RecoveryDepth: 1,
51 },
52 } {
53 if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil {
54 t.Fatal(err)
55 }
56 }
57 if err := catalog.ReconcileDirectory(context.Background(), sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
58 t.Fatal(err)
59 }
60 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
61 app.sessionCatalog.Store(catalog)
62 view := app.GetRecoveryLineage(ProjectTopicKey{Scope: "global", TopicID: "topic"})
63 if len(view.Members) != 2 {
64 t.Fatalf("members = %+v, want original plus recovery version", view.Members)
65 }
66 byPath := map[string]RecoveryLineageMember{}
67 for _, member := range view.Members {
68 byPath[member.Path] = member
69 }
70 if got := byPath[root]; got.VersionNote != "original note" || got.Preview == "" || got.CreatedAt != 10 || got.LastActivityAt == 0 {
71 t.Fatalf("original metadata = %+v", got)
72 }
73 if got := byPath[branch]; got.VersionNote != "branch note" || got.Preview == "" || got.Turns != 2 || got.CreatedAt != 1010 {
74 t.Fatalf("branch metadata = %+v", got)
75 }
76 }
77
78 func TestGetRecoveryLineageEmptyMembersEncodeAsArray(t *testing.T) {
79 view := NewApp().GetRecoveryLineage(ProjectTopicKey{Scope: "global", TopicID: "missing"})
80 data, err := json.Marshal(view)
81 if err != nil {
82 t.Fatal(err)
83 }
84 if string(data) == "" || string(data) == "null" || view.Members == nil {
85 t.Fatalf("empty lineage must keep members as []: %s", data)
86 }
87 }
88
89 func TestGetRecoveryLineageBindsRequestedPhysicalGroup(t *testing.T) {
90 ctx := context.Background()
91 dir := schemaOneTempDir(t)
92 catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{InMemory: true, DisableRepair: true})
93 if err != nil {
94 t.Fatal(err)
95 }
96 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
97
98 rootA := filepath.Join(dir, "root-a.jsonl")
99 copyA := filepath.Join(dir, "copy-a.jsonl")
100 rootB := filepath.Join(dir, "root-b.jsonl")
101 forkB := filepath.Join(dir, "fork-b.jsonl")
102 digests := map[string]string{}
103 save := func(path string, messages ...string) {
104 t.Helper()
105 session := agent.NewSession("system")
106 for index, message := range messages {
107 role := provider.RoleUser
108 if index%2 == 1 {
109 role = provider.RoleAssistant
110 }
111 session.Add(provider.Message{Role: role, Content: message})
112 }
113 if err := session.Save(path); err != nil {
114 t.Fatal(err)
115 }
116 digest, err := agent.ContentDigestForMessages(session.Snapshot())
117 if err != nil {
118 t.Fatal(err)
119 }
120 digests[path] = digest
121 }
122 save(rootA, "shared question", "shared answer")
123 save(copyA, "shared question", "shared answer", "leaf-a extra", "leaf-a answer")
124 save(rootB, "other shared question", "other shared answer", "root-b unique", "root-b answer")
125 save(forkB, "other shared question", "other shared answer", "fork-b unique", "fork-b answer")
126 created := time.UnixMilli(100)
127 metas := map[string]agent.BranchMeta{
128 rootA: {ID: "root-a", Scope: "global", TopicID: "topic", TopicTitle: "Topic", CreatedAt: created, UpdatedAt: created.Add(4 * time.Second)},
129 copyA: {ID: "copy-a", Scope: "global", TopicID: "topic", TopicTitle: "Topic", Recovered: true, ParentID: "root-a", RecoveryDepth: 1, Revision: 1, ContentDigest: digests[copyA], RecoveryDigest: digests[copyA], CreatedAt: created.Add(time.Second), UpdatedAt: created.Add(3 * time.Second)},
130 rootB: {ID: "root-b", Scope: "global", TopicID: "topic", TopicTitle: "Topic", CreatedAt: created.Add(2 * time.Second), UpdatedAt: created.Add(2 * time.Second)},
131 forkB: {ID: "fork-b", Scope: "global", TopicID: "topic", TopicTitle: "Topic", Recovered: true, ParentID: "root-b", RecoveryDepth: 1, Revision: 1, ContentDigest: digests[forkB], RecoveryDigest: digests[forkB], CreatedAt: created.Add(3 * time.Second), UpdatedAt: created.Add(time.Second)},
132 }
133 for path, meta := range metas {
134 if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil {
135 t.Fatal(err)
136 }
137 }
138 if err := catalog.ReconcileDirectory(ctx, sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
139 t.Fatal(err)
140 }
141 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
142 app.sessionCatalog.Store(catalog)
143
144 diverged := app.GetRecoveryLineage(ProjectTopicKey{Scope: "global", TopicID: "topic", Path: forkB})
145 if diverged.GroupID != "root-b" || diverged.State != "diverged" || diverged.Unresolved != 1 || len(diverged.Members) != 2 {
146 t.Fatalf("diverged lineage = %+v, want only root-b and fork-b", diverged)
147 }
148 for _, member := range diverged.Members {
149 if member.Path != rootB && member.Path != forkB {
150 t.Fatalf("diverged lineage contains unrelated member %+v", member)
151 }
152 }
153
154 adopted := app.GetRecoveryLineage(ProjectTopicKey{Scope: "global", TopicID: "topic", Path: copyA})
155 if adopted.GroupID != "root-a" || adopted.State != "adopted" || len(adopted.Members) != 2 {
156 t.Fatalf("adopted lineage = %+v, want only root-a and copy-a", adopted)
157 }
158
159 ambiguous := app.GetRecoveryLineage(ProjectTopicKey{Scope: "global", TopicID: "topic"})
160 if ambiguous.GroupID != "" || len(ambiguous.Members) != 0 || ambiguous.Members == nil {
161 t.Fatalf("ambiguous legacy lookup = %+v, want safe empty array", ambiguous)
162 }
163 }
164
165 func TestChooseRecoveryBranchPersistsPreferenceAndRefreshesOffBarrier(t *testing.T) {
166 isolateDesktopUserDirs(t)
167 ctx := context.Background()
168 dir := schemaOneTempDir(t)
169 catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{InMemory: true, DisableRepair: true})
170 if err != nil {
171 t.Fatal(err)
172 }
173 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
174 root := filepath.Join(dir, "root.jsonl")
175 fork := filepath.Join(dir, "fork.jsonl")
176 save := func(path string, messages ...string) {
177 t.Helper()
178 session := agent.NewSession("system")
179 for index, message := range messages {
180 role := provider.RoleUser
181 if index%2 == 1 {
182 role = provider.RoleAssistant
183 }
184 session.Add(provider.Message{Role: role, Content: message})
185 }
186 if err := session.Save(path); err != nil {
187 t.Fatal(err)
188 }
189 }
190 save(root, "shared question", "shared answer", "root unique", "root answer")
191 save(fork, "shared question", "shared answer", "fork unique", "fork answer")
192 created := time.UnixMilli(100)
193 for path, meta := range map[string]agent.BranchMeta{
194 root: {ID: "root", Scope: "global", TopicID: "topic", TopicTitle: "Topic", CreatedAt: created, UpdatedAt: created},
195 fork: {ID: "fork", Scope: "global", TopicID: "topic", TopicTitle: "Topic", Recovered: true, ParentID: "root", RecoveryDepth: 1, CreatedAt: created.Add(time.Second), UpdatedAt: created.Add(time.Second)},
196 } {
197 if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil {
198 t.Fatal(err)
199 }
200 }
201 if err := catalog.ReconcileDirectory(ctx, sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
202 t.Fatal(err)
203 }
204 app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
205 app.sessionCatalog.Store(catalog)
206 var barrierReleased atomic.Bool
207 app.projectTreeChangedHook = func() {
208 if !app.runtimeRebuildMu.TryLock() {
209 return
210 }
211 app.runtimeRebuildMu.Unlock()
212 if app.sessionRemovalMu.TryLock() {
213 app.sessionRemovalMu.Unlock()
214 barrierReleased.Store(true)
215 }
216 }
217 key := ProjectTopicKey{Scope: "global", TopicID: "topic", Path: fork}
218 if view := app.GetRecoveryLineage(key); view.State != "diverged" {
219 t.Fatalf("lineage before choice = %+v, want diverged", view)
220 }
221
222 if err := app.ChooseRecoveryBranch(RecoveryPreferenceRequest{Scope: "global", TopicID: "topic", Path: fork}); err != nil {
223 t.Fatal(err)
224 }
225 if !barrierReleased.Load() {
226 t.Fatal("catalog refresh after the choice still ran under the runtime mutation barrier")
227 }
228 meta, ok, err := agent.LoadBranchMeta(fork)
229 if err != nil || !ok || !meta.RecoveryPreferred {
230 t.Fatalf("fork meta = %+v ok=%v err=%v, want a persisted preference", meta, ok, err)
231 }
232 view := app.GetRecoveryLineage(key)
233 if view.State != "preferred" || view.Unresolved != 0 {
234 t.Fatalf("lineage after choice = %+v, want preferred", view)
235 }
236 for _, member := range view.Members {
237 if member.Canonical != (member.Path == fork) {
238 t.Fatalf("member %+v, want only the chosen fork canonical", member)
239 }
240 }
241
242 err = app.ChooseRecoveryBranch(RecoveryPreferenceRequest{Scope: "global", TopicID: "topic", Path: filepath.Join(dir, "missing.jsonl")})
243 if err == nil || err.Error() != "selected branch is outside the recovery lineage" {
244 t.Fatalf("outside-lineage choice error = %v", err)
245 }
246 }
247
247 lines GO