package agent import ( "encoding/json" "os" "path/filepath" "strings" "testing" "time" "reasonix/internal/provider" ) const legacyEventLog = `{"type":"model.turn.started","id":1,"ts":"t","turn":0,"model":"deepseek"} {"type":"user.message","id":2,"ts":"t","turn":0,"text":"list the files"} {"type":"model.delta","id":3,"ts":"t","turn":0,"channel":"content","text":"sure"} {"type":"model.final","id":4,"ts":"t","turn":0,"content":"On it.","toolCalls":[{"id":"call_1","type":"function","function":{"name":"ls","arguments":"{\"path\":\".\"}"}}],"usage":{},"costUsd":0} {"type":"tool.result","id":5,"ts":"t","turn":0,"callId":"call_1","ok":true,"output":"a.go\nb.go","durationMs":3} {"type":"model.final","id":6,"ts":"t","turn":0,"content":"There are two files.","toolCalls":[],"usage":{},"costUsd":0} ` func TestMigrateLegacySessionsReconstructsConversation(t *testing.T) { src := t.TempDir() dest := t.TempDir() if err := os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644); err != nil { t.Fatal(err) } n, err := MigrateLegacySessions(src, dest, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Fatalf("imported %d sessions, want 1", n) } loaded, err := LoadSession(filepath.Join(dest, "chat-1.jsonl")) if err != nil { t.Fatalf("reload migrated session: %v", err) } got := loaded.Messages if len(got) != 4 { t.Fatalf("message count = %d, want 4 (user, assistant+toolcall, tool, assistant):\n%+v", len(got), got) } if got[0].Role != provider.RoleUser || got[0].Content != "list the files" { t.Errorf("msg0 = %+v, want user 'list the files'", got[0]) } if got[1].Role != provider.RoleAssistant || len(got[1].ToolCalls) != 1 || got[1].ToolCalls[0].ID != "call_1" || got[1].ToolCalls[0].Name != "ls" { t.Errorf("msg1 = %+v, want assistant with ls tool call call_1", got[1]) } if got[2].Role != provider.RoleTool || got[2].ToolCallID != "call_1" || got[2].Name != "ls" || got[2].Content != "a.go\nb.go" { t.Errorf("msg2 = %+v, want tool result for call_1 named ls", got[2]) } if got[3].Role != provider.RoleAssistant || got[3].Content != "There are two files." { t.Errorf("msg3 = %+v, want final assistant text", got[3]) } } func TestMigrateLegacySessionsReplaysNativeEventLog(t *testing.T) { src := t.TempDir() dest := t.TempDir() path := filepath.Join(src, "native.jsonl") base := NewSession("sys") base.Add(provider.Message{Role: provider.RoleUser, Content: "checkpoint prompt"}) if err := base.Save(path); err != nil { t.Fatalf("Save base: %v", err) } loaded, err := LoadSession(path) if err != nil { t.Fatalf("LoadSession base: %v", err) } loaded.Add(provider.Message{Role: provider.RoleAssistant, Content: "event tail"}) if err := loaded.SaveSnapshot(path); err != nil { t.Fatalf("SaveSnapshot tail: %v", err) } n, err := MigrateLegacySessions(src, dest, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Fatalf("imported %d sessions, want 1", n) } migrated, err := LoadSession(filepath.Join(dest, "native.jsonl")) if err != nil { t.Fatalf("LoadSession migrated: %v", err) } if got := migrated.Messages[len(migrated.Messages)-1].Content; got != "event tail" { t.Fatalf("migrated tail = %q, want event tail", got) } } func TestMigrateLegacySessionsBackfillsAlongsideExisting(t *testing.T) { src := t.TempDir() dest := t.TempDir() os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644) os.WriteFile(filepath.Join(dest, "existing.jsonl"), []byte(`{"role":"user","content":"hi"}`+"\n"), 0o644) n, err := MigrateLegacySessions(src, dest, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Errorf("should back-fill the legacy session even when dest has others, imported %d", n) } if _, err := os.Stat(filepath.Join(dest, "chat-1.jsonl")); err != nil { t.Errorf("legacy session should have been imported: %v", err) } if _, err := os.Stat(filepath.Join(dest, "existing.jsonl")); err != nil { t.Errorf("pre-existing v1+ session must be left intact: %v", err) } } func TestMigrateLegacySessionsRunsOnce(t *testing.T) { src := t.TempDir() dest := t.TempDir() os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644) if n, err := MigrateLegacySessions(src, dest, nil); err != nil || n != 1 { t.Fatalf("first run: n=%d err=%v, want 1", n, err) } // User deletes the imported session, then a second launch happens. if err := os.Remove(filepath.Join(dest, "chat-1.jsonl")); err != nil { t.Fatal(err) } if n, err := MigrateLegacySessions(src, dest, nil); err != nil || n != 0 { t.Fatalf("second run must be a no-op (marker present): n=%d err=%v", n, err) } if _, err := os.Stat(filepath.Join(dest, "chat-1.jsonl")); !os.IsNotExist(err) { t.Errorf("a deleted import must not reappear after the one-time migration") } if _, err := os.Stat(filepath.Join(dest, legacyEventsHomeImportMarker)); err != nil { t.Errorf("source-specific import marker missing: %v", err) } if _, err := os.Stat(filepath.Join(dest, legacyImportMarker)); err != nil { t.Errorf("legacy compatibility import marker missing: %v", err) } } func TestMigrateLegacySessionsFromExplicitDirIgnoresDefaultMarkers(t *testing.T) { src := t.TempDir() dest := t.TempDir() if err := os.WriteFile(filepath.Join(src, "custom-install.jsonl"), []byte(legacyMessageLog), 0o644); err != nil { t.Fatal(err) } writeImportMarkers(dest, legacyRoutedHomeImportMarker, legacyJsonlPassMarker) if n, err := MigrateLegacySessions(src, dest, nil); err != nil || n != 0 { t.Fatalf("default migrate with markers: n=%d err=%v, want 0 nil", n, err) } n, err := MigrateLegacySessionsFromExplicitDir(src, dest, nil) if err != nil { t.Fatalf("explicit migrate: %v", err) } if n != 1 { t.Fatalf("explicit imported %d sessions, want 1", n) } if _, err := os.Stat(filepath.Join(dest, "custom-install.jsonl")); err != nil { t.Fatalf("explicit imported session missing: %v", err) } if n, err := MigrateLegacySessionsFromExplicitDir(src, dest, nil); err != nil || n != 0 { t.Fatalf("explicit migrate should be source-marker idempotent: n=%d err=%v", n, err) } } func TestMigrateLegacySessionsRoutedPassIgnoresFlatMarkers(t *testing.T) { src := t.TempDir() dest := t.TempDir() os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644) for _, m := range []string{legacyImportMarker, legacyEventsHomeImportMarker} { if err := os.WriteFile(filepath.Join(dest, m), nil, 0o644); err != nil { t.Fatal(err) } } // Flat markers must not block the routed pass — it has to run once for // existing upgraders to re-home sessions the flat import stranded (#3937). n, err := MigrateLegacySessions(src, dest, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Fatalf("routed pass should run despite flat markers, got %d", n) } if n, err := MigrateLegacySessions(src, dest, nil); err != nil || n != 0 { t.Fatalf("routed marker must gate the second run: n=%d err=%v", n, err) } } func TestMigrateLegacySessionsSourceMarkersAreIndependent(t *testing.T) { src := t.TempDir() dest := t.TempDir() os.WriteFile(filepath.Join(src, "appdata-chat.events.jsonl"), []byte(legacyEventLog), 0o644) if err := os.MkdirAll(dest, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dest, legacyImportMarker), nil, 0o644); err != nil { t.Fatal(err) } const appDataMarker = ".legacy-imported.v0-events-appdata" n, err := migrateLegacySessions(src, dest, appDataMarker, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Fatalf("independent source marker should allow a new source import, got %d", n) } if _, err := os.Stat(filepath.Join(dest, "appdata-chat.jsonl")); err != nil { t.Errorf("new source session should have been imported: %v", err) } if _, err := os.Stat(filepath.Join(dest, appDataMarker)); err != nil { t.Errorf("new source marker missing: %v", err) } } func TestMigrateLegacyConfigSourceDoesNotBlockHomeSource(t *testing.T) { configSrc := t.TempDir() homeSrc := t.TempDir() dest := t.TempDir() os.WriteFile(filepath.Join(homeSrc, "home-chat.events.jsonl"), []byte(legacyEventLog), 0o644) if n, err := MigrateLegacySessionsFromConfigDir(configSrc, dest, nil); err != nil || n != 0 { t.Fatalf("config source without events: n=%d err=%v, want 0 nil", n, err) } if _, err := os.Stat(filepath.Join(dest, legacyRoutedConfigImportMarker)); err != nil { t.Fatalf("config source marker missing: %v", err) } if n, err := MigrateLegacySessions(homeSrc, dest, nil); err != nil || n != 1 { t.Fatalf("home source after config source: n=%d err=%v, want 1 nil", n, err) } if _, err := os.Stat(filepath.Join(dest, "home-chat.jsonl")); err != nil { t.Fatalf("home source should still import after config source marker: %v", err) } } func TestMigrateLegacySessionsSkipsAlreadyImported(t *testing.T) { src := t.TempDir() dest := t.TempDir() os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644) os.WriteFile(filepath.Join(dest, "chat-1.jsonl"), []byte(`{"role":"user","content":"edited"}`+"\n"), 0o644) n, err := MigrateLegacySessions(src, dest, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 0 { t.Errorf("a same-named existing session must not be overwritten, imported %d", n) } loaded, err := LoadSession(filepath.Join(dest, "chat-1.jsonl")) if err != nil { t.Fatal(err) } if len(loaded.Messages) != 1 || loaded.Messages[0].Content != "edited" { t.Errorf("existing same-named session was clobbered: %+v", loaded.Messages) } } func TestMigrateLegacySessionsNoSrcIsNoop(t *testing.T) { n, err := MigrateLegacySessions(filepath.Join(t.TempDir(), "nope"), t.TempDir(), nil) if err != nil || n != 0 { t.Errorf("missing legacy session dir should be a silent no-op, got n=%d err=%v", n, err) } } func writeLegacyMeta(t *testing.T, srcDir, base, workspace, summary string) { t.Helper() b, err := json.Marshal(map[string]string{"workspace": workspace, "summary": summary}) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(srcDir, base+".meta.json"), b, 0o644); err != nil { t.Fatal(err) } } const v1MessageSession = `{"role":"user","content":"recovered after downgrade"} {"role":"assistant","content":"ok"} ` // stampMigrated marks src/dest as already through the one-time passes, with the // routing watermark set to `at`. It mirrors what a completed migration leaves // behind so the re-home pass (not the full passes) handles the next run. func stampMigrated(t *testing.T, dest string, at time.Time) { t.Helper() for _, m := range []string{legacyRoutedHomeImportMarker, legacyJsonlPassMarker, legacyImportMarker} { path := filepath.Join(dest, m) if err := os.WriteFile(path, nil, 0o644); err != nil { t.Fatal(err) } if err := os.Chtimes(path, at, at); err != nil { t.Fatal(err) } } } // TestRehomeStrandedSessionAfterDowngrade reproduces #4666: after the one-time // routing pass completes, a downgrade-to-old-build writes a project session into // the flat dir. The next upgrade must re-home it into its workspace dir even // though the routing marker is present. func TestRehomeStrandedSessionAfterDowngrade(t *testing.T) { src := t.TempDir() dest := t.TempDir() workspace := t.TempDir() projectDest := t.TempDir() projectDir := func(root string) string { if root == workspace { return projectDest } return "" } // Migration already ran a day ago. past := time.Now().Add(-24 * time.Hour) stampMigrated(t, dest, past) // The downgraded build then wrote a project session into the flat dir. base := "20260101-000000.000000000-deepseek" sessionPath := filepath.Join(src, base+".jsonl") if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil { t.Fatal(err) } if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace, TopicTitle: "downgrade work"}); err != nil { t.Fatal(err) } ref := "sa_20260101_000000_000000000_aabbccddeeff" writeMigratedSubagentArtifact(t, src, ref, base) n, err := MigrateLegacySessions(src, dest, projectDir) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Fatalf("stranded project session should be re-homed, imported %d", n) } if _, err := os.Stat(filepath.Join(projectDest, base+".jsonl")); err != nil { t.Errorf("session should land in the project dir: %v", err) } // The branch sidecar must follow so the sidebar keeps title/topic. if _, err := os.Stat(BranchMetaPath(filepath.Join(projectDest, base+".jsonl"))); err != nil { t.Errorf("branch meta sidecar should be copied alongside: %v", err) } if _, err := os.Stat(filepath.Join(projectDest, "subagents", ref+".jsonl")); err != nil { t.Errorf("subagent transcript should be copied alongside: %v", err) } if _, err := os.Stat(filepath.Join(projectDest, "subagents", ref+".meta.json")); err != nil { t.Errorf("subagent metadata should be copied alongside: %v", err) } // Source is never modified. if _, err := os.Stat(sessionPath); err != nil { t.Errorf("source session must be left intact: %v", err) } } func TestJsonlPassRoutesBranchMetaWhenJsonlMarkerMissing(t *testing.T) { src := t.TempDir() dest := t.TempDir() workspace := t.TempDir() projectDest := t.TempDir() projectDir := func(root string) string { if root == workspace { return projectDest } return "" } past := time.Now().Add(-24 * time.Hour) routedMarker := filepath.Join(dest, legacyRoutedHomeImportMarker) if err := os.WriteFile(routedMarker, nil, 0o644); err != nil { t.Fatal(err) } if err := os.Chtimes(routedMarker, past, past); err != nil { t.Fatal(err) } base := "20260101-003000.000000000-deepseek" sessionPath := filepath.Join(src, base+".jsonl") if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil { t.Fatal(err) } if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace, TopicTitle: "half-upgraded"}); err != nil { t.Fatal(err) } ref := "sa_20260101_003000_000000000_aabbccddeeff" writeMigratedSubagentArtifact(t, src, ref, base) n, err := MigrateLegacySessions(src, dest, projectDir) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Fatalf("branch-meta jsonl session should be imported once, got %d", n) } if _, err := os.Stat(filepath.Join(projectDest, base+".jsonl")); err != nil { t.Fatalf("session should be routed to the project dir while the jsonl marker is missing: %v", err) } if _, err := os.Stat(filepath.Join(dest, base+".jsonl")); !os.IsNotExist(err) { t.Fatalf("project session must not be copied into the global dir: %v", err) } if _, err := os.Stat(BranchMetaPath(filepath.Join(projectDest, base+".jsonl"))); err != nil { t.Fatalf("branch meta sidecar should be copied alongside: %v", err) } if _, err := os.Stat(filepath.Join(projectDest, "subagents", ref+".jsonl")); err != nil { t.Fatalf("subagent transcript should be copied alongside: %v", err) } if _, err := os.Stat(filepath.Join(projectDest, "subagents", ref+".meta.json")); err != nil { t.Fatalf("subagent metadata should be copied alongside: %v", err) } if n, err := MigrateLegacySessions(src, dest, projectDir); err != nil || n != 0 { t.Fatalf("second run should be a no-op: n=%d err=%v", n, err) } } // TestRehomeLeavesGlobalSessionsAlone guards the main risk: the flat dir is also // where CLI/global sessions live. A session with no project scope must stay put. func TestRehomeLeavesGlobalSessionsAlone(t *testing.T) { src := t.TempDir() dest := t.TempDir() projectDir := func(string) string { return t.TempDir() } stampMigrated(t, dest, time.Now().Add(-24*time.Hour)) // A global session (no branch meta, no workspace) written post-migration. base := "20260101-010000.000000000-deepseek" if err := os.WriteFile(filepath.Join(src, base+".jsonl"), []byte(v1MessageSession), 0o644); err != nil { t.Fatal(err) } n, err := MigrateLegacySessions(src, dest, projectDir) if err != nil { t.Fatalf("migrate: %v", err) } if n != 0 { t.Errorf("a global flat session must not be re-homed, imported %d", n) } } // TestRehomeIgnoresSessionsOlderThanWatermark ensures a session the user // imported and then deleted is not resurrected: only files newer than the // migration watermark are candidates. func TestRehomeIgnoresSessionsOlderThanWatermark(t *testing.T) { src := t.TempDir() dest := t.TempDir() workspace := t.TempDir() projectDest := t.TempDir() projectDir := func(root string) string { if root == workspace { return projectDest } return "" } now := time.Now() stampMigrated(t, dest, now) // watermark = now // A project session whose mtime predates the watermark (it was already seen // by the original pass and the user deleted the import). base := "20250101-000000.000000000-deepseek" sessionPath := filepath.Join(src, base+".jsonl") if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil { t.Fatal(err) } if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace}); err != nil { t.Fatal(err) } old := now.Add(-48 * time.Hour) if err := os.Chtimes(sessionPath, old, old); err != nil { t.Fatal(err) } n, err := MigrateLegacySessions(src, dest, projectDir) if err != nil { t.Fatalf("migrate: %v", err) } if n != 0 { t.Errorf("a pre-watermark session must not be revived, imported %d", n) } } // TestRehomeIsIdempotent verifies the second boot does not re-import: the // destination check skips already-routed sessions and the watermark advances. func TestRehomeIsIdempotent(t *testing.T) { src := t.TempDir() dest := t.TempDir() workspace := t.TempDir() projectDest := t.TempDir() projectDir := func(root string) string { if root == workspace { return projectDest } return "" } stampMigrated(t, dest, time.Now().Add(-24*time.Hour)) base := "20260101-020000.000000000-deepseek" sessionPath := filepath.Join(src, base+".jsonl") os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644) if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace}); err != nil { t.Fatal(err) } if n, _ := MigrateLegacySessions(src, dest, projectDir); n != 1 { t.Fatalf("first run should re-home 1, got %d", n) } if n, err := MigrateLegacySessions(src, dest, projectDir); err != nil || n != 0 { t.Fatalf("second run must be a no-op: n=%d err=%v", n, err) } } func TestRehomeKeepsWatermarkWhenProjectCopyFails(t *testing.T) { src := t.TempDir() dest := t.TempDir() workspace := t.TempDir() blocker := filepath.Join(t.TempDir(), "not-a-directory") if err := os.WriteFile(blocker, []byte("block"), 0o644); err != nil { t.Fatal(err) } projectDir := func(root string) string { if root == workspace { return filepath.Join(blocker, "sessions") } return "" } past := time.Now().Add(-24 * time.Hour).Round(0) stampMigrated(t, dest, past) base := "20260101-023000.000000000-deepseek" sessionPath := filepath.Join(src, base+".jsonl") if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil { t.Fatal(err) } if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace}); err != nil { t.Fatal(err) } if n, err := MigrateLegacySessions(src, dest, projectDir); err != nil || n != 0 { t.Fatalf("copy failure should not import: n=%d err=%v", n, err) } info, err := os.Stat(filepath.Join(dest, legacyRoutedHomeImportMarker)) if err != nil { t.Fatal(err) } if !info.ModTime().Equal(past) { t.Fatalf("watermark advanced after copy failure: got %s want %s", info.ModTime(), past) } } func TestRehomeKeepsWatermarkWhenSubagentCopyFails(t *testing.T) { src := t.TempDir() dest := t.TempDir() workspace := t.TempDir() projectDest := t.TempDir() projectDir := func(root string) string { if root == workspace { return projectDest } return "" } past := time.Now().Add(-24 * time.Hour).Round(0) stampMigrated(t, dest, past) base := "20260101-024000.000000000-deepseek" sessionPath := filepath.Join(src, base+".jsonl") if err := os.WriteFile(sessionPath, []byte(v1MessageSession), 0o644); err != nil { t.Fatal(err) } if err := SaveBranchMeta(sessionPath, BranchMeta{Scope: "project", WorkspaceRoot: workspace}); err != nil { t.Fatal(err) } writeMigratedSubagentArtifact(t, src, "sa_20260101_024000_000000000_aabbccddeeff", base) if err := os.MkdirAll(projectDest, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(projectDest, "subagents"), []byte("block"), 0o644); err != nil { t.Fatal(err) } if n, err := MigrateLegacySessions(src, dest, projectDir); err != nil || n != 1 { t.Fatalf("parent session should still import: n=%d err=%v", n, err) } info, err := os.Stat(filepath.Join(dest, legacyRoutedHomeImportMarker)) if err != nil { t.Fatal(err) } if !info.ModTime().Equal(past) { t.Fatalf("watermark advanced after subagent copy failure: got %s want %s", info.ModTime(), past) } } func writeMigratedSubagentArtifact(t *testing.T, sessionDir, ref, parentSession string) { t.Helper() subagentDir := filepath.Join(sessionDir, "subagents") if err := os.MkdirAll(subagentDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(subagentDir, ref+".jsonl"), []byte(`{"role":"user","content":"sub"}`+"\n"), 0o644); err != nil { t.Fatal(err) } meta := SubagentMeta{ Ref: ref, Status: SubagentCompleted, Kind: "task", Name: "task", ParentSession: parentSession, } data, err := json.Marshal(meta) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(subagentDir, ref+".meta.json"), data, 0o644); err != nil { t.Fatal(err) } } func TestMigrateLegacySessionsRoutesByWorkspaceMeta(t *testing.T) { src := t.TempDir() global := t.TempDir() workspace := t.TempDir() projRoot := t.TempDir() router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") } os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644) writeLegacyMeta(t, src, "chat-1", workspace, "fix the retry test") n, err := MigrateLegacySessions(src, global, router) if err != nil || n != 1 { t.Fatalf("migrate: n=%d err=%v, want 1 nil", n, err) } dest := filepath.Join(projRoot, filepath.Base(workspace), "sessions") if _, err := os.Stat(filepath.Join(dest, "chat-1.jsonl")); err != nil { t.Fatalf("session should land in the workspace dir: %v", err) } if _, err := os.Stat(filepath.Join(global, "chat-1.jsonl")); !os.IsNotExist(err) { t.Errorf("session must not also land in the global dir") } titles, err := os.ReadFile(filepath.Join(dest, ".titles.json")) if err != nil { t.Fatalf("titles file: %v", err) } m := map[string]string{} if err := json.Unmarshal(titles, &m); err != nil || m["chat-1.jsonl"] != "fix the retry test" { t.Errorf("title = %q (err=%v), want legacy summary", m["chat-1.jsonl"], err) } } func TestMigrateLegacySessionsDeadWorkspaceFallsBackToGlobal(t *testing.T) { src := t.TempDir() global := t.TempDir() projRoot := t.TempDir() router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") } os.WriteFile(filepath.Join(src, "chat-1.events.jsonl"), []byte(legacyEventLog), 0o644) writeLegacyMeta(t, src, "chat-1", filepath.Join(src, "no-such-workspace"), "") n, err := MigrateLegacySessions(src, global, router) if err != nil || n != 1 { t.Fatalf("migrate: n=%d err=%v, want 1 nil", n, err) } if _, err := os.Stat(filepath.Join(global, "chat-1.jsonl")); err != nil { t.Errorf("session with a dead workspace should fall back to the global dir: %v", err) } } func TestMigrateLegacySessionsRehomesFlatImport(t *testing.T) { src := t.TempDir() global := t.TempDir() workspace := t.TempDir() projRoot := t.TempDir() router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") } srcLog := filepath.Join(src, "chat-1.events.jsonl") os.WriteFile(srcLog, []byte(legacyEventLog), 0o644) writeLegacyMeta(t, src, "chat-1", workspace, "fix the retry test") // Simulate the old flat import: file in the global dir, mtime stamped from // the legacy event log. flat := filepath.Join(global, "chat-1.jsonl") os.WriteFile(flat, []byte(`{"role":"user","content":"flat-imported"}`+"\n"), 0o644) info, err := os.Stat(srcLog) if err != nil { t.Fatal(err) } if err := os.Chtimes(flat, info.ModTime(), info.ModTime()); err != nil { t.Fatal(err) } n, err := MigrateLegacySessions(src, global, router) if err != nil || n != 1 { t.Fatalf("migrate: n=%d err=%v, want 1 nil", n, err) } moved := filepath.Join(projRoot, filepath.Base(workspace), "sessions", "chat-1.jsonl") b, err := os.ReadFile(moved) if err != nil { t.Fatalf("re-homed session missing: %v", err) } if !strings.Contains(string(b), "flat-imported") { t.Errorf("re-home must move the existing import, not reconstruct: %s", b) } if _, err := os.Stat(flat); !os.IsNotExist(err) { t.Errorf("flat import should be moved out of the global dir") } } func TestMigrateLegacySessionsKeepsNativeSameNameSession(t *testing.T) { src := t.TempDir() global := t.TempDir() workspace := t.TempDir() projRoot := t.TempDir() router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") } srcLog := filepath.Join(src, "chat-1.events.jsonl") os.WriteFile(srcLog, []byte(legacyEventLog), 0o644) old := time.Now().Add(-48 * time.Hour) if err := os.Chtimes(srcLog, old, old); err != nil { t.Fatal(err) } writeLegacyMeta(t, src, "chat-1", workspace, "") // A native v1+ session that happens to share the name: mtime won't match // the legacy log, so it must stay where it is. native := filepath.Join(global, "chat-1.jsonl") os.WriteFile(native, []byte(`{"role":"user","content":"native"}`+"\n"), 0o644) if _, err := MigrateLegacySessions(src, global, router); err != nil { t.Fatalf("migrate: %v", err) } b, err := os.ReadFile(native) if err != nil || !strings.Contains(string(b), "native") { t.Errorf("native global session must be left intact: err=%v body=%s", err, b) } if _, err := os.Stat(filepath.Join(projRoot, filepath.Base(workspace), "sessions", "chat-1.jsonl")); err != nil { t.Errorf("legacy session should still be reconstructed into its workspace dir: %v", err) } } func TestMigrateLegacySessionsSkipsEmptyLog(t *testing.T) { src := t.TempDir() dest := t.TempDir() os.WriteFile(filepath.Join(src, "empty.events.jsonl"), []byte(`{"type":"model.turn.started","id":1,"ts":"t","turn":0}`+"\n"), 0o644) n, err := MigrateLegacySessions(src, dest, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 0 { t.Errorf("a log with no user/assistant/tool messages should not produce a session, imported %d", n) } } const legacyMessageLog = `{"role":"user","content":"hello from v0.x"} {"role":"assistant","content":"hi there","tool_calls":[{"id":"call_1","name":"read_file","arguments":"{\"path\":\"main.go\"}"}]} {"role":"tool","tool_call_id":"call_1","name":"read_file","content":"package main"} {"role":"assistant","content":"I found the file."} ` func TestMigrateLegacySessionsImportsJsonlOnly(t *testing.T) { src := t.TempDir() dest := t.TempDir() os.WriteFile(filepath.Join(src, "acp-chat.jsonl"), []byte(legacyMessageLog), 0o644) writeLegacyMeta(t, src, "acp-chat", "", "ACP session about main.go") n, err := MigrateLegacySessions(src, dest, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Fatalf("imported %d sessions, want 1 (jsonl-only)", n) } destPath := filepath.Join(dest, "acp-chat.jsonl") data, err := os.ReadFile(destPath) if err != nil { t.Fatalf("imported session missing: %v", err) } if !strings.Contains(string(data), `"hello from v0.x"`) { t.Errorf("imported content wrong:\n%s", data) } // Title from the meta sidecar should be stored. titles, err := os.ReadFile(filepath.Join(dest, ".titles.json")) if err != nil { t.Fatalf("titles file: %v", err) } m := map[string]string{} if err := json.Unmarshal(titles, &m); err != nil || m["acp-chat.jsonl"] != "ACP session about main.go" { t.Errorf("title = %q (err=%v), want ACP session about main.go", m["acp-chat.jsonl"], err) } // Marker must be stamped. if _, err := os.Stat(filepath.Join(dest, legacyJsonlPassMarker)); err != nil { t.Errorf("jsonl pass marker missing: %v", err) } } func TestMigrateLegacySessionsPrefersJsonlWhenNewer(t *testing.T) { src := t.TempDir() dest := t.TempDir() eventsPath := filepath.Join(src, "chat-1.events.jsonl") jsonlPath := filepath.Join(src, "chat-1.jsonl") // Write the event log first (older mtime). os.WriteFile(eventsPath, []byte(legacyEventLog), 0o644) time.Sleep(10 * time.Millisecond) // ensure mtime differs // Write the .jsonl second (newer mtime) — it should be preferred. os.WriteFile(jsonlPath, []byte(legacyMessageLog), 0o644) writeLegacyMeta(t, src, "chat-1", "", "newer jsonl wins") n, err := MigrateLegacySessions(src, dest, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Fatalf("imported %d, want 1", n) } data, err := os.ReadFile(filepath.Join(dest, "chat-1.jsonl")) if err != nil { t.Fatal(err) } // The .jsonl content ("hello from v0.x") should win over the reconstructed // event log content ("list the files"). if !strings.Contains(string(data), `"hello from v0.x"`) { t.Errorf("expected .jsonl content to be preferred:\n%s", data) } } func TestMigrateLegacySessionsFallsBackToEventsWhenJsonlOlder(t *testing.T) { src := t.TempDir() dest := t.TempDir() jsonlPath := filepath.Join(src, "chat-1.jsonl") eventsPath := filepath.Join(src, "chat-1.events.jsonl") // Write the .jsonl first (older mtime). os.WriteFile(jsonlPath, []byte(legacyMessageLog), 0o644) time.Sleep(10 * time.Millisecond) // Write the events log second (newer mtime) — events should be reconstructed. os.WriteFile(eventsPath, []byte(legacyEventLog), 0o644) writeLegacyMeta(t, src, "chat-1", "", "events are newer") n, err := MigrateLegacySessions(src, dest, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Fatalf("imported %d, want 1", n) } data, err := os.ReadFile(filepath.Join(dest, "chat-1.jsonl")) if err != nil { t.Fatal(err) } // The reconstructed event-log content ("list the files") should win because // the events log has a newer mtime. if !strings.Contains(string(data), `"list the files"`) { t.Errorf("expected events content to be reconstructed:\n%s", data) } } func TestMigrateLegacySessionsImportsJsonlBakFallback(t *testing.T) { src := t.TempDir() dest := t.TempDir() // Only a .jsonl.bak — no .jsonl, no .events.jsonl. Should recover from bak. os.WriteFile(filepath.Join(src, "recovered.jsonl.bak"), []byte(legacyMessageLog), 0o644) writeLegacyMeta(t, src, "recovered", "", "recovered from bak") n, err := MigrateLegacySessions(src, dest, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Fatalf("imported %d, want 1 (.bak recovery)", n) } data, err := os.ReadFile(filepath.Join(dest, "recovered.jsonl")) if err != nil { t.Fatalf("recovered session missing: %v", err) } if !strings.Contains(string(data), `"hello from v0.x"`) { t.Errorf("recovered content wrong:\n%s", data) } } func TestMigrateLegacySessionsSkipsBakWhenJsonlExists(t *testing.T) { src := t.TempDir() dest := t.TempDir() // Both .jsonl and .jsonl.bak exist — prefer .jsonl. os.WriteFile(filepath.Join(src, "chat.jsonl"), []byte(legacyMessageLog), 0o644) os.WriteFile(filepath.Join(src, "chat.jsonl.bak"), []byte(`{"role":"user","content":"stale backup"}`+"\n"), 0o644) writeLegacyMeta(t, src, "chat", "", "from jsonl not bak") n, err := MigrateLegacySessions(src, dest, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Fatalf("imported %d, want 1", n) } data, err := os.ReadFile(filepath.Join(dest, "chat.jsonl")) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), `"hello from v0.x"`) { t.Errorf("expected .jsonl content, not .bak:\n%s", data) } } func TestMigrateLegacySessionsSkipsNonMessageJsonl(t *testing.T) { src := t.TempDir() dest := t.TempDir() // A .jsonl file that is NOT in message format (starts with event-log "id"). os.WriteFile(filepath.Join(src, "bad.jsonl"), []byte(`{"id":1,"type":"model.turn.started"}`+"\n"), 0o644) n, err := MigrateLegacySessions(src, dest, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 0 { t.Errorf("non-message .jsonl should be skipped, imported %d", n) } } func TestMigrateLegacySessionsRecursesIntoSubdirectories(t *testing.T) { src := t.TempDir() global := t.TempDir() workspace := t.TempDir() projRoot := t.TempDir() router := func(ws string) string { return filepath.Join(projRoot, filepath.Base(ws), "sessions") } // Set up a project-scoped subdirectory with sessions. subDir := filepath.Join(src, "Users_Yuki_git_polytone-audio-engine") if err := os.MkdirAll(subDir, 0o755); err != nil { t.Fatal(err) } os.WriteFile(filepath.Join(subDir, "proj-chat.events.jsonl"), []byte(legacyEventLog), 0o644) writeLegacyMeta(t, subDir, "proj-chat", workspace, "project session") // Also add a subdirectory that has no session files — should be skipped. emptySub := filepath.Join(src, "empty-dir") os.MkdirAll(emptySub, 0o755) n, err := MigrateLegacySessions(src, global, router) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Fatalf("imported %d, want 1 (subdirectory session)", n) } // Should land in the project session dir, not global. projDest := filepath.Join(projRoot, filepath.Base(workspace), "sessions", "proj-chat.jsonl") if _, err := os.Stat(projDest); err != nil { t.Errorf("subdirectory session not in project dir %s: %v", projDest, err) } if _, err := os.Stat(filepath.Join(global, "proj-chat.jsonl")); !os.IsNotExist(err) { t.Errorf("subdirectory session should not land in global dir") } } func TestMigrateLegacySessionsJsonlPassIsIdempotent(t *testing.T) { src := t.TempDir() dest := t.TempDir() os.WriteFile(filepath.Join(src, "desktop-session.jsonl"), []byte(legacyMessageLog), 0o644) // First run imports it. n, err := MigrateLegacySessions(src, dest, nil) if err != nil || n != 1 { t.Fatalf("first run: n=%d err=%v, want 1", n, err) } // Delete the imported session. os.Remove(filepath.Join(dest, "desktop-session.jsonl")) // Second run: jsonl pass marker exists, must not re-import. n, err = MigrateLegacySessions(src, dest, nil) if err != nil || n != 0 { t.Fatalf("second run must be no-op: n=%d err=%v", n, err) } if _, err := os.Stat(filepath.Join(dest, "desktop-session.jsonl")); !os.IsNotExist(err) { t.Errorf("deleted session must not reappear after jsonl pass marker") } } func TestMigrateLegacySessionsJsonlPassRunsForExistingUpgrader(t *testing.T) { src := t.TempDir() dest := t.TempDir() os.WriteFile(filepath.Join(src, "acp-chat.jsonl"), []byte(legacyMessageLog), 0o644) // Simulate an upgrader whose events pass already completed in a prior // version: the routed marker is stamped but the v3-jsonl marker is not. writeImportMarkers(dest, legacyRoutedHomeImportMarker) n, err := MigrateLegacySessions(src, dest, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Fatalf("imported %d, want 1 (.jsonl-only must reach existing upgraders)", n) } if _, err := os.Stat(filepath.Join(dest, "acp-chat.jsonl")); err != nil { t.Errorf("jsonl-only session not imported for existing upgrader: %v", err) } } // legacyNestedFunctionLog uses the OpenAI-style nested-function tool-call format // that the TS version wrote: name and arguments live under "function". const legacyNestedFunctionLog = `{"role":"user","content":"read the file"} {"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"main.go\"}"}}],"reasoning_content":"need to read it"} {"role":"tool","tool_call_id":"call_1","name":"read_file","content":"package main\nfunc main() {}"} {"role":"assistant","content":"Found the main function."} ` func TestTransformAndCopyJsonlFlattensNestedToolCalls(t *testing.T) { src := t.TempDir() dest := t.TempDir() os.WriteFile(filepath.Join(src, "chat.jsonl"), []byte(legacyNestedFunctionLog), 0o644) writeLegacyMeta(t, src, "chat", "", "nested tool calls test") n, err := MigrateLegacySessions(src, dest, nil) if err != nil { t.Fatalf("migrate: %v", err) } if n != 1 { t.Fatalf("imported %d, want 1", n) } // Reload and verify the tool calls are flat, not nested. loaded, err := LoadSession(filepath.Join(dest, "chat.jsonl")) if err != nil { t.Fatal(err) } msgs := loaded.Messages if len(msgs) != 4 { t.Fatalf("message count = %d, want 4", len(msgs)) } // Message 1: assistant with tool call. if len(msgs[1].ToolCalls) != 1 { t.Fatalf("assistant tool_calls = %d, want 1", len(msgs[1].ToolCalls)) } tc := msgs[1].ToolCalls[0] if tc.ID != "call_1" { t.Errorf("tool call id = %q, want call_1", tc.ID) } if tc.Name != "read_file" { t.Errorf("tool call name = %q, want read_file", tc.Name) } if tc.Arguments != `{"path":"main.go"}` { t.Errorf("tool call arguments = %q, want {\"path\":\"main.go\"}", tc.Arguments) } // Message 2: tool result. if msgs[2].Role != provider.RoleTool || msgs[2].ToolCallID != "call_1" || msgs[2].Name != "read_file" { t.Errorf("tool result = %+v, want tool result for call_1", msgs[2]) } // Message 3: final assistant text. if msgs[3].Content != "Found the main function." { t.Errorf("final content = %q", msgs[3].Content) } }