返回 DeepSeek-Reasonix
historical_archive_version_kinds_test.go
根目录 / desktop / historical_archive_version_kinds_test.go
1 package main
2
3 import (
4 "os"
5 "path/filepath"
6 "testing"
7
8 "reasonix/desktop/internal/workspacestate"
9 "reasonix/internal/agent"
10 "reasonix/internal/provider"
11 "reasonix/internal/session"
12 )
13
14 func forkLegacyVersion(t *testing.T, legacy *agent.Session, path, kind, name, answer string) string {
15 t.Helper()
16 head, err := legacy.ForkHead(path, "question", kind, name)
17 if err != nil {
18 t.Fatal(err)
19 }
20 legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: answer})
21 if err := legacy.Save(path); err != nil {
22 t.Fatal(err)
23 }
24 return head
25 }
26
27 func legacyVersionApp(t *testing.T, path string) (*App, string, func() map[string]bool) {
28 t.Helper()
29 app := NewApp()
30 app.ctx = t.Context()
31 root := pinDesktopSessionRoot(t, app)
32 installNoopRuntimeEvents(app)
33 t.Cleanup(app.closeSessionServices)
34 installSessionCatalogForTest(t, app, filepath.Dir(path), "global", "")
35 return app, root, func() map[string]bool {
36 t.Helper()
37 page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
38 if err != nil {
39 t.Fatal(err)
40 }
41 heads := map[string]bool{}
42 for _, row := range page.Items {
43 if row.Source == nil {
44 t.Fatalf("unexpected materialized row: %+v", row)
45 }
46 heads[row.Source.HeadID] = true
47 }
48 return heads
49 }
50 }
51
52 func countSessionDirs(t *testing.T, root string) int {
53 t.Helper()
54 entries, err := os.ReadDir(root)
55 if err != nil {
56 t.Fatal(err)
57 }
58 dirs := 0
59 for _, entry := range entries {
60 if entry.IsDir() {
61 dirs++
62 }
63 }
64 return dirs
65 }
66
67 // Before rewinds became their own head kind, "fork from this turn" wrote an
68 // unnamed fork head and a second writer's save a concurrent one; both keep the
69 // conversation. Only a named fork is one the user split off.
70 func TestArchivingLegacyVersionRowCoversUnnamedForkAndConcurrentVersions(t *testing.T) {
71 isolateDesktopUserDirs(t)
72 path, legacy, mainHead := migrationSingleDAGFixture(t)
73 legacy.Add(provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "first answer"})
74 if err := legacy.Save(path); err != nil {
75 t.Fatal(err)
76 }
77 first := forkLegacyVersion(t, legacy, path, agent.HeadKindFork, "", "second answer")
78 second := forkLegacyVersion(t, legacy, path, agent.HeadKindFork, "", "third answer")
79 other, err := agent.LoadSession(path)
80 if err != nil {
81 t.Fatal(err)
82 }
83 legacy.Add(provider.Message{Role: provider.RoleUser, Content: "follow-up here"})
84 if err := legacy.Save(path); err != nil {
85 t.Fatal(err)
86 }
87 other.Add(provider.Message{Role: provider.RoleUser, Content: "follow-up elsewhere"})
88 if err := other.Save(path); err != nil {
89 t.Fatal(err)
90 }
91 concurrent, _ := other.Head()
92 rewound := forkLegacyVersion(t, other, path, agent.HeadKindRewind, "", "answer after the concurrent write")
93 named := forkLegacyVersion(t, legacy, path, agent.HeadKindFork, "separate idea", "forked answer")
94 heads, err := agent.ListSessionHeads(path)
95 if err != nil {
96 t.Fatal(err)
97 }
98 for _, head := range heads {
99 if head.ID == concurrent.HeadID && head.Kind != agent.HeadKindConcurrent {
100 t.Fatalf("fixture head %s is %q, want a concurrent head", head.ID, head.Kind)
101 }
102 }
103 versions := []string{mainHead, first, second, rewound}
104
105 app, root, rowHeads := legacyVersionApp(t, path)
106 if got := rowHeads(); len(got) != len(versions)+1 || !got[named] {
107 t.Fatalf("fixture rows = %v; want %d versions and the named fork", got, len(versions))
108 }
109 selector := SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: second}}
110 dirs := 0
111 for attempt := range 2 {
112 result, err := app.ArchiveSessionTarget(selector)
113 if err != nil || !result.Committed {
114 t.Fatalf("archive %d = %+v, %v", attempt, result, err)
115 }
116 if got := rowHeads(); len(got) != 1 || !got[named] {
117 t.Fatalf("archive %d left rows %v; want only the named fork", attempt, got)
118 }
119 state, err := app.workspaceRegistry().Load(t.Context())
120 if err != nil {
121 t.Fatal(err)
122 }
123 mapped := map[string]string{}
124 for _, mapping := range state.SourceMappings {
125 if previous, dup := mapped[mapping.HeadID]; dup {
126 t.Fatalf("head %s materialized twice: %s and %s", mapping.HeadID, previous, mapping.SessionID)
127 }
128 mapped[mapping.HeadID] = mapping.SessionID
129 if lifecycle := state.SessionStates[mapping.SessionID].Lifecycle; lifecycle != workspacestate.Archived {
130 t.Fatalf("version %s is %v, not archived", mapping.HeadID, lifecycle)
131 }
132 }
133 if len(mapped) != len(versions) {
134 t.Fatalf("archive %d mapped %v; want exactly the versions %v", attempt, mapped, versions)
135 }
136 for _, head := range versions {
137 if _, ok := mapped[head]; !ok {
138 t.Fatalf("version %s was not archived: %v", head, mapped)
139 }
140 }
141 if attempt == 0 {
142 dirs = countSessionDirs(t, root)
143 } else if got := countSessionDirs(t, root); got != dirs {
144 t.Fatalf("archive %d wrote new session storage: %d entries, was %d", attempt, got, dirs)
145 }
146 }
147 }
148
149 // Installs that archived versions one row at a time keep those records. When
150 // the transcript has changed since, a later archive of the conversation must
151 // retire the remaining rows without materializing the archived ones again.
152 func TestArchivingLegacyConversationDoesNotRematerializeArchivedVersions(t *testing.T) {
153 isolateDesktopUserDirs(t)
154 path, legacy, mainHead := migrationSingleDAGFixture(t)
155 legacy.Add(provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "first answer"})
156 if err := legacy.Save(path); err != nil {
157 t.Fatal(err)
158 }
159 versions := []string{mainHead}
160 for _, retry := range []string{"second answer", "third answer", "fourth answer"} {
161 versions = append(versions, forkLegacyVersion(t, legacy, path, agent.HeadKindRewind, "", retry))
162 }
163 named := forkLegacyVersion(t, legacy, path, agent.HeadKindFork, "separate idea", "forked answer")
164 app, root, rowHeads := legacyVersionApp(t, path)
165
166 for _, head := range versions[:2] {
167 ctx, done, err := app.beginHistoricalRecovery()
168 if err != nil {
169 t.Fatal(err)
170 }
171 id, source, err := app.historicalSourceForSelector(SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: head}})
172 if err != nil {
173 done()
174 t.Fatal(err)
175 }
176 _, err = app.archiveHistoricalSourceWithOperation(ctx, id, source, "archive-source-single-"+head)
177 done()
178 if err != nil {
179 t.Fatal(err)
180 }
181 }
182 legacy.Add(provider.Message{Role: provider.RoleUser, Content: "the named fork continued"})
183 if err := legacy.Save(path); err != nil {
184 t.Fatal(err)
185 }
186 before := countSessionDirs(t, root)
187
188 result, err := app.ArchiveSessionTarget(SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: versions[3]}})
189 if err != nil || !result.Committed {
190 t.Fatalf("archive = %+v, %v", result, err)
191 }
192 if got := rowHeads(); len(got) != 1 || !got[named] {
193 t.Fatalf("rows %v; want only the named fork", got)
194 }
195 if got := countSessionDirs(t, root); got != before+2 {
196 t.Fatalf("archive wrote %d session directories; want 2, one per unarchived version", got-before)
197 }
198 state, err := app.workspaceRegistry().Load(t.Context())
199 if err != nil {
200 t.Fatal(err)
201 }
202 perHead := map[string]int{}
203 for _, mapping := range state.SourceMappings {
204 perHead[mapping.HeadID]++
205 }
206 for _, head := range versions {
207 if perHead[head] != 1 {
208 t.Fatalf("version %s has %d mappings; want 1 (%v)", head, perHead[head], perHead)
209 }
210 }
211 }
212
213 func TestArchivingLegacyVersionKeepsAnotherOpenVersionActive(t *testing.T) {
214 isolateDesktopUserDirs(t)
215 path, legacy, mainHead := migrationSingleDAGFixture(t)
216 legacy.Add(provider.Message{ID: "answer", Role: provider.RoleAssistant, Content: "first answer"})
217 if err := legacy.Save(path); err != nil {
218 t.Fatal(err)
219 }
220 otherHead := forkLegacyVersion(t, legacy, path, agent.HeadKindRewind, "", "second answer")
221 app, root, _ := legacyVersionApp(t, path)
222
223 prepared, err := app.PrepareSession(SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: mainHead}})
224 if err != nil {
225 t.Fatal(err)
226 }
227 app.historicalImports.mu.Lock()
228 call := app.historicalImports.operations[prepared.OperationID]
229 app.historicalImports.mu.Unlock()
230 imported, err := waitHistoricalImport(call)
231 if err != nil {
232 t.Fatal(err)
233 }
234 active := session.SessionRef{HostID: localDesktopHostID, SessionID: imported.Session.SessionID}
235 if _, err := app.OpenSession(active); err != nil {
236 t.Fatal(err)
237 }
238 legacy.Add(provider.Message{Role: provider.RoleUser, Content: "the other legacy version continued"})
239 if err := legacy.Save(path); err != nil {
240 t.Fatal(err)
241 }
242 sourceBefore, err := desktopSourceFingerprint(path)
243 if err != nil {
244 t.Fatal(err)
245 }
246 before := countSessionDirs(t, root)
247
248 result, err := app.ArchiveSessionTarget(SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: path, HeadID: otherHead}})
249 if err != nil || !result.Committed {
250 t.Fatalf("archive other version = %+v, %v", result, err)
251 }
252 state, err := app.workspaceRegistry().Load(t.Context())
253 if err != nil {
254 t.Fatal(err)
255 }
256 if got := state.SessionStates[active.SessionID].Lifecycle; got != workspacestate.Active {
257 t.Fatalf("open version lifecycle = %q, want active", got)
258 }
259 mainMappings := 0
260 for _, mapping := range state.SourceMappings {
261 if mapping.HeadID == mainHead {
262 mainMappings++
263 if mapping.SessionID != active.SessionID {
264 t.Fatalf("open version remapped from %s to %s", active.SessionID, mapping.SessionID)
265 }
266 }
267 }
268 if mainMappings != 1 {
269 t.Fatalf("open version has %d mappings, want one", mainMappings)
270 }
271 if got := countSessionDirs(t, root); got != before+1 {
272 t.Fatalf("archive wrote %d session directories, want one for the other version", got-before)
273 }
274 if sourceAfter, err := desktopSourceFingerprint(path); err != nil || sourceAfter != sourceBefore {
275 t.Fatalf("archive changed the legacy source: before=%s after=%s err=%v", sourceBefore, sourceAfter, err)
276 }
277 }
278
278 lines GO