Files
2026-07-13 13:00:08 +08:00

1064 lines
37 KiB
Go

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)
}
}