返回 DeepSeek-Reasonix
migrate_test.go
根目录 / internal / agent / migrate_test.go
1 package agent
2
3 import (
4 "encoding/json"
5 "errors"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/provider"
13 )
14
15 const legacyEventLog = `{"type":"model.turn.started","id":1,"ts":"t","turn":0,"model":"deepseek"}
16 {"type":"user.message","id":2,"ts":"t","turn":0,"text":"list the files"}
17 {"type":"model.delta","id":3,"ts":"t","turn":0,"channel":"content","text":"sure"}
18 {"type":"model.final","id":4,"ts":"t","turn":0,"content":"On it.","toolCalls":[{"id":"call_1","type":"function","function":{"name":"ls","arguments":"{\"path\":\".\"}","thought_signature":"gemini-event-signed"}}],"usage":{},"costUsd":0}
19 {"type":"tool.result","id":5,"ts":"t","turn":0,"callId":"call_1","ok":true,"output":"a.go\nb.go","durationMs":3}
20 {"type":"model.final","id":6,"ts":"t","turn":0,"content":"There are two files.","toolCalls":[],"usage":{},"costUsd":0}
21 `
22
23 func TestMigrateLegacySessionsReconstructsConversation(t *testing.T) {
24 src := t.TempDir()
25 dest := t.TempDir()
26 if err := os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644); err != nil {
27 t.Fatal(err)
28 }
29
30 n, err := MigrateLegacySessions(src, dest, nil)
31 if err != nil {
32 t.Fatalf("migrate: %v", err)
33 }
34 if n != 1 {
35 t.Fatalf("imported %d sessions, want 1", n)
36 }
37
38 loaded, err := LoadSession(filepath.Join(dest, "chat-1.jsonl"))
39 if err != nil {
40 t.Fatalf("reload migrated session: %v", err)
41 }
42 got := loaded.Messages
43 if len(got) != 4 {
44 t.Fatalf("message count = %d, want 4 (user, assistant+toolcall, tool, assistant):\n%+v", len(got), got)
45 }
46 if got[0].Role != provider.RoleUser || got[0].Content != "list the files" {
47 t.Errorf("msg0 = %+v, want user 'list the files'", got[0])
48 }
49 if got[1].Role != provider.RoleAssistant || len(got[1].ToolCalls) != 1 ||
50 got[1].ToolCalls[0].ID != "call_1" || got[1].ToolCalls[0].Name != "ls" ||
51 got[1].ToolCalls[0].ThoughtSignature != "gemini-event-signed" {
52 t.Errorf("msg1 = %+v, want assistant with ls tool call call_1", got[1])
53 }
54 if got[2].Role != provider.RoleTool || got[2].ToolCallID != "call_1" ||
55 got[2].Name != "ls" || got[2].Content != "a.go\nb.go" {
56 t.Errorf("msg2 = %+v, want tool result for call_1 named ls", got[2])
57 }
58 if got[3].Role != provider.RoleAssistant || got[3].Content != "There are two files." {
59 t.Errorf("msg3 = %+v, want final assistant text", got[3])
60 }
61 }
62
63 func TestMigrateLegacySessionsReplaysNativeEventLog(t *testing.T) {
64 src := t.TempDir()
65 dest := t.TempDir()
66 path := filepath.Join(src, "native.jsonl")
67 base := NewSession("sys")
68 base.Add(provider.Message{Role: provider.RoleUser, Content: "checkpoint prompt"})
69 if err := base.Save(path); err != nil {
70 t.Fatalf("Save base: %v", err)
71 }
72 loaded, err := LoadSession(path)
73 if err != nil {
74 t.Fatalf("LoadSession base: %v", err)
75 }
76 loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "event tail"})
77 if err := loaded.SaveSnapshot(path); err != nil {
78 t.Fatalf("SaveSnapshot tail: %v", err)
79 }
80
81 n, err := MigrateLegacySessions(src, dest, nil)
82 if err != nil {
83 t.Fatalf("migrate: %v", err)
84 }
85 if n != 1 {
86 t.Fatalf("imported %d sessions, want 1", n)
87 }
88 migrated, err := LoadSession(filepath.Join(dest, "native.jsonl"))
89 if err != nil {
90 t.Fatalf("LoadSession migrated: %v", err)
91 }
92 if got := migrated.Messages[len(migrated.Messages)-1].Content; got != "event tail" {
93 t.Fatalf("migrated tail = %q, want event tail", got)
94 }
95 }
96
97 func TestMigrateLegacySessionsBackfillsAlongsideExisting(t *testing.T) {
98 src := t.TempDir()
99 dest := t.TempDir()
100 os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644)
101 os.WriteFile(filepath.Join(dest, "existing.jsonl"), []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644)
102
103 n, err := MigrateLegacySessions(src, dest, nil)
104 if err != nil {
105 t.Fatalf("migrate: %v", err)
106 }
107 if n != 1 {
108 t.Errorf("should back-fill the legacy session even when dest has others, imported %d", n)
109 }
110 if _, err := os.Stat(filepath.Join(dest, "chat-1.jsonl")); err != nil {
111 t.Errorf("legacy session should have been imported: %v", err)
112 }
113 if _, err := os.Stat(filepath.Join(dest, "existing.jsonl")); err != nil {
114 t.Errorf("pre-existing v1+ session must be left intact: %v", err)
115 }
116 }
117
118 func TestMigrateLegacySessionsRunsOnce(t *testing.T) {
119 src := t.TempDir()
120 dest := t.TempDir()
121 os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644)
122
123 if n, err := MigrateLegacySessions(src, dest, nil); err != nil || n != 1 {
124 t.Fatalf("first run: n=%d err=%v, want 1", n, err)
125 }
126 // User deletes the imported session, then a second launch happens.
127 if err := os.Remove(filepath.Join(dest, "chat-1.jsonl")); err != nil {
128 t.Fatal(err)
129 }
130 if n, err := MigrateLegacySessions(src, dest, nil); err != nil || n != 0 {
131 t.Fatalf("second run must be a no-op (marker present): n=%d err=%v", n, err)
132 }
133 if _, err := os.Stat(filepath.Join(dest, "chat-1.jsonl")); !os.IsNotExist(err) {
134 t.Errorf("a deleted import must not reappear after the one-time migration")
135 }
136 if _, err := os.Stat(filepath.Join(dest, legacyEventsHomeImportMarker)); err != nil {
137 t.Errorf("source-specific import marker missing: %v", err)
138 }
139 if _, err := os.Stat(filepath.Join(dest, legacyImportMarker)); err != nil {
140 t.Errorf("legacy compatibility import marker missing: %v", err)
141 }
142 }
143
144 func TestMigrateLegacySessionsFromExplicitDirIgnoresDefaultMarkers(t *testing.T) {
145 src := t.TempDir()
146 dest := t.TempDir()
147 if err := os.WriteFile(filepath.Join(src, "custom-install.jsonl"), []byte(legacyMessageLog), 0o644); err != nil {
148 t.Fatal(err)
149 }
150 writeImportMarkers(dest, legacyRoutedHomeImportMarker, legacyJsonlPassMarker)
151
152 if n, err := MigrateLegacySessions(src, dest, nil); err != nil || n != 0 {
153 t.Fatalf("default migrate with markers: n=%d err=%v, want 0 nil", n, err)
154 }
155 n, err := MigrateLegacySessionsFromExplicitDir(src, dest, nil)
156 if err != nil {
157 t.Fatalf("explicit migrate: %v", err)
158 }
159 if n != 1 {
160 t.Fatalf("explicit imported %d sessions, want 1", n)
161 }
162 if _, err := os.Stat(filepath.Join(dest, "custom-install.jsonl")); err != nil {
163 t.Fatalf("explicit imported session missing: %v", err)
164 }
165 if n, err := MigrateLegacySessionsFromExplicitDir(src, dest, nil); err != nil || n != 0 {
166 t.Fatalf("explicit migrate should be source-marker idempotent: n=%d err=%v", n, err)
167 }
168 }
169
170 func TestMigrateLegacySessionsRoutedPassIgnoresFlatMarkers(t *testing.T) {
171 src := t.TempDir()
172 dest := t.TempDir()
173 os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644)
174 for _, m := range []string{legacyImportMarker, legacyEventsHomeImportMarker} {
175 if err := os.WriteFile(filepath.Join(dest, m), nil, 0o644); err != nil {
176 t.Fatal(err)
177 }
178 }
179
180 // Flat markers must not block the routed pass — it has to run once for
181 // existing upgraders to re-home sessions the flat import stranded (#3937).
182 n, err := MigrateLegacySessions(src, dest, nil)
183 if err != nil {
184 t.Fatalf("migrate: %v", err)
185 }
186 if n != 1 {
187 t.Fatalf("routed pass should run despite flat markers, got %d", n)
188 }
189 if n, err := MigrateLegacySessions(src, dest, nil); err != nil || n != 0 {
190 t.Fatalf("routed marker must gate the second run: n=%d err=%v", n, err)
191 }
192 }
193
194 func TestMigrateLegacySessionsSourceMarkersAreIndependent(t *testing.T) {
195 src := t.TempDir()
196 dest := t.TempDir()
197 os.WriteFile(filepath.Join(src, "appdata-chat.events.jsonl"), []byte(legacyEventLog), 0o644)
198 if err := os.MkdirAll(dest, 0o755); err != nil {
199 t.Fatal(err)
200 }
201 if err := os.WriteFile(filepath.Join(dest, legacyImportMarker), nil, 0o644); err != nil {
202 t.Fatal(err)
203 }
204
205 const appDataMarker = ".legacy-imported.v0-events-appdata"
206 n, err := migrateLegacySessions(src, dest, appDataMarker, nil)
207 if err != nil {
208 t.Fatalf("migrate: %v", err)
209 }
210 if n != 1 {
211 t.Fatalf("independent source marker should allow a new source import, got %d", n)
212 }
213 if _, err := os.Stat(filepath.Join(dest, "appdata-chat.jsonl")); err != nil {
214 t.Errorf("new source session should have been imported: %v", err)
215 }
216 if _, err := os.Stat(filepath.Join(dest, appDataMarker)); err != nil {
217 t.Errorf("new source marker missing: %v", err)
218 }
219 }
220
221 func TestMigrateLegacyConfigSourceDoesNotBlockHomeSource(t *testing.T) {
222 configSrc := t.TempDir()
223 homeSrc := t.TempDir()
224 dest := t.TempDir()
225 os.WriteFile(filepath.Join(homeSrc, "home-chat.events.jsonl"), []byte(legacyEventLog), 0o644)
226
227 if n, err := MigrateLegacySessionsFromConfigDir(configSrc, dest, nil); err != nil || n != 0 {
228 t.Fatalf("config source without events: n=%d err=%v, want 0 nil", n, err)
229 }
230 if _, err := os.Stat(filepath.Join(dest, legacyRoutedConfigImportMarker)); err != nil {
231 t.Fatalf("config source marker missing: %v", err)
232 }
233
234 if n, err := MigrateLegacySessions(homeSrc, dest, nil); err != nil || n != 1 {
235 t.Fatalf("home source after config source: n=%d err=%v, want 1 nil", n, err)
236 }
237 if _, err := os.Stat(filepath.Join(dest, "home-chat.jsonl")); err != nil {
238 t.Fatalf("home source should still import after config source marker: %v", err)
239 }
240 }
241
242 func TestMigrateLegacySessionsSkipsAlreadyImported(t *testing.T) {
243 src := t.TempDir()
244 dest := t.TempDir()
245 os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644)
246 os.WriteFile(filepath.Join(dest, "chat-1.jsonl"), []byte(`{"role":"user","content":"edited"}`+"\n"), 0o644)
247
248 n, err := MigrateLegacySessions(src, dest, nil)
249 if err != nil {
250 t.Fatalf("migrate: %v", err)
251 }
252 if n != 0 {
253 t.Errorf("a same-named existing session must not be overwritten, imported %d", n)
254 }
255 loaded, err := LoadSession(filepath.Join(dest, "chat-1.jsonl"))
256 if err != nil {
257 t.Fatal(err)
258 }
259 if len(loaded.Messages) != 1 || loaded.Messages[0].Content != "edited" {
260 t.Errorf("existing same-named session was clobbered: %+v", loaded.Messages)
261 }
262 }
263
264 func TestSessionSaveIfAbsentNeverReplacesExistingTranscript(t *testing.T) {
265 dest := filepath.Join(t.TempDir(), "imported.jsonl")
266 first := NewSession("sys")
267 first.Add(provider.Message{Role: provider.RoleUser, Content: "first import"})
268 if err := first.SaveIfAbsent(dest); err != nil {
269 t.Fatalf("first SaveIfAbsent: %v", err)
270 }
271 second := NewSession("sys")
272 second.Add(provider.Message{Role: provider.RoleUser, Content: "stale import"})
273 if err := second.SaveIfAbsent(dest); !errors.Is(err, os.ErrExist) {
274 t.Fatalf("second SaveIfAbsent = %v, want os.ErrExist", err)
275 }
276 loaded, err := LoadSession(dest)
277 if err != nil {
278 t.Fatalf("load preserved destination: %v", err)
279 }
280 if len(loaded.Messages) != 2 || loaded.Messages[1].Content != "first import" {
281 t.Fatalf("destination was replaced: %+v", loaded.Messages)
282 }
283 }
284
285 func TestMigrateLegacyEventLogCanShareDestinationDirectory(t *testing.T) {
286 dir := t.TempDir()
287 legacy := filepath.Join(dir, "chat-1.events.jsonl")
288 if err := os.WriteFile(legacy, []byte(legacyEventLog), 0o644); err != nil {
289 t.Fatal(err)
290 }
291
292 n, err := MigrateLegacySessions(dir, dir, nil)
293 if err != nil {
294 t.Fatalf("migrate: %v", err)
295 }
296 if n != 1 {
297 t.Fatalf("imported %d sessions, want 1", n)
298 }
299 if _, err := os.Stat(filepath.Join(dir, "chat-1.jsonl")); err != nil {
300 t.Fatalf("migrated destination missing: %v", err)
301 }
302 if _, err := os.Stat(legacy); err != nil {
303 t.Fatalf("legacy source must remain untouched: %v", err)
304 }
305 }
306
307 func TestMigrateLegacySessionsNoSrcIsNoop(t *testing.T) {
308 n, err := MigrateLegacySessions(filepath.Join(t.TempDir(), "nope"), t.TempDir(), nil)
309 if err != nil || n != 0 {
310 t.Errorf("missing legacy session dir should be a silent no-op, got n=%d err=%v", n, err)
311 }
312 }
313
314 func writeLegacyMeta(t *testing.T, srcDir, base, workspace, summary string) {
315 t.Helper()
316 b, err := json.Marshal(map[string]string{"workspace": workspace, "summary": summary})
317 if err != nil {
318 t.Fatal(err)
319 }
320 if err := os.WriteFile(filepath.Join(srcDir, base+".meta.json"), b, 0o644); err != nil {
321 t.Fatal(err)
322 }
323 }
324
325 const v1MessageSession = `{"role":"user","content":"recovered after downgrade"}
326 {"role":"assistant","content":"ok"}
327 `
328
329 func TestMigratedJsonlSessionPersistsNewTurns(t *testing.T) {
330 src := t.TempDir()
331 dest := t.TempDir()
332 if err := os.WriteFile(filepath.Join(src, "desktop-legacy.jsonl"), []byte(v1MessageSession), 0o644); err != nil {
333 t.Fatalf("write legacy session: %v", err)
334 }
335 if n, err := MigrateLegacySessions(src, dest, nil); err != nil || n != 1 {
336 t.Fatalf("MigrateLegacySessions: n=%d err=%v", n, err)
337 }
338
339 path := filepath.Join(dest, "desktop-legacy.jsonl")
340 loaded, err := LoadSession(path)
341 if err != nil {
342 t.Fatalf("LoadSession migrated: %v", err)
343 }
344 loaded.Add(provider.Message{Role: provider.RoleUser, Content: "new turn"})
345 loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "persisted"})
346 if err := loaded.SaveSnapshot(path); err != nil {
347 t.Fatalf("SaveSnapshot migrated: %v", err)
348 }
349
350 reloaded, err := LoadSession(path)
351 if err != nil {
352 t.Fatalf("LoadSession after switch: %v", err)
353 }
354 if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "persisted" {
355 t.Fatalf("migrated session tail = %q, want persisted", got)
356 }
357 }
358
359 // stampMigrated marks src/dest as already through the one-time passes, with the
360 // routing watermark set to `at`. It mirrors what a completed migration leaves
361 // behind so the re-home pass (not the full passes) handles the next run.
362 func stampMigrated(t *testing.T, dest string, at time.Time) {
363 t.Helper()
364 for _, m := range []string{legacyRoutedHomeImportMarker, legacyJsonlPassMarker, legacyImportMarker} {
365 path := filepath.Join(dest, m)
366 if err := os.WriteFile(path, nil, 0o644); err != nil {
367 t.Fatal(err)
368 }
369 if err := os.Chtimes(path, at, at); err != nil {
370 t.Fatal(err)
371 }
372 }
373 }
374
375 // TestRehomeStrandedSessionAfterDowngrade reproduces #4666: after the one-time
376 // routing pass completes, a downgrade-to-old-build writes a project session into
377 // the flat dir. The next upgrade must re-home it into its workspace dir even
378 // though the routing marker is present.
379 func TestRehomeStrandedSessionAfterDowngrade(t *testing.T) {
380 src := t.TempDir()
381 dest := t.TempDir()
382 workspace := t.TempDir()
383 projectDest := t.TempDir()
384 projectDir := func(root string) string {
385 if root == workspace {
386 return projectDest
387 }
388 return ""
389 }
390
391 // Migration already ran a day ago.
392 past := time.Now().Add(-24 * time.Hour)
393 stampMigrated(t, dest, past)
394
395 // The downgraded build then wrote a project session into the flat dir.
396 base := "20260101-000000.000000000-deepseek"
397 sessionPath := filepath.Join(src, base+".jsonl")
398 if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil {
399 t.Fatal(err)
400 }
401 if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace, TopicTitle: "downgrade work"}); err != nil {
402 t.Fatal(err)
403 }
404 ref := "sa_20260101_000000_000000000_aabbccddeeff"
405 writeMigratedSubagentArtifact(t, src, ref, base)
406
407 n, err := MigrateLegacySessions(src, dest, projectDir)
408 if err != nil {
409 t.Fatalf("migrate: %v", err)
410 }
411 if n != 1 {
412 t.Fatalf("stranded project session should be re-homed, imported %d", n)
413 }
414 if _, err := os.Stat(filepath.Join(projectDest, base+".jsonl")); err != nil {
415 t.Errorf("session should land in the project dir: %v", err)
416 }
417 // The branch sidecar must follow so the sidebar keeps title/topic.
418 if _, err := os.Stat(BranchMetaPath(filepath.Join(projectDest, base+".jsonl"))); err != nil {
419 t.Errorf("branch meta sidecar should be copied alongside: %v", err)
420 }
421 if _, err := os.Stat(filepath.Join(projectDest, "subagents", ref+".jsonl")); err != nil {
422 t.Errorf("subagent transcript should be copied alongside: %v", err)
423 }
424 if _, err := os.Stat(filepath.Join(projectDest, "subagents", ref+".meta.json")); err != nil {
425 t.Errorf("subagent metadata should be copied alongside: %v", err)
426 }
427 // Source is never modified.
428 if _, err := os.Stat(sessionPath); err != nil {
429 t.Errorf("source session must be left intact: %v", err)
430 }
431 }
432
433 func TestJsonlPassRoutesBranchMetaWhenJsonlMarkerMissing(t *testing.T) {
434 src := t.TempDir()
435 dest := t.TempDir()
436 workspace := t.TempDir()
437 projectDest := t.TempDir()
438 projectDir := func(root string) string {
439 if root == workspace {
440 return projectDest
441 }
442 return ""
443 }
444
445 past := time.Now().Add(-24 * time.Hour)
446 routedMarker := filepath.Join(dest, legacyRoutedHomeImportMarker)
447 if err := os.WriteFile(routedMarker, nil, 0o644); err != nil {
448 t.Fatal(err)
449 }
450 if err := os.Chtimes(routedMarker, past, past); err != nil {
451 t.Fatal(err)
452 }
453
454 base := "20260101-003000.000000000-deepseek"
455 sessionPath := filepath.Join(src, base+".jsonl")
456 if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil {
457 t.Fatal(err)
458 }
459 if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace, TopicTitle: "half-upgraded"}); err != nil {
460 t.Fatal(err)
461 }
462 ref := "sa_20260101_003000_000000000_aabbccddeeff"
463 writeMigratedSubagentArtifact(t, src, ref, base)
464
465 n, err := MigrateLegacySessions(src, dest, projectDir)
466 if err != nil {
467 t.Fatalf("migrate: %v", err)
468 }
469 if n != 1 {
470 t.Fatalf("branch-meta jsonl session should be imported once, got %d", n)
471 }
472 if _, err := os.Stat(filepath.Join(projectDest, base+".jsonl")); err != nil {
473 t.Fatalf("session should be routed to the project dir while the jsonl marker is missing: %v", err)
474 }
475 if _, err := os.Stat(filepath.Join(dest, base+".jsonl")); !os.IsNotExist(err) {
476 t.Fatalf("project session must not be copied into the global dir: %v", err)
477 }
478 if _, err := os.Stat(BranchMetaPath(filepath.Join(projectDest, base+".jsonl"))); err != nil {
479 t.Fatalf("branch meta sidecar should be copied alongside: %v", err)
480 }
481 if _, err := os.Stat(filepath.Join(projectDest, "subagents", ref+".jsonl")); err != nil {
482 t.Fatalf("subagent transcript should be copied alongside: %v", err)
483 }
484 if _, err := os.Stat(filepath.Join(projectDest, "subagents", ref+".meta.json")); err != nil {
485 t.Fatalf("subagent metadata should be copied alongside: %v", err)
486 }
487 if n, err := MigrateLegacySessions(src, dest, projectDir); err != nil || n != 0 {
488 t.Fatalf("second run should be a no-op: n=%d err=%v", n, err)
489 }
490 }
491
492 // TestRehomeLeavesGlobalSessionsAlone guards the main risk: the flat dir is also
493 // where CLI/global sessions live. A session with no project scope must stay put.
494 func TestRehomeLeavesGlobalSessionsAlone(t *testing.T) {
495 src := t.TempDir()
496 dest := t.TempDir()
497 projectDir := func(string) string { return t.TempDir() }
498
499 stampMigrated(t, dest, time.Now().Add(-24*time.Hour))
500
501 // A global session (no branch meta, no workspace) written post-migration.
502 base := "20260101-010000.000000000-deepseek"
503 if err := os.WriteFile(filepath.Join(src, base+".jsonl"), []byte(v1MessageSession), 0o644); err != nil {
504 t.Fatal(err)
505 }
506
507 n, err := MigrateLegacySessions(src, dest, projectDir)
508 if err != nil {
509 t.Fatalf("migrate: %v", err)
510 }
511 if n != 0 {
512 t.Errorf("a global flat session must not be re-homed, imported %d", n)
513 }
514 }
515
516 // TestRehomeIgnoresSessionsOlderThanWatermark ensures a session the user
517 // imported and then deleted is not resurrected: only files newer than the
518 // migration watermark are candidates.
519 func TestRehomeIgnoresSessionsOlderThanWatermark(t *testing.T) {
520 src := t.TempDir()
521 dest := t.TempDir()
522 workspace := t.TempDir()
523 projectDest := t.TempDir()
524 projectDir := func(root string) string {
525 if root == workspace {
526 return projectDest
527 }
528 return ""
529 }
530
531 now := time.Now()
532 stampMigrated(t, dest, now) // watermark = now
533
534 // A project session whose mtime predates the watermark (it was already seen
535 // by the original pass and the user deleted the import).
536 base := "20250101-000000.000000000-deepseek"
537 sessionPath := filepath.Join(src, base+".jsonl")
538 if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil {
539 t.Fatal(err)
540 }
541 if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace}); err != nil {
542 t.Fatal(err)
543 }
544 old := now.Add(-48 * time.Hour)
545 if err := os.Chtimes(sessionPath, old, old); err != nil {
546 t.Fatal(err)
547 }
548
549 n, err := MigrateLegacySessions(src, dest, projectDir)
550 if err != nil {
551 t.Fatalf("migrate: %v", err)
552 }
553 if n != 0 {
554 t.Errorf("a pre-watermark session must not be revived, imported %d", n)
555 }
556 }
557
558 // TestRehomeIsIdempotent verifies the second boot does not re-import: the
559 // destination check skips already-routed sessions and the watermark advances.
560 func TestRehomeIsIdempotent(t *testing.T) {
561 src := t.TempDir()
562 dest := t.TempDir()
563 workspace := t.TempDir()
564 projectDest := t.TempDir()
565 projectDir := func(root string) string {
566 if root == workspace {
567 return projectDest
568 }
569 return ""
570 }
571
572 stampMigrated(t, dest, time.Now().Add(-24*time.Hour))
573 base := "20260101-020000.000000000-deepseek"
574 sessionPath := filepath.Join(src, base+".jsonl")
575 os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644)
576 if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace}); err != nil {
577 t.Fatal(err)
578 }
579
580 if n, _ := MigrateLegacySessions(src, dest, projectDir); n != 1 {
581 t.Fatalf("first run should re-home 1, got %d", n)
582 }
583 if n, err := MigrateLegacySessions(src, dest, projectDir); err != nil || n != 0 {
584 t.Fatalf("second run must be a no-op: n=%d err=%v", n, err)
585 }
586 }
587
588 func TestRehomeKeepsWatermarkWhenProjectCopyFails(t *testing.T) {
589 src := t.TempDir()
590 dest := t.TempDir()
591 workspace := t.TempDir()
592 blocker := filepath.Join(t.TempDir(), "not-a-directory")
593 if err := os.WriteFile(blocker, []byte("block"), 0o644); err != nil {
594 t.Fatal(err)
595 }
596 projectDir := func(root string) string {
597 if root == workspace {
598 return filepath.Join(blocker, "sessions")
599 }
600 return ""
601 }
602
603 past := time.Now().Add(-24 * time.Hour).Round(0)
604 stampMigrated(t, dest, past)
605 base := "20260101-023000.000000000-deepseek"
606 sessionPath := filepath.Join(src, base+".jsonl")
607 if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil {
608 t.Fatal(err)
609 }
610 if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace}); err != nil {
611 t.Fatal(err)
612 }
613
614 if n, err := MigrateLegacySessions(src, dest, projectDir); err != nil || n != 0 {
615 t.Fatalf("copy failure should not import: n=%d err=%v", n, err)
616 }
617 info, err := os.Stat(filepath.Join(dest, legacyRoutedHomeImportMarker))
618 if err != nil {
619 t.Fatal(err)
620 }
621 if !info.ModTime().Equal(past) {
622 t.Fatalf("watermark advanced after copy failure: got %s want %s", info.ModTime(), past)
623 }
624 }
625
626 func TestRehomeKeepsWatermarkWhenSubagentCopyFails(t *testing.T) {
627 src := t.TempDir()
628 dest := t.TempDir()
629 workspace := t.TempDir()
630 projectDest := t.TempDir()
631 projectDir := func(root string) string {
632 if root == workspace {
633 return projectDest
634 }
635 return ""
636 }
637
638 past := time.Now().Add(-24 * time.Hour).Round(0)
639 stampMigrated(t, dest, past)
640 base := "20260101-024000.000000000-deepseek"
641 sessionPath := filepath.Join(src, base+".jsonl")
642 if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil {
643 t.Fatal(err)
644 }
645 if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace}); err != nil {
646 t.Fatal(err)
647 }
648 writeMigratedSubagentArtifact(t, src, "sa_20260101_024000_000000000_aabbccddeeff", base)
649 if err := os.MkdirAll(projectDest, 0o755); err != nil {
650 t.Fatal(err)
651 }
652 if err := os.WriteFile(filepath.Join(projectDest, "subagents"), []byte("block"), 0o644); err != nil {
653 t.Fatal(err)
654 }
655
656 if n, err := MigrateLegacySessions(src, dest, projectDir); err != nil || n != 1 {
657 t.Fatalf("parent session should still import: n=%d err=%v", n, err)
658 }
659 info, err := os.Stat(filepath.Join(dest, legacyRoutedHomeImportMarker))
660 if err != nil {
661 t.Fatal(err)
662 }
663 if !info.ModTime().Equal(past) {
664 t.Fatalf("watermark advanced after subagent copy failure: got %s want %s", info.ModTime(), past)
665 }
666 }
667
668 func writeMigratedSubagentArtifact(t *testing.T, sessionDir, ref, parentSession string) {
669 t.Helper()
670 subagentDir := filepath.Join(sessionDir, "subagents")
671 if err := os.MkdirAll(subagentDir, 0o755); err != nil {
672 t.Fatal(err)
673 }
674 if err := os.WriteFile(filepath.Join(subagentDir, ref+".jsonl"), []byte(`{"role":"user","content":"sub"}`+"\n"), 0o644); err != nil {
675 t.Fatal(err)
676 }
677 meta := SubagentMeta{
678 Ref: ref,
679 Status: SubagentCompleted,
680 Kind: "task",
681 Name: "task",
682 ParentSession: parentSession,
683 }
684 data, err := json.Marshal(meta)
685 if err != nil {
686 t.Fatal(err)
687 }
688 if err := os.WriteFile(filepath.Join(subagentDir, ref+".meta.json"), data, 0o644); err != nil {
689 t.Fatal(err)
690 }
691 }
692
693 func TestMigrateLegacySessionsRoutesByWorkspaceMeta(t *testing.T) {
694 src := t.TempDir()
695 global := t.TempDir()
696 workspace := t.TempDir()
697 projRoot := t.TempDir()
698 router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") }
699 os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644)
700 writeLegacyMeta(t, src, "chat-1", workspace, "fix the retry test")
701
702 n, err := MigrateLegacySessions(src, global, router)
703 if err != nil || n != 1 {
704 t.Fatalf("migrate: n=%d err=%v, want 1 nil", n, err)
705 }
706 dest := filepath.Join(projRoot, filepath.Base(workspace), "sessions")
707 if _, err := os.Stat(filepath.Join(dest, "chat-1.jsonl")); err != nil {
708 t.Fatalf("session should land in the workspace dir: %v", err)
709 }
710 if _, err := os.Stat(filepath.Join(global, "chat-1.jsonl")); !os.IsNotExist(err) {
711 t.Errorf("session must not also land in the global dir")
712 }
713 titles, err := os.ReadFile(filepath.Join(dest, ".titles.json"))
714 if err != nil {
715 t.Fatalf("titles file: %v", err)
716 }
717 m := map[string]string{}
718 if err := json.Unmarshal(titles, &m); err != nil || m["chat-1.jsonl"] != "fix the retry test" {
719 t.Errorf("title = %q (err=%v), want legacy summary", m["chat-1.jsonl"], err)
720 }
721 }
722
723 func TestMigrateLegacySessionsDeadWorkspaceFallsBackToGlobal(t *testing.T) {
724 src := t.TempDir()
725 global := t.TempDir()
726 projRoot := t.TempDir()
727 router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") }
728 os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644)
729 writeLegacyMeta(t, src, "chat-1", filepath.Join(src, "no-such-workspace"), "")
730
731 n, err := MigrateLegacySessions(src, global, router)
732 if err != nil || n != 1 {
733 t.Fatalf("migrate: n=%d err=%v, want 1 nil", n, err)
734 }
735 if _, err := os.Stat(filepath.Join(global, "chat-1.jsonl")); err != nil {
736 t.Errorf("session with a dead workspace should fall back to the global dir: %v", err)
737 }
738 }
739
740 func TestMigrateLegacySessionsRehomesFlatImport(t *testing.T) {
741 src := t.TempDir()
742 global := t.TempDir()
743 workspace := t.TempDir()
744 projRoot := t.TempDir()
745 router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") }
746 srcLog := filepath.Join(src, "chat-1.events.jsonl")
747 os.WriteFile(srcLog, []byte(legacyEventLog), 0o644)
748 writeLegacyMeta(t, src, "chat-1", workspace, "fix the retry test")
749
750 // Simulate the old flat import: file in the global dir, mtime stamped from
751 // the legacy event log.
752 flat := filepath.Join(global, "chat-1.jsonl")
753 os.WriteFile(flat, []byte(`{"role":"user","content":"flat-imported"}`+"\n"), 0o644)
754 info, err := os.Stat(srcLog)
755 if err != nil {
756 t.Fatal(err)
757 }
758 if err := os.Chtimes(flat, info.ModTime(), info.ModTime()); err != nil {
759 t.Fatal(err)
760 }
761
762 n, err := MigrateLegacySessions(src, global, router)
763 if err != nil || n != 1 {
764 t.Fatalf("migrate: n=%d err=%v, want 1 nil", n, err)
765 }
766 moved := filepath.Join(projRoot, filepath.Base(workspace), "sessions", "chat-1.jsonl")
767 b, err := os.ReadFile(moved)
768 if err != nil {
769 t.Fatalf("re-homed session missing: %v", err)
770 }
771 if !strings.Contains(string(b), "flat-imported") {
772 t.Errorf("re-home must move the existing import, not reconstruct: %s", b)
773 }
774 if _, err := os.Stat(flat); !os.IsNotExist(err) {
775 t.Errorf("flat import should be moved out of the global dir")
776 }
777 }
778
779 func TestMigrateLegacySessionsKeepsNativeSameNameSession(t *testing.T) {
780 src := t.TempDir()
781 global := t.TempDir()
782 workspace := t.TempDir()
783 projRoot := t.TempDir()
784 router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") }
785 srcLog := filepath.Join(src, "chat-1.events.jsonl")
786 os.WriteFile(srcLog, []byte(legacyEventLog), 0o644)
787 old := time.Now().Add(-48 * time.Hour)
788 if err := os.Chtimes(srcLog, old, old); err != nil {
789 t.Fatal(err)
790 }
791 writeLegacyMeta(t, src, "chat-1", workspace, "")
792
793 // A native v1+ session that happens to share the name: mtime won't match
794 // the legacy log, so it must stay where it is.
795 native := filepath.Join(global, "chat-1.jsonl")
796 os.WriteFile(native, []byte(`{"role":"user","content":"native"}`+"\n"), 0o644)
797
798 if _, err := MigrateLegacySessions(src, global, router); err != nil {
799 t.Fatalf("migrate: %v", err)
800 }
801 b, err := os.ReadFile(native)
802 if err != nil || !strings.Contains(string(b), "native") {
803 t.Errorf("native global session must be left intact: err=%v body=%s", err, b)
804 }
805 if _, err := os.Stat(filepath.Join(projRoot, filepath.Base(workspace), "sessions", "chat-1.jsonl")); err != nil {
806 t.Errorf("legacy session should still be reconstructed into its workspace dir: %v", err)
807 }
808 }
809
810 func TestMigrateLegacySessionsSkipsEmptyLog(t *testing.T) {
811 src := t.TempDir()
812 dest := t.TempDir()
813 os.WriteFile(filepath.Join(src, "empty.events.jsonl"), []byte(`{"type":"model.turn.started","id":1,"ts":"t","turn":0}`+"\n"), 0o644)
814
815 n, err := MigrateLegacySessions(src, dest, nil)
816 if err != nil {
817 t.Fatalf("migrate: %v", err)
818 }
819 if n != 0 {
820 t.Errorf("a log with no user/assistant/tool messages should not produce a session, imported %d", n)
821 }
822 }
823
824 const legacyMessageLog = `{"role":"user","content":"hello from v0.x"}
825 {"role":"assistant","content":"hi there","tool_calls":[{"id":"call_1","name":"read_file","arguments":"{\"path\":\"main.go\"}"}]}
826 {"role":"tool","tool_call_id":"call_1","name":"read_file","content":"package main"}
827 {"role":"assistant","content":"I found the file."}
828 `
829
830 func TestMigrateLegacySessionsImportsJsonlOnly(t *testing.T) {
831 src := t.TempDir()
832 dest := t.TempDir()
833 os.WriteFile(filepath.Join(src, "acp-chat.jsonl"), []byte(legacyMessageLog), 0o644)
834 writeLegacyMeta(t, src, "acp-chat", "", "ACP session about main.go")
835
836 n, err := MigrateLegacySessions(src, dest, nil)
837 if err != nil {
838 t.Fatalf("migrate: %v", err)
839 }
840 if n != 1 {
841 t.Fatalf("imported %d sessions, want 1 (jsonl-only)", n)
842 }
843
844 destPath := filepath.Join(dest, "acp-chat.jsonl")
845 data, err := os.ReadFile(destPath)
846 if err != nil {
847 t.Fatalf("imported session missing: %v", err)
848 }
849 if !strings.Contains(string(data), `"hello from v0.x"`) {
850 t.Errorf("imported content wrong:\n%s", data)
851 }
852
853 // Title from the meta sidecar should be stored.
854 titles, err := os.ReadFile(filepath.Join(dest, ".titles.json"))
855 if err != nil {
856 t.Fatalf("titles file: %v", err)
857 }
858 m := map[string]string{}
859 if err := json.Unmarshal(titles, &m); err != nil || m["acp-chat.jsonl"] != "ACP session about main.go" {
860 t.Errorf("title = %q (err=%v), want ACP session about main.go", m["acp-chat.jsonl"], err)
861 }
862
863 // Marker must be stamped.
864 if _, err := os.Stat(filepath.Join(dest, legacyJsonlPassMarker)); err != nil {
865 t.Errorf("jsonl pass marker missing: %v", err)
866 }
867 }
868
869 func TestMigrateLegacySessionsPrefersJsonlWhenNewer(t *testing.T) {
870 src := t.TempDir()
871 dest := t.TempDir()
872
873 eventsPath := filepath.Join(src, "chat-1.events.jsonl")
874 jsonlPath := filepath.Join(src, "chat-1.jsonl")
875
876 // Write the event log first (older mtime).
877 os.WriteFile(eventsPath, []byte(legacyEventLog), 0o644)
878 time.Sleep(10 * time.Millisecond) // ensure mtime differs
879 // Write the .jsonl second (newer mtime) — it should be preferred.
880 os.WriteFile(jsonlPath, []byte(legacyMessageLog), 0o644)
881 writeLegacyMeta(t, src, "chat-1", "", "newer jsonl wins")
882
883 n, err := MigrateLegacySessions(src, dest, nil)
884 if err != nil {
885 t.Fatalf("migrate: %v", err)
886 }
887 if n != 1 {
888 t.Fatalf("imported %d, want 1", n)
889 }
890
891 data, err := os.ReadFile(filepath.Join(dest, "chat-1.jsonl"))
892 if err != nil {
893 t.Fatal(err)
894 }
895 // The .jsonl content ("hello from v0.x") should win over the reconstructed
896 // event log content ("list the files").
897 if !strings.Contains(string(data), `"hello from v0.x"`) {
898 t.Errorf("expected .jsonl content to be preferred:\n%s", data)
899 }
900 }
901
902 func TestMigrateLegacySessionsFallsBackToEventsWhenJsonlOlder(t *testing.T) {
903 src := t.TempDir()
904 dest := t.TempDir()
905
906 jsonlPath := filepath.Join(src, "chat-1.jsonl")
907 eventsPath := filepath.Join(src, "chat-1.events.jsonl")
908
909 // Write the .jsonl first (older mtime).
910 os.WriteFile(jsonlPath, []byte(legacyMessageLog), 0o644)
911 time.Sleep(10 * time.Millisecond)
912 // Write the events log second (newer mtime) — events should be reconstructed.
913 os.WriteFile(eventsPath, []byte(legacyEventLog), 0o644)
914 writeLegacyMeta(t, src, "chat-1", "", "events are newer")
915
916 n, err := MigrateLegacySessions(src, dest, nil)
917 if err != nil {
918 t.Fatalf("migrate: %v", err)
919 }
920 if n != 1 {
921 t.Fatalf("imported %d, want 1", n)
922 }
923
924 data, err := os.ReadFile(filepath.Join(dest, "chat-1.jsonl"))
925 if err != nil {
926 t.Fatal(err)
927 }
928 // The reconstructed event-log content ("list the files") should win because
929 // the events log has a newer mtime.
930 if !strings.Contains(string(data), `"list the files"`) {
931 t.Errorf("expected events content to be reconstructed:\n%s", data)
932 }
933 }
934
935 func TestMigrateLegacySessionsImportsJsonlBakFallback(t *testing.T) {
936 src := t.TempDir()
937 dest := t.TempDir()
938
939 // Only a .jsonl.bak — no .jsonl, no .events.jsonl. Should recover from bak.
940 os.WriteFile(filepath.Join(src, "recovered.jsonl.bak"), []byte(legacyMessageLog), 0o644)
941 writeLegacyMeta(t, src, "recovered", "", "recovered from bak")
942
943 n, err := MigrateLegacySessions(src, dest, nil)
944 if err != nil {
945 t.Fatalf("migrate: %v", err)
946 }
947 if n != 1 {
948 t.Fatalf("imported %d, want 1 (.bak recovery)", n)
949 }
950
951 data, err := os.ReadFile(filepath.Join(dest, "recovered.jsonl"))
952 if err != nil {
953 t.Fatalf("recovered session missing: %v", err)
954 }
955 if !strings.Contains(string(data), `"hello from v0.x"`) {
956 t.Errorf("recovered content wrong:\n%s", data)
957 }
958 }
959
960 func TestMigrateLegacySessionsSkipsBakWhenJsonlExists(t *testing.T) {
961 src := t.TempDir()
962 dest := t.TempDir()
963
964 // Both .jsonl and .jsonl.bak exist — prefer .jsonl.
965 os.WriteFile(filepath.Join(src, "chat.jsonl"), []byte(legacyMessageLog), 0o644)
966 os.WriteFile(filepath.Join(src, "chat.jsonl.bak"), []byte(`{"role":"user","content":"stale backup"}`+"\n"), 0o644)
967 writeLegacyMeta(t, src, "chat", "", "from jsonl not bak")
968
969 n, err := MigrateLegacySessions(src, dest, nil)
970 if err != nil {
971 t.Fatalf("migrate: %v", err)
972 }
973 if n != 1 {
974 t.Fatalf("imported %d, want 1", n)
975 }
976
977 data, err := os.ReadFile(filepath.Join(dest, "chat.jsonl"))
978 if err != nil {
979 t.Fatal(err)
980 }
981 if !strings.Contains(string(data), `"hello from v0.x"`) {
982 t.Errorf("expected .jsonl content, not .bak:\n%s", data)
983 }
984 }
985
986 func TestMigrateLegacySessionsSkipsNonMessageJsonl(t *testing.T) {
987 src := t.TempDir()
988 dest := t.TempDir()
989
990 // A .jsonl file that is NOT in message format (starts with event-log "id").
991 os.WriteFile(filepath.Join(src, "bad.jsonl"), []byte(`{"id":1,"type":"model.turn.started"}`+"\n"), 0o644)
992
993 n, err := MigrateLegacySessions(src, dest, nil)
994 if err != nil {
995 t.Fatalf("migrate: %v", err)
996 }
997 if n != 0 {
998 t.Errorf("non-message .jsonl should be skipped, imported %d", n)
999 }
1000 }
1001
1002 func TestMigrateLegacySessionsRecursesIntoSubdirectories(t *testing.T) {
1003 src := t.TempDir()
1004 global := t.TempDir()
1005 workspace := t.TempDir()
1006 projRoot := t.TempDir()
1007 router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") }
1008
1009 // Set up a project-scoped subdirectory with sessions.
1010 subDir := filepath.Join(src, "Users_Yuki_git_polytone-audio-engine")
1011 if err := os.MkdirAll(subDir, 0o755); err != nil {
1012 t.Fatal(err)
1013 }
1014 os.WriteFile(filepath.Join(subDir, "proj-chat.events.jsonl"), []byte(legacyEventLog), 0o644)
1015 writeLegacyMeta(t, subDir, "proj-chat", workspace, "project session")
1016
1017 // Also add a subdirectory that has no session files — should be skipped.
1018 emptySub := filepath.Join(src, "empty-dir")
1019 os.MkdirAll(emptySub, 0o755)
1020
1021 n, err := MigrateLegacySessions(src, global, router)
1022 if err != nil {
1023 t.Fatalf("migrate: %v", err)
1024 }
1025 if n != 1 {
1026 t.Fatalf("imported %d, want 1 (subdirectory session)", n)
1027 }
1028
1029 // Should land in the project session dir, not global.
1030 projDest := filepath.Join(projRoot, filepath.Base(workspace), "sessions", "proj-chat.jsonl")
1031 if _, err := os.Stat(projDest); err != nil {
1032 t.Errorf("subdirectory session not in project dir %s: %v", projDest, err)
1033 }
1034 if _, err := os.Stat(filepath.Join(global, "proj-chat.jsonl")); !os.IsNotExist(err) {
1035 t.Errorf("subdirectory session should not land in global dir")
1036 }
1037 }
1038
1039 func TestMigrateLegacySessionsJsonlPassIsIdempotent(t *testing.T) {
1040 src := t.TempDir()
1041 dest := t.TempDir()
1042
1043 os.WriteFile(filepath.Join(src, "desktop-session.jsonl"), []byte(legacyMessageLog), 0o644)
1044
1045 // First run imports it.
1046 n, err := MigrateLegacySessions(src, dest, nil)
1047 if err != nil || n != 1 {
1048 t.Fatalf("first run: n=%d err=%v, want 1", n, err)
1049 }
1050 // Delete the imported session.
1051 os.Remove(filepath.Join(dest, "desktop-session.jsonl"))
1052
1053 // Second run: jsonl pass marker exists, must not re-import.
1054 n, err = MigrateLegacySessions(src, dest, nil)
1055 if err != nil || n != 0 {
1056 t.Fatalf("second run must be no-op: n=%d err=%v", n, err)
1057 }
1058 if _, err := os.Stat(filepath.Join(dest, "desktop-session.jsonl")); !os.IsNotExist(err) {
1059 t.Errorf("deleted session must not reappear after jsonl pass marker")
1060 }
1061 }
1062
1063 func TestMigrateLegacySessionsJsonlPassRunsForExistingUpgrader(t *testing.T) {
1064 src := t.TempDir()
1065 dest := t.TempDir()
1066
1067 os.WriteFile(filepath.Join(src, "acp-chat.jsonl"), []byte(legacyMessageLog), 0o644)
1068
1069 // Simulate an upgrader whose events pass already completed in a prior
1070 // version: the routed marker is stamped but the v3-jsonl marker is not.
1071 writeImportMarkers(dest, legacyRoutedHomeImportMarker)
1072
1073 n, err := MigrateLegacySessions(src, dest, nil)
1074 if err != nil {
1075 t.Fatalf("migrate: %v", err)
1076 }
1077 if n != 1 {
1078 t.Fatalf("imported %d, want 1 (.jsonl-only must reach existing upgraders)", n)
1079 }
1080 if _, err := os.Stat(filepath.Join(dest, "acp-chat.jsonl")); err != nil {
1081 t.Errorf("jsonl-only session not imported for existing upgrader: %v", err)
1082 }
1083 }
1084
1085 // legacyNestedFunctionLog uses the OpenAI-style nested-function tool-call format
1086 // that the TS version wrote: name and arguments live under "function".
1087 const legacyNestedFunctionLog = `{"role":"user","content":"read the file"}
1088 {"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"main.go\"}","thought_signature":"gemini-jsonl-signed"}}],"reasoning_content":"need to read it"}
1089 {"role":"tool","tool_call_id":"call_1","name":"read_file","content":"package main\nfunc main() {}"}
1090 {"role":"assistant","content":"Found the main function."}
1091 `
1092
1093 func TestTransformAndCopyJsonlFlattensNestedToolCalls(t *testing.T) {
1094 src := t.TempDir()
1095 dest := t.TempDir()
1096 os.WriteFile(filepath.Join(src, "chat.jsonl"), []byte(legacyNestedFunctionLog), 0o644)
1097 writeLegacyMeta(t, src, "chat", "", "nested tool calls test")
1098
1099 n, err := MigrateLegacySessions(src, dest, nil)
1100 if err != nil {
1101 t.Fatalf("migrate: %v", err)
1102 }
1103 if n != 1 {
1104 t.Fatalf("imported %d, want 1", n)
1105 }
1106
1107 // Reload and verify the tool calls are flat, not nested.
1108 loaded, err := LoadSession(filepath.Join(dest, "chat.jsonl"))
1109 if err != nil {
1110 t.Fatal(err)
1111 }
1112 msgs := loaded.Messages
1113 if len(msgs) != 4 {
1114 t.Fatalf("message count = %d, want 4", len(msgs))
1115 }
1116 // Message 1: assistant with tool call.
1117 if len(msgs[1].ToolCalls) != 1 {
1118 t.Fatalf("assistant tool_calls = %d, want 1", len(msgs[1].ToolCalls))
1119 }
1120 tc := msgs[1].ToolCalls[0]
1121 if tc.ID != "call_1" {
1122 t.Errorf("tool call id = %q, want call_1", tc.ID)
1123 }
1124 if tc.Name != "read_file" {
1125 t.Errorf("tool call name = %q, want read_file", tc.Name)
1126 }
1127 if tc.Arguments != `{"path":"main.go"}` {
1128 t.Errorf("tool call arguments = %q, want {\"path\":\"main.go\"}", tc.Arguments)
1129 }
1130 if tc.ThoughtSignature != "gemini-jsonl-signed" {
1131 t.Errorf("tool call thought_signature = %q, want gemini-jsonl-signed", tc.ThoughtSignature)
1132 }
1133 // Message 2: tool result.
1134 if msgs[2].Role != provider.RoleTool || msgs[2].ToolCallID != "call_1" || msgs[2].Name != "read_file" {
1135 t.Errorf("tool result = %+v, want tool result for call_1", msgs[2])
1136 }
1137 // Message 3: final assistant text.
1138 if msgs[3].Content != "Found the main function." {
1139 t.Errorf("final content = %q", msgs[3].Content)
1140 }
1141 }
1142
1142 lines GO