返回 DeepSeek-Reasonix
store_test.go
根目录 / internal / session / store_test.go
1 package session
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "os"
10 "path/filepath"
11 "testing"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/event"
15 "reasonix/internal/filelock"
16 "reasonix/internal/provider"
17 "reasonix/internal/store"
18 )
19
20 func TestStoredManifestRequiresExactFormatBoundary(t *testing.T) {
21 t.Parallel()
22 if supportedStoredManifest(Manifest{SchemaVersion: SchemaVersion, Codec: Codec}) {
23 t.Fatal("unpublished v4 draft without storageRevision was accepted as final v4")
24 }
25 if !supportedStoredManifest(Manifest{SchemaVersion: SchemaVersion, Codec: Codec, StorageRevision: StorageRevision}) {
26 t.Fatal("final v4 manifest was rejected")
27 }
28 if !supportedStoredManifest(Manifest{SchemaVersion: 3, Codec: FinalV31Codec}) {
29 t.Fatal("frozen v3.1 input format was rejected")
30 }
31 if supportedStoredManifest(Manifest{SchemaVersion: SchemaVersion, Codec: FinalV31Codec}) {
32 t.Fatal("schema-4 data mislabeled as v3.1 was accepted")
33 }
34 }
35
36 func TestCommitBatchAndProjectionAreAtomic(t *testing.T) {
37 dir := filepath.Join(t.TempDir(), "s")
38 s, err := Open(dir, "s")
39 if err != nil {
40 t.Fatal(err)
41 }
42 t.Cleanup(func() { _ = s.Close(context.Background()) })
43 todos, _ := json.Marshal(map[string]any{"todos": []event.Todo{{Content: "A", Status: "in_progress"}, {Content: "B", Status: "in_progress"}}})
44 commit, err := s.Append(t.Context(), Batch{OperationID: "todo-1", TurnID: "turn-1", Events: []Event{
45 {Kind: "turn/start"},
46 {Kind: "todo/write", Payload: todos},
47 }})
48 if err != nil {
49 t.Fatal(err)
50 }
51 if commit.FirstSequence != 1 || commit.EventCount != 2 || commit.Events[1].Sequence != 2 {
52 t.Fatalf("commit boundary = %+v", commit)
53 }
54 projection := s.Snapshot().Projection
55 if !projection.TodoWritten || len(projection.Todos) != 2 || projection.Todos[1].Content != "B" {
56 t.Fatalf("live todo projection = %+v", projection)
57 }
58 if _, err := s.Flush(t.Context()); err != nil {
59 t.Fatal(err)
60 }
61 commits, err := Replay(dir, nil)
62 if err != nil {
63 t.Fatal(err)
64 }
65 replayed, err := Project(commits)
66 if err != nil {
67 t.Fatal(err)
68 }
69 if !replayed.TodoWritten || len(replayed.Todos) != 2 || replayed.Todos[1].Content != "B" {
70 t.Fatalf("replayed todo projection = %+v", replayed)
71 }
72 }
73
74 func TestProjectionRebuildsMessagesGoalPlanInteractionsAndRecovery(t *testing.T) {
75 dir := filepath.Join(t.TempDir(), "s")
76 s, err := Open(dir, "s")
77 if err != nil {
78 t.Fatal(err)
79 }
80 t.Cleanup(func() { _ = s.Close(context.Background()) })
81 message, _ := json.Marshal(map[string]any{"message": provider.Message{ID: "m1", Role: provider.RoleUser, Content: "hello"}})
82 interaction, _ := json.Marshal(map[string]any{"id": "p1", "state": "pending"})
83 recovery, _ := json.Marshal(event.RecoveryStatus{State: "recovery_required", RequiresUserDecision: true, Reason: "worker did not exit"})
84 if _, err := s.Append(t.Context(), Batch{OperationID: "state-1", TurnID: "t1", Events: []Event{
85 {Kind: "turn/start"},
86 {Kind: "message/complete", Payload: message},
87 {Kind: "interaction/created", Payload: interaction},
88 {Kind: "plan/state", Payload: json.RawMessage(`{"status":"approved"}`)},
89 {Kind: "goal/state", Payload: json.RawMessage(`{"objective":"ship","status":"active"}`)},
90 {Kind: "runtime/recovery", Payload: recovery},
91 }}); err != nil {
92 t.Fatal(err)
93 }
94 projection := s.Snapshot().Projection
95 if len(projection.Messages) != 1 || projection.Messages[0].ID != "m1" || projection.Interactions["p1"] != "pending" {
96 t.Fatalf("message/interaction projection = %+v", projection)
97 }
98 if projection.Recovery == nil || !projection.Recovery.RequiresUserDecision || len(projection.PlanState) == 0 || len(projection.GoalState) == 0 {
99 t.Fatalf("state projection = %+v", projection)
100 }
101 }
102
103 func TestModelContextReplaceDoesNotRewriteUIHistory(t *testing.T) {
104 dir := filepath.Join(t.TempDir(), "replace")
105 store, err := Open(dir, "replace")
106 if err != nil {
107 t.Fatal(err)
108 }
109 t.Cleanup(func() { _ = store.Close(context.Background()) })
110 messages := []provider.Message{{ID: "u1", Role: provider.RoleUser, Content: "new context"}}
111 payload, _ := json.Marshal(map[string]any{"messages": messages, "reason": "compaction", "sourceSequences": []uint64{1}})
112 if _, err := store.Append(context.Background(), Batch{OperationID: "replace-1", Events: []Event{{Kind: "model/context-replace", Payload: payload}}}); err != nil {
113 t.Fatal(err)
114 }
115 got := store.Snapshot().Projection.Messages
116 if len(got) != 0 {
117 t.Fatalf("messages = %#v", got)
118 }
119 derived := store.DeriveMessages()
120 if len(derived) != 1 || derived[0].ID != "u1" {
121 t.Fatalf("model messages = %#v", derived)
122 }
123 }
124
125 func TestMessageUpsertPersistsLocalMetadataWithoutEnteringModelHistory(t *testing.T) {
126 store, err := Open(filepath.Join(t.TempDir(), "upsert"), "upsert")
127 if err != nil {
128 t.Fatal(err)
129 }
130 t.Cleanup(func() { _ = store.Close(context.Background()) })
131 local := provider.Message{ID: "receipt", Role: provider.RoleTool, Name: provider.LocalOnlyToolName, ToolCallID: provider.LocalOnlyToolID, LocalOnly: true, ProtocolRecovery: json.RawMessage(`{"version":1,"id":"r","state":"pending"}`)}
132 payload, _ := json.Marshal(map[string]any{"message": local})
133 if _, err := store.Append(t.Context(), Batch{OperationID: "upsert-local", Events: []Event{{Kind: "message/upsert", Payload: payload}}}); err != nil {
134 t.Fatal(err)
135 }
136 snapshot := store.Snapshot().Projection
137 if len(snapshot.Messages) != 1 || snapshot.Messages[0].ID != "receipt" {
138 t.Fatalf("ui messages = %#v", snapshot.Messages)
139 }
140 if len(snapshot.ModelMessages) != 0 {
141 t.Fatalf("local receipt leaked into model history: %#v", snapshot.ModelMessages)
142 }
143 local.ProtocolRecovery = json.RawMessage(`{"version":1,"id":"r","state":"consumed"}`)
144 payload, _ = json.Marshal(map[string]any{"message": local})
145 if _, err := store.Append(t.Context(), Batch{OperationID: "upsert-local-again", Events: []Event{{Kind: "message/upsert", Payload: payload}}}); err != nil {
146 t.Fatal(err)
147 }
148 if got := store.Snapshot().Projection; len(got.Messages) != 1 || len(got.ModelMessages) != 0 {
149 t.Fatalf("updated projections = %#v / %#v", got.Messages, got.ModelMessages)
150 }
151 }
152
153 func TestMessageCompleteRejectsDuplicateStableIDAtomically(t *testing.T) {
154 store, err := Open(filepath.Join(t.TempDir(), "duplicates"), "duplicates")
155 if err != nil {
156 t.Fatal(err)
157 }
158 t.Cleanup(func() { _ = store.Close(context.Background()) })
159 payload, _ := json.Marshal(map[string]any{"message": provider.Message{ID: "same", Role: provider.RoleUser, Content: "one"}})
160 if _, err := store.Append(t.Context(), Batch{OperationID: "one", Events: []Event{{Kind: "message/complete", Payload: payload}}}); err != nil {
161 t.Fatal(err)
162 }
163 before := store.Snapshot()
164 payload, _ = json.Marshal(map[string]any{"message": provider.Message{ID: "same", Role: provider.RoleAssistant, Content: "two"}})
165 if _, err := store.Append(t.Context(), Batch{OperationID: "two", Events: []Event{{Kind: "message/complete", Payload: payload}}}); err == nil {
166 t.Fatal("duplicate stable id accepted")
167 }
168 after := store.Snapshot()
169 if after.EventSequence != before.EventSequence || len(after.Projection.Messages) != 1 {
170 t.Fatalf("failed batch mutated state: before=%+v after=%+v", before, after)
171 }
172 }
173
174 func TestCompactionReplacesOnlyModelProjection(t *testing.T) {
175 dir := filepath.Join(t.TempDir(), "compaction")
176 store, err := Open(dir, "compaction")
177 if err != nil {
178 t.Fatal(err)
179 }
180 t.Cleanup(func() { _ = store.Close(context.Background()) })
181 original := provider.Message{ID: "u1", Role: provider.RoleUser, Content: "original"}
182 messagePayload, _ := json.Marshal(map[string]any{"message": original})
183 projected := []provider.Message{{ID: "summary", Role: provider.RoleUser, Content: "summary"}}
184 compactionPayload, _ := json.Marshal(map[string]any{"messages": projected, "trigger": "manual"})
185 if _, err := store.Append(context.Background(), Batch{OperationID: "compact", Events: []Event{
186 {Kind: "message/complete", Payload: messagePayload},
187 {Kind: "compaction", Payload: compactionPayload},
188 }}); err != nil {
189 t.Fatal(err)
190 }
191 snapshot := store.Snapshot().Projection
192 if len(snapshot.Messages) != 1 || snapshot.Messages[0].ID != "u1" {
193 t.Fatalf("canonical messages changed: %#v", snapshot.Messages)
194 }
195 if len(snapshot.ModelMessages) != 1 || snapshot.ModelMessages[0].ID != "summary" {
196 t.Fatalf("model projection = %#v", snapshot.ModelMessages)
197 }
198 }
199
200 func TestRestartRecoveryClosesTurnToolsAndInteractionsWithoutReplay(t *testing.T) {
201 dir := filepath.Join(t.TempDir(), "recover")
202 first, err := Open(dir, "recover")
203 if err != nil {
204 t.Fatal(err)
205 }
206 call := json.RawMessage(`{"id":"call-1","name":"bash"}`)
207 interaction := json.RawMessage(`{"id":"approval-1","state":"pending"}`)
208 if _, err := first.Append(t.Context(), Batch{OperationID: "open-turn", TurnID: "turn-1", Events: []Event{
209 {Kind: "turn/start"}, {Kind: "tool/start", Payload: call}, {Kind: "interaction/created", Payload: interaction},
210 }}); err != nil {
211 t.Fatal(err)
212 }
213 if err := first.Close(t.Context()); err != nil {
214 t.Fatal(err)
215 }
216
217 second, err := Open(dir, "recover")
218 if err != nil {
219 t.Fatal(err)
220 }
221 defer second.Close(context.Background())
222 if _, recovered, err := second.RecoverInterrupted(t.Context()); err != nil || !recovered {
223 t.Fatalf("RecoverInterrupted = recovered %v, err %v", recovered, err)
224 }
225 projection := second.Snapshot().Projection
226 if projection.TurnID != "" || projection.TurnStatus != event.TurnInterrupted || len(projection.ActiveTools) != 0 || len(projection.Interactions) != 0 {
227 t.Fatalf("recovered projection = %+v", projection)
228 }
229 }
230
231 func TestAppendValidationDoesNotChangeLiveState(t *testing.T) {
232 dir := filepath.Join(t.TempDir(), "s")
233 s, err := Open(dir, "s")
234 if err != nil {
235 t.Fatal(err)
236 }
237 t.Cleanup(func() { _ = s.Close(context.Background()) })
238 tests := []Event{
239 {Kind: "todo/write", Payload: json.RawMessage(`{"todos":[{"content":" A ","status":"pending"}]}`)},
240 {Kind: "interaction/created", Payload: json.RawMessage(`{"id":"p","state":"answered"}`)},
241 {Kind: "interaction/resolved", Payload: json.RawMessage(`{"id":"p","state":"pending"}`)},
242 {Kind: "message/complete", Payload: json.RawMessage(`{"message":{"role":"user","content":"missing id"}}`)},
243 {Kind: "plan/state", Payload: json.RawMessage(`[]`)},
244 }
245 for i, invalid := range tests {
246 before := s.Snapshot()
247 _, err := s.Append(t.Context(), Batch{OperationID: "invalid-" + string(rune('a'+i)), Events: []Event{invalid}})
248 if !errors.Is(err, ErrDamagedStore) {
249 t.Fatalf("event %s error = %v", invalid.Kind, err)
250 }
251 after := s.Snapshot()
252 if after.EventSequence != before.EventSequence {
253 t.Fatalf("invalid %s advanced sequence: before=%d after=%d", invalid.Kind, before.EventSequence, after.EventSequence)
254 }
255 }
256 }
257
258 func TestSessionConfigIsProjectedFromTheEventStream(t *testing.T) {
259 dir := filepath.Join(t.TempDir(), "s")
260 s, err := Open(dir, "s")
261 if err != nil {
262 t.Fatal(err)
263 }
264 if _, err := s.Append(t.Context(), Batch{OperationID: "config", Events: []Event{{Kind: "session/config", Payload: json.RawMessage(`{"modelRef":"provider/model","modelIdentity":"revision-1"}`)}}}); err != nil {
265 t.Fatal(err)
266 }
267 if projection := s.Snapshot().Projection; projection.ModelRef != "provider/model" || projection.ModelIdentity != "revision-1" {
268 t.Fatalf("live model selection = %q / %q", projection.ModelRef, projection.ModelIdentity)
269 }
270 if err := s.Close(t.Context()); err != nil {
271 t.Fatal(err)
272 }
273 commits, err := Replay(dir, nil)
274 if err != nil {
275 t.Fatal(err)
276 }
277 projection, err := Project(commits)
278 if err != nil {
279 t.Fatal(err)
280 }
281 if projection.ModelRef != "provider/model" || projection.ModelIdentity != "revision-1" {
282 t.Fatalf("replayed model selection = %q / %q", projection.ModelRef, projection.ModelIdentity)
283 }
284 }
285
286 func TestReplayIgnoresTornTailAndWriterRecoversUnderLease(t *testing.T) {
287 dir := filepath.Join(t.TempDir(), "s")
288 s, err := Open(dir, "s")
289 if err != nil {
290 t.Fatal(err)
291 }
292 if _, err := s.Append(t.Context(), Batch{OperationID: "turn", Events: []Event{{Kind: "turn/start"}}}); err != nil {
293 t.Fatal(err)
294 }
295 if err := s.Close(t.Context()); err != nil {
296 t.Fatal(err)
297 }
298 path := filepath.Join(dir, currentLogName)
299 file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
300 if err != nil {
301 t.Fatal(err)
302 }
303 _, _ = file.WriteString(`{"torn`)
304 _ = file.Close()
305 commits, err := Replay(dir, nil)
306 if err != nil || len(commits) != 1 {
307 t.Fatalf("Replay partial tail = %d, %v", len(commits), err)
308 }
309 reopened, err := Open(dir, "s")
310 if err != nil {
311 t.Fatalf("writer recovery: %v", err)
312 }
313 backups, err := filepath.Glob(filepath.Join(dir, "events.torn-*.tail"))
314 if err != nil || len(backups) != 1 {
315 t.Fatalf("torn tail backups = %v, %v", backups, err)
316 }
317 tail, err := os.ReadFile(backups[0])
318 if err != nil || string(tail) != `{"torn` {
319 t.Fatalf("preserved tail = %q, %v", tail, err)
320 }
321 if _, err := reopened.Append(t.Context(), Batch{OperationID: "after-recovery", Events: []Event{{Kind: "diagnostic", Optional: true}}}); err != nil {
322 t.Fatal(err)
323 }
324 if err := reopened.Close(t.Context()); err != nil {
325 t.Fatal(err)
326 }
327 commits, err = Replay(dir, nil)
328 if err != nil || len(commits) != 2 {
329 t.Fatalf("Replay recovered log = %d, %v", len(commits), err)
330 }
331 }
332
333 func TestReplayRejectsUnknownRequiredEventAndAllowsOptionalDiagnostic(t *testing.T) {
334 dir := filepath.Join(t.TempDir(), "s")
335 s, err := Open(dir, "s")
336 if err != nil {
337 t.Fatal(err)
338 }
339 if _, err := s.Append(t.Context(), Batch{OperationID: "unknown", Events: []Event{{Kind: "future/required"}}}); !errors.Is(err, ErrUnsupportedVersion) {
340 t.Fatalf("unknown required append error = %v", err)
341 }
342 if _, err := s.Append(t.Context(), Batch{OperationID: "diagnostic", Events: []Event{{Kind: "future/diagnostic", Optional: true}}}); err != nil {
343 t.Fatal(err)
344 }
345 if err := s.Close(t.Context()); err != nil {
346 t.Fatal(err)
347 }
348 if commits, err := Replay(dir, nil); err != nil || len(commits) != 1 {
349 t.Fatalf("optional diagnostic replay = %d, %v", len(commits), err)
350 }
351 }
352
353 func TestOpenUsesSingleWriterLeaseAndAdvancesGeneration(t *testing.T) {
354 dir := filepath.Join(t.TempDir(), "s")
355 first, err := Open(dir, "s")
356 if err != nil {
357 t.Fatal(err)
358 }
359 if _, err := Open(dir, "s"); !errors.Is(err, ErrWriterOwned) {
360 t.Fatalf("second writer error = %v", err)
361 }
362 firstGeneration := first.manifest.WriterGeneration
363 if err := first.Close(t.Context()); err != nil {
364 t.Fatal(err)
365 }
366 second, err := Open(dir, "s")
367 if err != nil {
368 t.Fatal(err)
369 }
370 defer second.Close(context.Background())
371 if second.manifest.WriterGeneration != firstGeneration+1 {
372 t.Fatalf("writer generation = %d, want %d", second.manifest.WriterGeneration, firstGeneration+1)
373 }
374 }
375
376 func TestMigrateLegacyIsIdempotentAndUsesFrozenArtifacts(t *testing.T) {
377 root := t.TempDir()
378 legacyDir := filepath.Join(root, "sessions")
379 path := filepath.Join(legacyDir, "old.jsonl")
380 if err := os.MkdirAll(legacyDir, 0o700); err != nil {
381 t.Fatal(err)
382 }
383 session := agent.NewSession("sys")
384 session.Add(provider.Message{Role: provider.RoleUser, Content: "hello", ID: "user-stable-id"})
385 if err := session.Save(path); err != nil {
386 t.Fatal(err)
387 }
388 if err := agent.SetBranchModelSelectionPreserveUpdated(path, "provider/model", "connection-revision"); err != nil {
389 t.Fatal(err)
390 }
391 goal := `{"objective":"finish","status":"paused","token_budget":123,"todos":[{"content":"old","status":"in_progress"}],"auto_continue":true}`
392 if err := os.WriteFile(store.SessionGoalState(path), []byte(goal), 0o600); err != nil {
393 t.Fatal(err)
394 }
395 v3root := filepath.Join(root, "sessions-v4")
396 first, err := MigrateLegacy(context.Background(), path, v3root)
397 if err != nil {
398 t.Fatal(err)
399 }
400 second, err := MigrateLegacy(context.Background(), path, v3root)
401 if err != nil {
402 t.Fatal(err)
403 }
404 if first.TargetID != second.TargetID || !second.Reused {
405 t.Fatalf("migration idempotency: first=%+v second=%+v", first, second)
406 }
407 commits, err := Replay(first.TargetDir, nil)
408 if err != nil || len(commits) == 0 {
409 t.Fatalf("Replay migrated = %d, %v", len(commits), err)
410 }
411 projection, err := Project(commits)
412 if err != nil {
413 t.Fatal(err)
414 }
415 if len(projection.Messages) != 2 || projection.Messages[1].ID != "user-stable-id" {
416 t.Fatalf("migrated messages = %+v", projection.Messages)
417 }
418 if projection.ModelRef != "provider/model" || projection.ModelIdentity != "connection-revision" {
419 t.Fatalf("migrated model selection = %q / %q", projection.ModelRef, projection.ModelIdentity)
420 }
421 if containsJSONKey(projection.GoalState, "todos") || containsJSONKey(projection.GoalState, "auto_continue") {
422 t.Fatalf("migrated goal projection = %s", projection.GoalState)
423 }
424 legacyGoal := filepath.Join(first.TargetDir, "legacy", filepath.Base(store.SessionGoalState(path)))
425 if string(mustRead(t, legacyGoal)) != goal {
426 t.Fatal("raw goal sidecar was not preserved byte-for-byte")
427 }
428 }
429
430 func TestMigrateLegacyBeyondFormer128MiBReplayLimit(t *testing.T) {
431 if os.Getenv("REASONIX_LARGE_SESSION_TEST") != "1" {
432 t.Skip("set REASONIX_LARGE_SESSION_TEST=1 to run the exact 134,308,416-byte regression")
433 }
434 const (
435 totalBytes = int64(134_308_416)
436 messages = 8_192
437 )
438 root := t.TempDir()
439 legacy := filepath.Join(root, "oversized.jsonl")
440 file, err := os.OpenFile(legacy, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
441 if err != nil {
442 t.Fatal(err)
443 }
444 prefixBytes := int64(len(fmt.Sprintf(`{"role":"user","id":"m-%08d","content":"`, 0)))
445 suffix := []byte("\"}\n")
446 contentBytes := totalBytes - int64(messages)*(prefixBytes+int64(len(suffix)))
447 if contentBytes <= 0 {
448 t.Fatal("invalid oversized fixture dimensions")
449 }
450 base, extra := contentBytes/int64(messages), contentBytes%int64(messages)
451 chunk := bytes.Repeat([]byte{'x'}, 32<<10)
452 for i := range messages {
453 prefix := fmt.Sprintf(`{"role":"user","id":"m-%08d","content":"`, i)
454 if _, err := file.WriteString(prefix); err != nil {
455 t.Fatal(err)
456 }
457 n := base
458 if int64(i) < extra {
459 n++
460 }
461 for n > 0 {
462 part := min(n, int64(len(chunk)))
463 if _, err := file.Write(chunk[:part]); err != nil {
464 t.Fatal(err)
465 }
466 n -= part
467 }
468 if _, err := file.Write(suffix); err != nil {
469 t.Fatal(err)
470 }
471 }
472 if err := file.Sync(); err != nil {
473 t.Fatal(err)
474 }
475 if err := file.Close(); err != nil {
476 t.Fatal(err)
477 }
478 info, err := os.Stat(legacy)
479 if err != nil || info.Size() != totalBytes {
480 t.Fatalf("legacy fixture size = %d, %v", info.Size(), err)
481 }
482 targetRoot := filepath.Join(root, "sessions-v4")
483 result, err := MigrateLegacy(t.Context(), legacy, targetRoot)
484 if err != nil {
485 t.Fatal(err)
486 }
487 if result.Source.Size != totalBytes || result.MessageNum != messages {
488 t.Fatalf("migration result = %+v", result)
489 }
490 service, err := NewService("capacity", NewFilesystemPersistence(targetRoot))
491 if err != nil {
492 t.Fatal(err)
493 }
494 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
495 binding, err := service.Open(t.Context(), SessionRef{HostID: "capacity", SessionID: result.TargetID})
496 if err != nil {
497 t.Fatal(err)
498 }
499 if _, err := binding.Runtime().Session().AppendBatch(t.Context(), "continued-after-oversized-migration", []Event{{Kind: "session/title", Payload: json.RawMessage(`{"title":"continued"}`)}}); err != nil {
500 t.Fatal(err)
501 }
502 receipt, err := binding.Runtime().Session().Flush(t.Context())
503 if err != nil {
504 t.Fatal(err)
505 }
506 if receipt.DurableSequence != messages+1 {
507 t.Fatalf("continued durable sequence = %d, want %d", receipt.DurableSequence, messages+1)
508 }
509 if err := binding.Release(t.Context()); err != nil {
510 t.Fatal(err)
511 }
512 }
513
514 func TestMigrateEmptyLegacyTranscriptProducesValidExplicitEmptyHistory(t *testing.T) {
515 root := t.TempDir()
516 legacy := filepath.Join(root, "empty.jsonl")
517 if err := os.WriteFile(legacy, nil, 0o600); err != nil {
518 t.Fatal(err)
519 }
520 result, err := MigrateLegacy(t.Context(), legacy, filepath.Join(root, "sessions-v4"))
521 if err != nil {
522 t.Fatal(err)
523 }
524 store, err := Open(result.TargetDir, result.TargetID)
525 if err != nil {
526 t.Fatal(err)
527 }
528 defer store.Close(context.Background())
529 projection := store.Snapshot().Projection
530 if len(projection.Messages) != 0 || len(projection.ModelMessages) != 0 {
531 t.Fatalf("empty migration projection = messages %#v model %#v", projection.Messages, projection.ModelMessages)
532 }
533 }
534
535 func TestMigrateLegacyRefusesActiveSourceLease(t *testing.T) {
536 dir := t.TempDir()
537 path := filepath.Join(dir, "old.jsonl")
538 if err := agent.NewSession("sys").Save(path); err != nil {
539 t.Fatal(err)
540 }
541 lease, err := agent.TryAcquireSessionLease(path)
542 if err != nil {
543 t.Fatal(err)
544 }
545 defer lease.Release()
546 if _, err := MigrateLegacy(t.Context(), path, filepath.Join(dir, "sessions-v4")); !errors.Is(err, agent.ErrSessionLeaseHeld) {
547 t.Fatalf("migration with active source lease error = %v", err)
548 }
549 }
550
551 func TestMigrationMapLeaseWaitIsContextCancellable(t *testing.T) {
552 path := filepath.Join(t.TempDir(), "sessions-v4", "migration-map.json")
553 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
554 t.Fatal(err)
555 }
556 release, err := filelock.TryAcquire(path + ".lock")
557 if err != nil {
558 t.Fatal(err)
559 }
560 defer release()
561 ctx, cancel := context.WithCancel(t.Context())
562 cancel()
563 if _, err := acquireMigrationMapLease(ctx, path); !errors.Is(err, context.Canceled) {
564 t.Fatalf("acquireMigrationMapLease error = %v", err)
565 }
566 }
567
568 func TestMigrateLegacyHeadsBecomeIndependentLinearSessions(t *testing.T) {
569 dir := t.TempDir()
570 path := filepath.Join(dir, "old.jsonl")
571 session := agent.NewSession("sys")
572 session.Add(provider.Message{Role: provider.RoleUser, Content: "root question"})
573 session.Add(provider.Message{Role: provider.RoleAssistant, Content: "root answer"})
574 if err := session.Save(path); err != nil {
575 t.Fatal(err)
576 }
577 childHead, err := session.ForkHead(path, session.Snapshot()[2].ID, agent.HeadKindFork, "child")
578 if err != nil {
579 t.Fatal(err)
580 }
581 session.Add(provider.Message{Role: provider.RoleUser, Content: "child only"})
582 if err := session.Save(path); err != nil {
583 t.Fatal(err)
584 }
585 heads, err := agent.ListSessionHeads(path)
586 if err != nil {
587 t.Fatal(err)
588 }
589 rootHead := ""
590 for _, head := range heads {
591 if head.ID != childHead {
592 rootHead = head.ID
593 break
594 }
595 }
596 if rootHead == "" {
597 t.Fatalf("legacy root head missing: %+v", heads)
598 }
599
600 v3root := filepath.Join(dir, "sessions-v4")
601 rootResult, err := MigrateLegacyHead(t.Context(), path, v3root, rootHead)
602 if err != nil {
603 t.Fatal(err)
604 }
605 childResult, err := MigrateLegacyHead(t.Context(), path, v3root, childHead)
606 if err != nil {
607 t.Fatal(err)
608 }
609 if rootResult.TargetID == childResult.TargetID {
610 t.Fatal("distinct legacy heads reused one v3 target")
611 }
612 rootCommits, err := Replay(rootResult.TargetDir, nil)
613 if err != nil {
614 t.Fatal(err)
615 }
616 childCommits, err := Replay(childResult.TargetDir, nil)
617 if err != nil {
618 t.Fatal(err)
619 }
620 rootProjection, err := Project(rootCommits)
621 if err != nil {
622 t.Fatal(err)
623 }
624 childProjection, err := Project(childCommits)
625 if err != nil {
626 t.Fatal(err)
627 }
628 if len(rootProjection.Messages) != 3 || len(childProjection.Messages) != 4 || childProjection.Messages[3].Content != "child only" {
629 t.Fatalf("head projections root=%+v child=%+v", rootProjection.Messages, childProjection.Messages)
630 }
631 }
632
633 func mustRead(t *testing.T, path string) []byte {
634 t.Helper()
635 b, err := os.ReadFile(path)
636 if err != nil {
637 t.Fatal(err)
638 }
639 return b
640 }
641
642 func containsJSONKey(raw []byte, key string) bool {
643 var value map[string]any
644 _ = json.Unmarshal(raw, &value)
645 _, ok := value[key]
646 return ok
647 }
648
648 lines GO